Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,15 @@ <h5 id="cfg-view-heading" class="text-sm text-muted" style="margin: 0">
>
<span class="material-symbols-outlined">point_scan</span>
</button>
<button
class="btn-icon btn-neutral"
id="btn-label-mode"
aria-pressed="false"
aria-label="Label mode"
title="Toggle labeling mode"
>
<span class="material-symbols-outlined">ink_highlighter</span>
</button>
<!-- Collapse/expand side panels -->
<button
class="btn-icon btn-neutral"
Expand Down
205 changes: 205 additions & 0 deletions src/charts/timeSeries.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { TimeSeriesLabel } from '@domain/timeSeries';
import { hexToRgba, uuid } from '@shared/misc';

import { installModalFocusTrap } from '../ui/dom';

import { init, type EChartOption, type ECharts } from './echarts';
Expand All @@ -7,6 +10,17 @@ interface DataZoomEvent {
readonly end?: number;
}

interface LabelOption {
readonly value: string;
readonly label: string;
readonly color?: string;
}

interface LabelDropdownEl extends HTMLElement {
value: string;
options: LabelOption[];
}

/**
* Interface for time series data - clean abstraction for different data sources
*/
Expand All @@ -17,6 +31,8 @@ export interface TimeSeriesData {
getData(xColumn: string, yColumn: string): ReadonlyArray<readonly [number, number]>;
isLabeled(): boolean;
setLabeled(labeled: boolean): void;
getLabels(): ReadonlyArray<TimeSeriesLabel>;
addLabel(label: TimeSeriesLabel): void;
}

