| |
| |
| |
| |
| import * as d3 from 'd3'; |
| import { showDialog } from '../../shared/ui/dialog'; |
| import { tr } from '../../shared/lang/i18n-lite'; |
| import type { TextAnalysisAPI } from '../../shared/api/GLTR_API'; |
| import { |
| STATS_PERIOD_HOURS, |
| STATS_SLOTS_PER_LOCAL_DAY, |
| STATS_SLOTS_PER_LOCAL_WEEK, |
| STATS_UTC_HOUR_FMT, |
| } from './visitStatsContract'; |
|
|
| type TimelineMetric = 'active_visits' | 'active_sec'; |
| type TimelineBin = { hour: string; active_visits: number; active_sec: number }; |
| type TimelineGranularity = 'hour' | 'day'; |
| type TimelineOverlay = 'none' | 'day' | 'week'; |
| type ChartPoint = { t: Date; value: number }; |
|
|
| const METRIC_OPTIONS: { value: TimelineMetric; label: string }[] = [ |
| { value: 'active_visits', label: 'Active visits' }, |
| { value: 'active_sec', label: 'Active time' }, |
| ]; |
|
|
| function binCount(b: TimelineBin, metric: TimelineMetric): number { |
| return b[metric] ?? 0; |
| } |
|
|
| function formatMetricValue(n: number, metric: TimelineMetric): string { |
| if (metric === 'active_sec') return `${n}s`; |
| const unit = n === 1 ? 'active visit' : 'active visits'; |
| return `${n} ${unit}`; |
| } |
|
|
| function metricCountLabel(metric: TimelineMetric): string { |
| if (metric === 'active_sec') return 'active time (s)'; |
| return 'active visits'; |
| } |
|
|
| const CHART_WIDTH = 560; |
| const CHART_HEIGHT = 220; |
| const MARGIN = { top: 12, right: 12, bottom: 36, left: 40 }; |
| const BAR_COLOR = '#22c55e'; |
| const WEEKEND_BG = 'rgba(128, 128, 128, 0.14)'; |
| const parseUtcHour = d3.utcParse(STATS_UTC_HOUR_FMT)!; |
|
|
| function localDayKey(d: Date): string { |
| const y = d.getFullYear(); |
| const m = String(d.getMonth() + 1).padStart(2, '0'); |
| const day = String(d.getDate()).padStart(2, '0'); |
| return `${y}-${m}-${day}`; |
| } |
|
|
| function parseLocalDay(key: string): Date { |
| const [y, m, day] = key.split('-').map((s) => parseInt(s, 10)); |
| return new Date(y, m - 1, day); |
| } |
|
|
| function addLocalDays(t: Date, n: number): Date { |
| return new Date(t.getFullYear(), t.getMonth(), t.getDate() + n); |
| } |
|
|
| function formatLocalDateTime(d: Date): string { |
| return d.toLocaleString(); |
| } |
|
|
| function formatLocalDate(d: Date): string { |
| return d.toLocaleDateString(); |
| } |
|
|
| function isLocalWeekend(d: Date): boolean { |
| const day = d.getDay(); |
| return day === 0 || day === 6; |
| } |
|
|
| function formatLocalAxisHour(d: Date): string { |
| if (d.getHours() === 0 && d.getMinutes() === 0) { |
| return formatLocalDate(d); |
| } |
| return d.toLocaleString(undefined, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); |
| } |
|
|
| const LOCAL_WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const; |
|
|
| function formatOverlayDayAxis(d: Date): string { |
| return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); |
| } |
|
|
| function formatOverlayWeekAxis(d: Date): string { |
| return LOCAL_WEEKDAY_LABELS[d.getDay()]; |
| } |
|
|
| function formatOverlayWeekHourAxis(d: Date): string { |
| return `${formatOverlayWeekAxis(d)} ${formatOverlayDayAxis(d)}`; |
| } |
|
|
| const WEEK_HOUR_SLOTS = STATS_SLOTS_PER_LOCAL_WEEK; |
|
|
| type OverlayView = 'none' | 'day24' | 'week168' | 'week7'; |
|
|
| function resolveOverlayView(granularity: TimelineGranularity, overlay: TimelineOverlay): OverlayView { |
| if (overlay === 'none') return 'none'; |
| if (overlay === 'day') return 'day24'; |
| return granularity === 'hour' ? 'week168' : 'week7'; |
| } |
|
|
| function overlayHourBinEnd(t: Date): Date { |
| return new Date( |
| t.getFullYear(), |
| t.getMonth(), |
| t.getDate(), |
| t.getHours() + STATS_PERIOD_HOURS, |
| ); |
| } |
|
|
| function timelineDialogFullscreenExpanded(el: HTMLElement): boolean { |
| return document.fullscreenElement === el; |
| } |
|
|
| async function toggleTimelineDialogFullscreen(el: HTMLElement): Promise<void> { |
| if (document.fullscreenElement === el) { |
| await document.exitFullscreen(); |
| return; |
| } |
| if (document.fullscreenElement) { |
| await document.exitFullscreen(); |
| } |
| await el.requestFullscreen(); |
| } |
|
|
| function measureChart(host: HTMLDivElement, fullscreen: boolean): { width: number; height: number } { |
| if (!fullscreen) { |
| return { width: CHART_WIDTH, height: CHART_HEIGHT }; |
| } |
| const r = host.getBoundingClientRect(); |
| const w = Math.max(320, Math.floor(r.width)); |
| const h = Math.max(200, Math.floor(r.height)); |
| if (w > 0 && h > 0) { |
| return { width: w, height: h }; |
| } |
| return { width: Math.max(320, window.innerWidth - 64), height: Math.max(200, window.innerHeight - 120) }; |
| } |
|
|
| function utcHourKey(d: Date): string { |
| return d3.utcFormat(STATS_UTC_HOUR_FMT)(d3.utcHour.floor(d)); |
| } |
|
|
| |
| function fillHourGaps(bins: TimelineBin[], metric: TimelineMetric): ChartPoint[] { |
| if (bins.length === 0) return []; |
| const map = new Map(bins.map((b) => [b.hour, binCount(b, metric)])); |
| const start = d3.utcHour.floor(parseUtcHour(bins[0].hour)!); |
| const end = d3.utcHour.floor(parseUtcHour(bins[bins.length - 1].hour)!); |
| const out: ChartPoint[] = []; |
| for (let t = start; t <= end; t = d3.utcHour.offset(t, STATS_PERIOD_HOURS)) { |
| const key = utcHourKey(t); |
| out.push({ t, value: map.get(key) ?? 0 }); |
| } |
| return out; |
| } |
|
|
| function aggregateToLocalDayBins( |
| bins: TimelineBin[], |
| metric: TimelineMetric, |
| ): { day: string; value: number }[] { |
| const map = new Map<string, number>(); |
| for (const b of bins) { |
| const key = localDayKey(parseUtcHour(b.hour)!); |
| map.set(key, (map.get(key) ?? 0) + binCount(b, metric)); |
| } |
| return [...map.keys()].sort().map((day) => ({ day, value: map.get(day)! })); |
| } |
|
|
| function fillLocalDayGaps(bins: { day: string; value: number }[]): ChartPoint[] { |
| if (bins.length === 0) return []; |
| const map = new Map(bins.map((b) => [b.day, b.value])); |
| let start = parseLocalDay(bins[0].day); |
| const end = parseLocalDay(bins[bins.length - 1].day); |
| const out: ChartPoint[] = []; |
| for (let t = start; t <= end; t = addLocalDays(t, 1)) { |
| const key = localDayKey(t); |
| out.push({ t, value: map.get(key) ?? 0 }); |
| } |
| return out; |
| } |
|
|
| function binsToChartPoints( |
| bins: TimelineBin[], |
| granularity: TimelineGranularity, |
| metric: TimelineMetric, |
| ): ChartPoint[] { |
| if (granularity === 'hour') return fillHourGaps(bins, metric); |
| return fillLocalDayGaps(aggregateToLocalDayBins(bins, metric)); |
| } |
|
|
| |
| function overlayToLocalDayDistribution(bins: TimelineBin[], metric: TimelineMetric): ChartPoint[] { |
| const sums = new Array<number>(STATS_SLOTS_PER_LOCAL_DAY).fill(0); |
| for (const b of bins) { |
| const t = parseUtcHour(b.hour); |
| if (!t) continue; |
| sums[t.getHours()] += binCount(b, metric); |
| } |
| const ref = new Date(2000, 0, 1); |
| return sums.map((value, h) => ({ |
| t: new Date(ref.getFullYear(), ref.getMonth(), ref.getDate(), h), |
| value, |
| })); |
| } |
|
|
| |
| function overlayToLocalWeekHourDistribution(bins: TimelineBin[], metric: TimelineMetric): ChartPoint[] { |
| const sums = new Array<number>(WEEK_HOUR_SLOTS).fill(0); |
| for (const b of bins) { |
| const t = parseUtcHour(b.hour); |
| if (!t) continue; |
| sums[t.getDay() * STATS_SLOTS_PER_LOCAL_DAY + t.getHours()] += binCount(b, metric); |
| } |
| const refSunday = new Date(2000, 0, 2); |
| return sums.map((value, idx) => { |
| const dow = Math.floor(idx / STATS_SLOTS_PER_LOCAL_DAY); |
| const hour = idx % STATS_SLOTS_PER_LOCAL_DAY; |
| return { |
| t: new Date(refSunday.getFullYear(), refSunday.getMonth(), refSunday.getDate() + dow, hour), |
| value, |
| }; |
| }); |
| } |
|
|
| |
| function overlayToLocalWeekDistribution(bins: TimelineBin[], metric: TimelineMetric): ChartPoint[] { |
| const sums = new Array<number>(7).fill(0); |
| for (const { day, value } of aggregateToLocalDayBins(bins, metric)) { |
| sums[parseLocalDay(day).getDay()] += value; |
| } |
| const refSunday = new Date(2000, 0, 2); |
| return sums.map((value, dow) => ({ |
| t: addLocalDays(refSunday, dow), |
| value, |
| })); |
| } |
|
|
| function prepareChartData( |
| bins: TimelineBin[], |
| granularity: TimelineGranularity, |
| overlay: TimelineOverlay, |
| metric: TimelineMetric, |
| ): ChartPoint[] { |
| const view = resolveOverlayView(granularity, overlay); |
| if (view === 'day24') return overlayToLocalDayDistribution(bins, metric); |
| if (view === 'week168') return overlayToLocalWeekHourDistribution(bins, metric); |
| if (view === 'week7') return overlayToLocalWeekDistribution(bins, metric); |
| return binsToChartPoints(bins, granularity, metric); |
| } |
|
|
| function binEnd(t: Date, granularity: TimelineGranularity, overlay: TimelineOverlay): Date { |
| const view = resolveOverlayView(granularity, overlay); |
| if (view === 'day24' || view === 'week168') return overlayHourBinEnd(t); |
| if (view === 'week7') return addLocalDays(t, 1); |
| return granularity === 'hour' ? d3.utcHour.offset(t, STATS_PERIOD_HOURS) : addLocalDays(t, 1); |
| } |
|
|
| type TimelineXZoomState = { transform: d3.ZoomTransform | null }; |
|
|
| const ZOOM_MIN_POINTS = 2; |
|
|
| function timelineZoomFilter(event: Event, fullscreen: boolean): boolean { |
| if (event.type === 'wheel') { |
| if (!fullscreen && !(event as WheelEvent).ctrlKey) return false; |
| event.preventDefault(); |
| return true; |
| } |
| return !(event as MouseEvent).ctrlKey && (event as MouseEvent).button !== 1; |
| } |
|
|
| function renderTimelineChart( |
| host: d3.Selection<HTMLDivElement, unknown, HTMLElement, unknown>, |
| bins: TimelineBin[], |
| metric: TimelineMetric, |
| granularity: TimelineGranularity, |
| overlay: TimelineOverlay, |
| fullscreen: boolean, |
| xZoom: TimelineXZoomState, |
| ): void { |
| host.selectAll('*').remove(); |
| const data = prepareChartData(bins, granularity, overlay, metric); |
| const countLabel = metricCountLabel(metric); |
| const overlayView = resolveOverlayView(granularity, overlay); |
| if (data.length === 0) { |
| host.append('p').style('margin', '0').style('font-size', '13px').text('No timeline data yet.'); |
| return; |
| } |
|
|
| const hostEl = host.node() as HTMLDivElement; |
| const { width: chartWidth, height: chartHeight } = measureChart(hostEl, fullscreen); |
| const innerW = chartWidth - MARGIN.left - MARGIN.right; |
| const innerH = chartHeight - MARGIN.top - MARGIN.bottom; |
|
|
| const x0 = d3 |
| .scaleTime() |
| .domain([data[0].t, binEnd(data[data.length - 1].t, granularity, overlay)]) |
| .range([0, innerW]); |
|
|
| const tickCount = Math.min(data.length, 16); |
| const xTickFormat = |
| overlayView === 'day24' |
| ? (d: Date | d3.NumberValue) => formatOverlayDayAxis(d as Date) |
| : overlayView === 'week168' |
| ? (d: Date | d3.NumberValue) => formatOverlayWeekHourAxis(d as Date) |
| : overlayView === 'week7' |
| ? (d: Date | d3.NumberValue) => formatOverlayWeekAxis(d as Date) |
| : granularity === 'hour' |
| ? (d: Date | d3.NumberValue) => formatLocalAxisHour(d as Date) |
| : (d: Date | d3.NumberValue) => formatLocalDate(d as Date); |
|
|
| const svg = host |
| .append('svg') |
| .attr('width', chartWidth) |
| .attr('height', chartHeight) |
| .attr('role', 'img') |
| .attr( |
| 'aria-label', |
| overlayView === 'day24' |
| ? `${countLabel} by hour of day (all dates summed, local time).` |
| : overlayView === 'week168' |
| ? `${countLabel} by hour within week (all weeks summed, local time).` |
| : overlayView === 'week7' |
| ? `${countLabel} by weekday (all weeks summed, local time).` |
| : granularity === 'hour' |
| ? `${countLabel} per hour (local time). Drag to pan; Ctrl+wheel or fullscreen wheel to zoom; double-click to reset.` |
| : `${countLabel} per day (local time). Drag to pan; Ctrl+wheel or fullscreen wheel to zoom; double-click to reset.`, |
| ); |
|
|
| const marginTransform = `translate(${MARGIN.left},${MARGIN.top})`; |
| const g = svg.append('g').attr('transform', marginTransform); |
|
|
| const yAxisG = g.append('g').attr('class', 'visit-stats-timeline-y'); |
|
|
| const xAxisG = g.append('g').attr('transform', `translate(0,${innerH})`).attr('class', 'visit-stats-timeline-x'); |
|
|
| const plotG = g.append('g').attr('class', 'visit-stats-timeline-plot'); |
| plotG |
| .append('rect') |
| .attr('class', 'visit-stats-timeline-plot-bg') |
| .attr('width', innerW) |
| .attr('height', innerH) |
| .attr('fill', 'transparent'); |
|
|
| const weekendG = plotG.append('g').attr('class', 'visit-stats-timeline-weekend-bg'); |
|
|
| const tip = host |
| .append('div') |
| .attr('class', 'visit-stats-timeline-tip') |
| .style('position', 'absolute') |
| .style('pointer-events', 'none') |
| .style('visibility', 'hidden') |
| .style('font-size', '12px') |
| .style('padding', '4px 8px') |
| .style('border-radius', '4px') |
| .style('background', 'var(--panel-bg, rgba(0,0,0,0.75))') |
| .style('color', 'var(--text-color, #fff)'); |
|
|
| host.style('position', 'relative'); |
|
|
| const weekendData = overlayView === 'day24' ? [] : data.filter((d) => isLocalWeekend(d.t)); |
|
|
| const tipHtml = (d: ChartPoint) => { |
| const valueLabel = formatMetricValue(d.value, metric); |
| const end = binEnd(d.t, granularity, overlay); |
| if (overlayView === 'day24') { |
| return `${formatOverlayDayAxis(d.t)} – ${formatOverlayDayAxis(end)} (all dates)<br/>${valueLabel}`; |
| } |
| if (overlayView === 'week168') { |
| return `${formatOverlayWeekHourAxis(d.t)} – ${formatOverlayWeekHourAxis(end)} (all weeks)<br/>${valueLabel}`; |
| } |
| if (overlayView === 'week7') { |
| return `${formatOverlayWeekAxis(d.t)} (all weeks)<br/>${valueLabel}`; |
| } |
| if (granularity === 'hour') { |
| return `${formatLocalDateTime(d.t)} – ${formatLocalDateTime(end)}<br/>${valueLabel}`; |
| } |
| return `${formatLocalDate(d.t)}<br/>${valueLabel}`; |
| }; |
|
|
| const barsG = plotG.append('g').attr('class', 'visit-stats-timeline-bars'); |
| barsG |
| .selectAll('rect.visit-stats-timeline-bar') |
| .data(data) |
| .join('rect') |
| .attr('class', 'visit-stats-timeline-bar') |
| .attr('fill', BAR_COLOR) |
| .style('cursor', 'pointer') |
| .attr('opacity', (d) => (d.value > 0 ? 0.85 : 0.15)) |
| .on('mouseenter', function (_event, d) { |
| d3.select(this).attr('opacity', 1); |
| tip.style('visibility', 'visible').html(tipHtml(d)); |
| }) |
| .on('mousemove', (event) => { |
| const box = hostEl.getBoundingClientRect(); |
| tip.style('left', `${event.clientX - box.left + 8}px`).style('top', `${event.clientY - box.top - 28}px`); |
| }) |
| .on('mouseleave', function () { |
| const d = d3.select(this).datum() as ChartPoint; |
| d3.select(this).attr('opacity', d.value > 0 ? 0.85 : 0.15); |
| tip.style('visibility', 'hidden'); |
| }); |
|
|
| const binOverlapsVisibleDomain = (d: ChartPoint, x: d3.ScaleTime<number, number>) => { |
| const [vis0, vis1] = x.domain(); |
| const start = d.t.getTime(); |
| const end = binEnd(d.t, granularity, overlay).getTime(); |
| return end > vis0.getTime() && start < vis1.getTime(); |
| }; |
|
|
| const yForVisible = (x: d3.ScaleTime<number, number>) => { |
| const visible = data.filter((d) => binOverlapsVisibleDomain(d, x)); |
| const yMax = d3.max(visible, (d) => d.value) ?? 1; |
| return d3.scaleLinear().domain([0, yMax]).nice().range([innerH, 0]); |
| }; |
|
|
| const applyView = (x: d3.ScaleTime<number, number>) => { |
| const yVis = yForVisible(x); |
| const yTop = yVis.domain()[1] ?? 1; |
| xAxisG |
| .call(d3.axisBottom(x).tickValues(x.ticks(tickCount)).tickFormat(xTickFormat)) |
| .selectAll('text') |
| .attr('transform', 'rotate(-24)') |
| .style('text-anchor', 'end'); |
| yAxisG |
| .call(d3.axisLeft(yVis).ticks(Math.min(5, yTop + 1)).tickFormat(d3.format('d'))) |
| .call((sel) => sel.select('.domain').remove()); |
| weekendG |
| .selectAll('rect') |
| .data(weekendData) |
| .join('rect') |
| .attr('x', (d) => x(d.t)) |
| .attr('y', 0) |
| .attr('width', (d) => Math.max(1, x(binEnd(d.t, granularity, overlay)) - x(d.t))) |
| .attr('height', innerH) |
| .attr('fill', WEEKEND_BG) |
| .attr('pointer-events', 'none'); |
| barsG |
| .selectAll<SVGRectElement, ChartPoint>('rect.visit-stats-timeline-bar') |
| .attr('x', (d) => x(d.t)) |
| .attr('width', (d) => Math.max(1, x(binEnd(d.t, granularity, overlay)) - x(d.t) - 1)) |
| .attr('y', (d) => yVis(d.value)) |
| .attr('height', (d) => innerH - yVis(d.value)); |
| }; |
|
|
| const initialTransform = xZoom.transform ?? d3.zoomIdentity; |
| applyView(initialTransform.rescaleX(x0)); |
|
|
| if (overlayView !== 'day24' && overlayView !== 'week7' && data.length >= ZOOM_MIN_POINTS) { |
| const zoom = d3 |
| .zoom<SVGGElement, unknown>() |
| .scaleExtent([1, 48]) |
| .translateExtent([ |
| [0, 0], |
| [innerW, innerH], |
| ]) |
| .extent([ |
| [0, 0], |
| [innerW, innerH], |
| ]) |
| .filter((event) => timelineZoomFilter(event, fullscreen)) |
| .on('zoom', (event) => { |
| plotG.attr('transform', null); |
| xZoom.transform = event.transform; |
| applyView(event.transform.rescaleX(x0)); |
| }); |
|
|
| plotG |
| .style('cursor', 'grab') |
| .call(zoom) |
| .call(zoom.transform, initialTransform) |
| .on('dblclick.zoom', (event) => { |
| event.preventDefault(); |
| xZoom.transform = null; |
| plotG.call(zoom.transform, d3.zoomIdentity); |
| }); |
| } |
| } |
|
|
| function syncFullscreenButton(btn: d3.Selection<HTMLButtonElement, unknown, null, undefined>, dialogEl: HTMLElement): void { |
| const on = timelineDialogFullscreenExpanded(dialogEl); |
| btn.text(on ? '×' : '⛶').attr('title', on ? 'Exit fullscreen' : 'Fullscreen'); |
| } |
|
|
| export async function showVisitStatsTimelineDialog(api: TextAnalysisAPI): Promise<void> { |
| let binsCache: TimelineBin[] = []; |
| let metricCache: TimelineMetric = 'active_visits'; |
| let granularityCache: TimelineGranularity = 'hour'; |
| let overlayCache: TimelineOverlay = 'none'; |
| const xZoom: TimelineXZoomState = { transform: null }; |
| let dialogEl: HTMLDivElement | null = null; |
| let chartHost: d3.Selection<HTMLDivElement, unknown, null, undefined> | null = null; |
| let fullscreenBtn: d3.Selection<HTMLButtonElement, unknown, null, undefined> | null = null; |
|
|
| const rerender = () => { |
| if (!dialogEl || !chartHost || chartHost.empty()) return; |
| renderTimelineChart( |
| chartHost, |
| binsCache, |
| metricCache, |
| granularityCache, |
| overlayCache, |
| timelineDialogFullscreenExpanded(dialogEl), |
| xZoom, |
| ); |
| if (fullscreenBtn) syncFullscreenButton(fullscreenBtn, dialogEl); |
| }; |
|
|
| const scheduleRerender = () => requestAnimationFrame(() => rerender()); |
|
|
| const onLayoutChange = () => { |
| if (dialogEl && timelineDialogFullscreenExpanded(dialogEl)) scheduleRerender(); |
| }; |
|
|
| showDialog({ |
| title: 'Visits timeline', |
| content: (dialog) => { |
| const shell = d3.select(dialog.node()!.parentElement!); |
| dialogEl = shell.node() as HTMLDivElement; |
| shell.classed('visit-stats-timeline-dialog', true); |
|
|
| const wrap = dialog |
| .append('div') |
| .attr('class', 'dialog-form-container dialog-form-container--fill'); |
| const toolbar = wrap |
| .append('div') |
| .attr('class', 'visit-stats-timeline-toolbar') |
| .style('display', 'flex') |
| .style('flex-wrap', 'wrap') |
| .style('align-items', 'center') |
| .style('gap', '12px') |
| .style('margin', '0 0 8px') |
| .style('font-size', '13px'); |
|
|
| const metricSelect = toolbar |
| .append('select') |
| .attr('class', 'visit-stats-timeline-metric') |
| .style('width', 'fit-content'); |
| metricSelect |
| .selectAll('option') |
| .data(METRIC_OPTIONS) |
| .join('option') |
| .attr('value', (d) => d.value) |
| .property('selected', (d) => d.value === metricCache) |
| .text((d) => d.label); |
|
|
| toolbar.append('span').text('per'); |
|
|
| const granularitySelect = toolbar |
| .append('select') |
| .attr('class', 'visit-stats-timeline-granularity') |
| .style('width', 'fit-content'); |
| granularitySelect |
| .selectAll('option') |
| .data([ |
| { value: 'hour', label: 'Hour' }, |
| { value: 'day', label: 'Day' }, |
| ] as const) |
| .join('option') |
| .attr('value', (d) => d.value) |
| .property('selected', (d) => d.value === granularityCache) |
| .text((d) => d.label); |
| const overlayDayLabel = toolbar |
| .append('label') |
| .style('display', 'inline-flex') |
| .style('align-items', 'center') |
| .style('gap', '4px') |
| .style('cursor', 'pointer'); |
| const overlayDayInput = overlayDayLabel |
| .append('input') |
| .attr('type', 'checkbox') |
| .property('checked', overlayCache === 'day'); |
| overlayDayLabel.append('span').text('Sum to 1 day'); |
|
|
| const overlayWeekLabel = toolbar |
| .append('label') |
| .style('display', 'inline-flex') |
| .style('align-items', 'center') |
| .style('gap', '4px') |
| .style('cursor', 'pointer'); |
| const overlayWeekInput = overlayWeekLabel |
| .append('input') |
| .attr('type', 'checkbox') |
| .property('checked', overlayCache === 'week'); |
| overlayWeekLabel.append('span').text('Sum to 1 week'); |
|
|
| const syncToolbar = () => { |
| const hourMode = granularityCache === 'hour'; |
| overlayDayLabel.style('display', hourMode ? 'inline-flex' : 'none'); |
| if (!hourMode && overlayCache === 'day') { |
| overlayCache = 'none'; |
| overlayDayInput.property('checked', false); |
| } |
| }; |
|
|
| metricSelect.on('change', function () { |
| metricCache = (this as HTMLSelectElement).value as TimelineMetric; |
| xZoom.transform = null; |
| scheduleRerender(); |
| }); |
|
|
| granularitySelect.on('change', function () { |
| granularityCache = (this as HTMLSelectElement).value as TimelineGranularity; |
| xZoom.transform = null; |
| syncToolbar(); |
| scheduleRerender(); |
| }); |
|
|
| const setOverlay = (next: TimelineOverlay) => { |
| if (next === 'day' && granularityCache !== 'hour') next = 'none'; |
| overlayCache = next; |
| overlayDayInput.property('checked', next === 'day'); |
| overlayWeekInput.property('checked', next === 'week'); |
| syncToolbar(); |
| xZoom.transform = null; |
| scheduleRerender(); |
| }; |
|
|
| overlayDayInput.on('change', function () { |
| setOverlay((this as HTMLInputElement).checked ? 'day' : 'none'); |
| }); |
| overlayWeekInput.on('change', function () { |
| setOverlay((this as HTMLInputElement).checked ? 'week' : 'none'); |
| }); |
| syncToolbar(); |
|
|
| chartHost = wrap.append('div').attr('class', 'visit-stats-timeline-chart'); |
| chartHost.append('p').style('margin', '0 0 8px').style('font-size', '13px').style('opacity', '0.75').text('Loading…'); |
|
|
| fullscreenBtn = shell |
| .select('.dialog-buttons') |
| .insert('button', '.dialog-button.cancel') |
| .attr('type', 'button') |
| .attr('class', 'dialog-button cancel visit-stats-timeline-fullscreen-btn') |
| .attr('title', 'Fullscreen') |
| .text('⛶') |
| .on('click', async function () { |
| if (!dialogEl) return; |
| try { |
| await toggleTimelineDialogFullscreen(dialogEl); |
| } catch { |
| |
| } |
| scheduleRerender(); |
| }); |
|
|
| document.addEventListener('fullscreenchange', onLayoutChange); |
| window.addEventListener('resize', onLayoutChange); |
|
|
| void (async () => { |
| try { |
| const data = await api.getVisitStatsActiveVisitsTimeline(); |
| if (!data?.success) throw new Error(data?.error ?? 'Failed to load timeline'); |
| binsCache = (data.bins ?? []).map((b) => ({ |
| hour: b.hour, |
| active_visits: b.active_visits ?? 0, |
| active_sec: b.active_sec ?? 0, |
| })); |
| xZoom.transform = null; |
| chartHost!.select('p').remove(); |
| scheduleRerender(); |
| } catch (e) { |
| chartHost!.selectAll('*').remove(); |
| const msg = e instanceof Error ? e.message : String(e); |
| chartHost! |
| .append('p') |
| .style('margin', '0') |
| .style('font-size', '13px') |
| .text(`Failed to load timeline: ${msg}`); |
| } |
| })(); |
|
|
| return { focus: () => {} }; |
| }, |
| onCancel: () => { |
| document.removeEventListener('fullscreenchange', onLayoutChange); |
| window.removeEventListener('resize', onLayoutChange); |
| if (dialogEl && document.fullscreenElement === dialogEl) { |
| void document.exitFullscreen(); |
| } |
| }, |
| cancelText: tr('Exit'), |
| confirmText: null, |
| width: 'clamp(360px, 94vw, 620px)', |
| }); |
| } |
|
|