/** * Visit Stats — active visits 时间线(后端 UTC 整点小时;UI 与访问面板一致用本地时间展示) * 周期与 hour 格式:visitStatsContract.ts ↔ backend/platform/visit_stats.py */ 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 { 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)); } /** 后端 UTC 整点小时;补全空缺 */ 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(); 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)); } /** 所有日期的本地小时访问合计(24 槽) */ function overlayToLocalDayDistribution(bins: TimelineBin[], metric: TimelineMetric): ChartPoint[] { const sums = new Array(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, })); } /** Hour + Sum to 1 week:各周叠到同一周模板,168 个本地小时槽(周日 0:00 起) */ function overlayToLocalWeekHourDistribution(bins: TimelineBin[], metric: TimelineMetric): ChartPoint[] { const sums = new Array(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, }; }); } /** Day + Sum to 1 week:各周按本地星期合计(7 槽,周日=0) */ function overlayToLocalWeekDistribution(bins: TimelineBin[], metric: TimelineMetric): ChartPoint[] { const sums = new Array(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, 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)
${valueLabel}`; } if (overlayView === 'week168') { return `${formatOverlayWeekHourAxis(d.t)} – ${formatOverlayWeekHourAxis(end)} (all weeks)
${valueLabel}`; } if (overlayView === 'week7') { return `${formatOverlayWeekAxis(d.t)} (all weeks)
${valueLabel}`; } if (granularity === 'hour') { return `${formatLocalDateTime(d.t)} – ${formatLocalDateTime(end)}
${valueLabel}`; } return `${formatLocalDate(d.t)}
${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) => { 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) => { 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) => { 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('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() .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, dialogEl: HTMLElement): void { const on = timelineDialogFullscreenExpanded(dialogEl); btn.text(on ? '×' : '⛶').attr('title', on ? 'Exit fullscreen' : 'Fullscreen'); } export async function showVisitStatsTimelineDialog(api: TextAnalysisAPI): Promise { 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 | null = null; let fullscreenBtn: d3.Selection | 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); 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 { /* request/exit rejected */ } 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)', }); }