/**
Expand Down Expand Up @@ -71,6 +87,11 @@ export class TimeSeriesChart {
private chartConfigCleanup: (() => void) | null = null;
private lastConfig: TimeSeriesConfig | null = null;
private currentData: ReadonlyArray<readonly [number | string, number]> = [];
private labelMode = false;
private labelOverlay: HTMLDivElement | null = null;
private labelStartX: number | null = null;
private activeLabelRect: HTMLDivElement | null = null;
private activeLabelLine: HTMLDivElement | null = null;
private readonly handleZoomEvent = (params: DataZoomEvent): void => {
this.updateYAxisFromZoom(params.start, params.end);
};
Expand Down Expand Up @@ -112,6 +133,23 @@ export class TimeSeriesChart {
this.chart = chartInstance;
chartInstance.setOption(option);

// Create label overlay after chart initialization so ECharts doesn't remove it
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.right = '0';
overlay.style.bottom = '0';
overlay.style.left = '0';
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '10';
overlay.style.touchAction = 'none';
container.appendChild(overlay);
overlay.addEventListener('pointerdown', this.handleLabelStart);
overlay.addEventListener('pointermove', this.handleLabelMove);
overlay.addEventListener('pointerup', this.handleLabelEnd);
overlay.addEventListener('pointerleave', this.handleLabelEnd);
this.labelOverlay = overlay;

// Create empty state element AFTER chart is ready
this.createEmptyStateElement();

Expand Down Expand Up @@ -402,6 +440,147 @@ export class TimeSeriesChart {
}
}

toggleLabelMode(): boolean {
this.labelMode = !this.labelMode;
if (this.labelOverlay) {
this.labelOverlay.style.pointerEvents = this.labelMode ? 'auto' : 'none';
}
if (!this.labelMode) {
this.cleanupActiveRect();
}
return this.labelMode;
}

private getActiveLabelDefinition(): { name: string; color: string } | null {
const dropdown = document.getElementById('active-label') as LabelDropdownEl | null;
if (!dropdown) {
return null;
}
const val = dropdown.value;
const opt = dropdown.options.find((o) => o.value === val);
if (!opt || !opt.color) {
return null;
}
return { name: opt.label, color: opt.color };
}

private handleLabelStart = (e: PointerEvent): void => {
if (!this.labelMode || !this.labelOverlay) {
return;
}

const def = this.getActiveLabelDefinition();
if (!def) {
return;
}

e.stopPropagation();
e.preventDefault();
this.labelOverlay.setPointerCapture(e.pointerId);

const startX = e.offsetX;
this.labelStartX = startX;

const line = document.createElement('div');
line.style.position = 'absolute';
line.style.top = '0';
line.style.bottom = '0';
line.style.left = String(startX) + 'px';
line.style.width = '1px';
line.style.background = def.color;
line.style.pointerEvents = 'none';
this.labelOverlay.appendChild(line);
this.activeLabelLine = line;

const rect = document.createElement('div');
rect.style.position = 'absolute';
rect.style.top = '0';
rect.style.bottom = '0';
rect.style.left = String(startX) + 'px';
rect.style.background = hexToRgba(def.color, 0.3);
rect.style.pointerEvents = 'none';
this.labelOverlay.appendChild(rect);
this.activeLabelRect = rect;
};

private handleLabelMove = (e: PointerEvent): void => {
if (!this.labelMode || this.labelStartX === null || !this.activeLabelRect) {
return;
}

e.stopPropagation();
e.preventDefault();

const currentX = e.offsetX;
const left = Math.min(this.labelStartX, currentX);
const width = Math.abs(currentX - this.labelStartX);
this.activeLabelRect.style.left = String(left) + 'px';
this.activeLabelRect.style.width = String(width) + 'px';
};

private handleLabelEnd = (e: PointerEvent): void => {
if (
!this.labelMode ||
this.labelStartX === null ||
!this.chart ||
!this.activeLabelRect ||
!this.activeLabelLine
) {
this.cleanupActiveRect();
return;
}

e.stopPropagation();
e.preventDefault();

if (this.labelOverlay) {
this.labelOverlay.releasePointerCapture(e.pointerId);
}

const endX = e.offsetX;
const startPx = this.labelStartX;
const start = this.chart.convertFromPixel({ xAxisIndex: 0 }, [
Math.min(startPx, endX),
0,
])[0] as number;
const end = this.chart.convertFromPixel({ xAxisIndex: 0 }, [
Math.max(startPx, endX),
0,
])[0] as number;

this.cleanupActiveRect();

const def = this.getActiveLabelDefinition();
if (!def || start === end) {
return;
}

const label: TimeSeriesLabel = {
id: uuid(),
name: def.name,
startTime: Math.min(start, end),
endTime: Math.max(start, end),
color: def.color,
};
const source = this.getCurrentSource();
source?.addLabel(label);
if (this.lastConfig) {
this.updateDisplay(this.lastConfig);
}
};

private cleanupActiveRect(): void {
if (this.activeLabelRect && this.labelOverlay) {
this.labelOverlay.removeChild(this.activeLabelRect);
}
if (this.activeLabelLine && this.labelOverlay) {
this.labelOverlay.removeChild(this.activeLabelLine);
}
this.activeLabelRect = null;
this.activeLabelLine = null;
this.labelStartX = null;
}

/**
* Update chart display with current configuration
*/
Expand All @@ -425,6 +604,7 @@ export class TimeSeriesChart {
}

const data = source.getData(config.xColumn, config.yColumn);
const labels = source.getLabels();

// Apply configuration with defaults
const xType = config.xType || 'category';
Expand Down Expand Up @@ -489,6 +669,16 @@ export class TimeSeriesChart {
axisPointer: { type: 'cross' as const, snap },
};

const markArea =
labels.length > 0
? ({
data: labels.map((l) => [
{ xAxis: l.startTime, itemStyle: { color: hexToRgba(l.color, 0.3) } },
{ xAxis: l.endTime },
]),
} as unknown as EChartOption.SeriesLine['markArea'])
: undefined;

this.chart.setOption(
{
tooltip,
Expand All @@ -504,6 +694,7 @@ export class TimeSeriesChart {
lineStyle: { width: lineWidth },
areaStyle: showArea ? {} : undefined,
markLine,
markArea,
data: [...seriesData] as Array<[number | string, number]>,
},
],
Expand Down Expand Up @@ -613,6 +804,14 @@ export class TimeSeriesChart {
this.emptyStateElement.remove();
this.emptyStateElement = null;
}
if (this.labelOverlay) {
this.labelOverlay.removeEventListener('pointerdown', this.handleLabelStart);
this.labelOverlay.removeEventListener('pointermove', this.handleLabelMove);
this.labelOverlay.removeEventListener('pointerup', this.handleLabelEnd);
this.labelOverlay.removeEventListener('pointerleave', this.handleLabelEnd);
this.labelOverlay.remove();
this.labelOverlay = null;
}
}

/**
Expand Down Expand Up @@ -741,6 +940,7 @@ function bindUIControls(chart: TimeSeriesChart): void {
const elPrev = document.getElementById('series-prev') as HTMLButtonElement | null;
const elNext = document.getElementById('series-next') as HTMLButtonElement | null;
const btnToggleLabeled = document.getElementById('toggle-labeled') as HTMLButtonElement | null;
const btnLabelMode = document.getElementById('btn-label-mode') as HTMLButtonElement | null;

elPrev?.addEventListener('click', () => {
chart.previousSeries();
Expand All @@ -752,6 +952,11 @@ function bindUIControls(chart: TimeSeriesChart): void {
chart.toggleLabeled();
});

btnLabelMode?.addEventListener('click', () => {
const active = chart.toggleLabelMode();
btnLabelMode.setAttribute('aria-pressed', String(active));
});

// Bind axis dropdown changes
const xDropdown = document.querySelector('#x-axis');
const yDropdown = document.querySelector('#y-axis');
Expand Down
26 changes: 20 additions & 6 deletions src/data/csvProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* CSV data processing and TimeSeriesData implementation
*/

import type { TimeSeriesLabel } from '@domain/timeSeries';

import type { TimeSeriesData } from '../charts/timeSeries';
import type { TDataFile } from '../uploads';

Expand All @@ -15,6 +17,7 @@ export class CSVTimeSeriesData implements TimeSeriesData {
private parsedData: ReadonlyArray<readonly number[]> = [];
private labeled = false;
private sourceFile: TDataFile;
private labels: TimeSeriesLabel[] = [];

constructor(file: TDataFile) {
this.id = file.id;
Expand All @@ -26,12 +29,8 @@ export class CSVTimeSeriesData implements TimeSeriesData {
this.columns = columns;
this.parsedData = data;

// Initialize labeled state from file data
if (file.labeled) {
this.labeled = file.labeled;
} else {
this.labeled = false;
}
this.labels = Array.isArray(file.labels) ? file.labels : [];
this.labeled = file.labeled || this.labels.length > 0;
}

getData(xColumn: string, yColumn: string): ReadonlyArray<readonly [number, number]> {
Expand Down Expand Up @@ -66,6 +65,21 @@ export class CSVTimeSeriesData implements TimeSeriesData {
});
window.dispatchEvent(event);
}

getLabels(): ReadonlyArray<TimeSeriesLabel> {
return this.labels;
}

addLabel(label: TimeSeriesLabel): void {
this.labels = [...this.labels, label];
this.labeled = true;
this.sourceFile.labels = this.labels;
this.sourceFile.labeled = true;
const event = new CustomEvent('timelab:labelsChanged', {
detail: { fileId: this.sourceFile.id, labels: this.labels },
});
window.dispatchEvent(event);
}
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/shared/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,12 @@ export const formatBytes = (bytes: number): string => {

return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
};

export function hexToRgba(hex: string, alpha: number): string {
const normalized = hex.replace('#', '');
const bigint = parseInt(normalized, 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return 'rgba(' + String(r) + ', ' + String(g) + ', ' + String(b) + ', ' + String(alpha) + ')';
}
Loading
Loading