Files
phishingclub/frontend/src/lib/components/EventTimeline.svelte
Ronni Skansing 94d4f73a5b added Follow mode on campaign event timeline
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2026-02-11 20:12:08 +01:00

1220 lines
41 KiB
Svelte

<script>
import { onMount, onDestroy } from 'svelte';
import * as d3 from 'd3';
import { toEvent } from '$lib/utils/events';
// props
export let events = [];
export let isGhost = false;
export let refreshInterval = 60000;
// hoisted constant lookup tables (created once, not per-call)
const EVENT_COLORS = Object.freeze({
campaign_scheduled: '#62aded',
campaign_active: '#5557f6',
campaign_self_managed: '#9622fc',
campaign_closed: '#9f9f9f',
campaign_recipient_scheduled: '#4e68d8',
campaign_recipient_cancelled: '#161692',
campaign_recipient_message_sent: '#94cae6',
campaign_recipient_message_failed: '#f2bb58',
campaign_recipient_message_read: '#4cb5b5',
campaign_recipient_evasion_page_visited: '#c8a2f0',
campaign_recipient_before_page_visited: '#eea5fa',
campaign_recipient_page_visited: '#f96dcf',
campaign_recipient_after_page_visited: '#f6287b',
campaign_recipient_deny_page_visited: '#ff6b35',
campaign_recipient_submitted_data: '#f42e41',
campaign_recipient_reported: '#2c3e50'
});
const EVENT_ICONS = Object.freeze({
campaign_scheduled:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>',
campaign_active:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6 4h1m4 0h1m-6 4h1m4 0h1M4 20h16a2 2 0 002-2V6a2 2 0 00-2-2H4a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>',
campaign_self_managed:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>',
campaign_closed:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
campaign_recipient_scheduled:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>',
campaign_recipient_cancelled:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>',
campaign_recipient_message_sent:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/></svg>',
campaign_recipient_message_failed:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>',
campaign_recipient_message_read:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>',
campaign_recipient_evasion_page_visited:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>',
campaign_recipient_before_page_visited:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>',
campaign_recipient_page_visited:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>',
campaign_recipient_after_page_visited:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>',
campaign_recipient_deny_page_visited:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>',
campaign_recipient_submitted_data:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
campaign_recipient_reported:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>'
});
const DEFAULT_ICON =
'<svg fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="10" r="3"></circle></svg>';
const DEFAULT_COLOR = '#6b7280';
// performance constants
const MAX_ZOOM_IN = 1000;
const MIN_ZOOM_OUT_PADDING = 0.1;
const MAX_VISIBLE_EVENTS = 1000;
const AXIS_DEBOUNCE_MS = 150;
const TOOLTIP_HIDE_DELAY = 100;
const RESIZE_DEBOUNCE_MS = 100;
const CURRENT_TIME_UPDATE_MS = 1000; // update every second so NOW line moves visibly
// layout constants
const HEIGHT = 200;
const MARGIN = Object.freeze({ top: 20, right: 20, bottom: 30, left: 20 });
// theme state
let isDarkMode = false;
// dom references
let svg;
let container;
let timelineGroup;
let currentTimeIndicator;
let currentTimeWindow;
let circlesGroup;
let circleSelection = null;
// layout state
let width = 800;
// timeline state
let currentCenterDate = new Date();
let zoom;
let currentTransform = null;
let xScale;
let use24Hour = true;
let initialized = false;
let showFilterDropdown = false;
let followCurrentTime = false;
let previousFollowCurrentTime = false;
let isFollowUpdating = false;
// tooltip state (reactive for svelte template)
let tooltipVisible = false;
let tooltipX = 0;
let tooltipY = 0;
let tooltipEvent = null;
// filter state
let eventFilters = {
campaign_recipient_scheduled: true,
campaign_recipient_cancelled: true,
campaign_recipient_message_sent: true,
campaign_recipient_message_failed: true,
campaign_recipient_message_read: true,
campaign_recipient_evasion_page_visited: true,
campaign_recipient_before_page_visited: true,
campaign_recipient_page_visited: true,
campaign_recipient_after_page_visited: true,
campaign_recipient_deny_page_visited: true,
campaign_recipient_submitted_data: true,
campaign_recipient_reported: true
};
// caching - pre-processed events with timestamps
let processedEvents = [];
let filteredEventsCache = null;
let lastFilterHash = '';
let lastEventsHash = '';
// animation and timing handles
let rafId = null;
let pendingZoomUpdate = false;
let axisUpdateId = null;
let tooltipHideId = null;
let resizeTimeoutId = null;
let currentTimeIntervalId = null;
let themeObserver = null;
let resizeObserver = null;
// cached scale for RAF updates
let pendingScale = null;
// pre-cached circle nodes for fast iteration
let circleNodes = [];
let circleData = [];
// inline helper functions
const getEventColor = (eventName) => EVENT_COLORS[eventName] || DEFAULT_COLOR;
const getEventIcon = (eventName) => EVENT_ICONS[eventName] || DEFAULT_ICON;
function checkTheme() {
isDarkMode = document.documentElement.classList.contains('dark');
}
function updateTimelineColors() {
if (!timelineGroup) return;
const strokeColor = isDarkMode ? '#374151' : '#fff';
const centerLineColor = isDarkMode ? '#6b7280' : '#d1d5db';
const indicatorColor = isDarkMode ? '#6887ea' : '#445ecc';
const windowFill = isDarkMode ? 'rgba(104, 135, 234, 0.15)' : 'rgba(68, 94, 204, 0.15)';
if (circlesGroup) {
circlesGroup.selectAll('circle').attr('stroke', strokeColor);
}
timelineGroup.select('.center-line').attr('stroke', centerLineColor);
if (currentTimeIndicator) {
currentTimeIndicator.attr('stroke', indicatorColor);
}
if (currentTimeWindow) {
currentTimeWindow.attr('fill', windowFill);
}
}
// pre-process events with numeric timestamps for fast access
function processEvents(eventsList) {
if (!eventsList || eventsList.length === 0) return [];
return eventsList
.filter((e) => e?.eventName && e?.createdAt)
.map((e) => ({
...e,
_ts: new Date(e.createdAt).getTime() // pre-computed timestamp
}));
}
function getFilteredEvents() {
// fast path: no events
if (processedEvents.length === 0) {
filteredEventsCache = [];
return filteredEventsCache;
}
// check cache validity
const filterHash = JSON.stringify(eventFilters);
if (filterHash === lastFilterHash && filteredEventsCache) {
return filteredEventsCache;
}
const hasAnyRecipientFiltersEnabled = Object.values(eventFilters).some(Boolean);
filteredEventsCache = processedEvents.filter((event) => {
// always show non-recipient campaign events
if (
event.eventName.startsWith('campaign_') &&
!event.eventName.startsWith('campaign_recipient_')
) {
return true;
}
// filter recipient events
if (!hasAnyRecipientFiltersEnabled) return false;
return eventFilters[event.eventName] === true;
});
lastFilterHash = filterHash;
return filteredEventsCache;
}
function getVisibleEvents(scale) {
const filteredEvents = getFilteredEvents();
if (!scale || filteredEvents.length <= MAX_VISIBLE_EVENTS) {
return filteredEvents;
}
const domain = scale.domain();
const rangeStart = domain[0].getTime();
const rangeEnd = domain[1].getTime();
const padding = (rangeEnd - rangeStart) * 0.1;
const extendedStart = rangeStart - padding;
const extendedEnd = rangeEnd + padding;
// use pre-computed _ts for fast filtering
const eventsInRange = filteredEvents.filter(
(e) => e._ts >= extendedStart && e._ts <= extendedEnd
);
if (eventsInRange.length > MAX_VISIBLE_EVENTS) {
const step = Math.ceil(eventsInRange.length / MAX_VISIBLE_EVENTS);
return eventsInRange.filter((_, i) => i % step === 0);
}
return eventsInRange;
}
function updateCurrentTimeIndicatorImmediate() {
if (!currentTimeIndicator || !xScale) return;
const scale = currentTransform ? currentTransform.rescaleX(xScale) : xScale;
const now = Date.now();
const nowDate = new Date(now);
const xPos = scale(nowDate);
const domain = scale.domain();
if (nowDate >= domain[0] && nowDate <= domain[1]) {
const intervalMs = Math.max(refreshInterval, 60000);
const windowStartTime = new Date(now - intervalMs);
const xWindowStart = scale(windowStartTime);
const windowWidth = Math.max(xPos - xWindowStart, 2);
currentTimeIndicator.attr('x1', xPos).attr('x2', xPos).attr('opacity', 0.8);
if (currentTimeWindow) {
currentTimeWindow.attr('x', xWindowStart).attr('width', windowWidth).attr('opacity', 0.5);
}
// if following current time, keep the view centered on now
// recenter on every tick to move in sync with now indicator
if (followCurrentTime && zoom && svg) {
isFollowUpdating = true;
centerOnTime(nowDate);
isFollowUpdating = false;
}
} else {
currentTimeIndicator.attr('opacity', 0);
if (currentTimeWindow) {
currentTimeWindow.attr('opacity', 0);
}
}
}
function centerOnTime(date) {
if (!xScale || !zoom || !svg) return;
const currentScale = currentTransform ? currentTransform.k : 1;
const targetX = xScale(date);
const centerX = width / 2;
// calculate the translation needed to center the target date
const translateX = centerX - targetX * currentScale;
// create new transform
const newTransform = d3.zoomIdentity.translate(translateX, 0).scale(currentScale);
// apply transform instantly
d3.select(svg).call(zoom.transform, newTransform);
}
function updateAxesImmediate(scale) {
if (!scale?.domain) return;
const domain = scale.domain();
const timeSpan = domain[1] - domain[0];
const minTickSpacing = 80;
const maxTicks = Math.floor(width / minTickSpacing);
const xAxis = d3.select(svg).select('.x-axis');
if (xAxis.empty()) return;
let tickFunction;
let tickFormat;
if (timeSpan < 1000) {
tickFunction = d3.timeMillisecond.every(Math.ceil(timeSpan / maxTicks / 10) * 10);
tickFormat = d3.timeFormat(use24Hour ? '%H:%M:%S.%L' : '%I:%M:%S.%L %p');
} else if (timeSpan < 60000) {
tickFunction = d3.timeSecond.every(Math.max(1, Math.ceil(timeSpan / maxTicks / 1000)));
tickFormat = d3.timeFormat(use24Hour ? '%H:%M:%S' : '%I:%M:%S %p');
} else if (timeSpan < 3600000) {
tickFunction = d3.timeMinute.every(Math.max(1, Math.ceil(timeSpan / maxTicks / 60000)));
tickFormat = d3.timeFormat(use24Hour ? '%H:%M' : '%I:%M %p');
} else if (timeSpan < 86400000) {
tickFunction = d3.timeHour.every(Math.max(1, Math.ceil(timeSpan / maxTicks / 3600000)));
tickFormat = d3.timeFormat(use24Hour ? '%H:%M' : '%I:%M %p');
} else if (timeSpan < 2592000000) {
tickFunction = d3.timeDay.every(Math.max(1, Math.ceil(timeSpan / maxTicks / 86400000)));
tickFormat = d3.timeFormat('%d %b');
} else if (timeSpan < 31536000000) {
tickFunction = d3.timeMonth.every(Math.max(1, Math.ceil(timeSpan / maxTicks / 2592000000)));
tickFormat = d3.timeFormat('%b %Y');
} else {
tickFunction = d3.timeYear.every(Math.max(1, Math.ceil(timeSpan / maxTicks / 31536000000)));
tickFormat = d3.timeFormat('%Y');
}
xAxis.call(d3.axisBottom(scale).ticks(tickFunction).tickFormat(tickFormat));
xAxis.selectAll('text').style('text-anchor', 'middle').attr('dy', '1em');
updateCenterDate(scale);
updateCurrentTimeIndicatorImmediate();
}
function updateAxesDebounced(scale) {
if (axisUpdateId) clearTimeout(axisUpdateId);
axisUpdateId = setTimeout(() => updateAxesImmediate(scale), AXIS_DEBOUNCE_MS);
}
function updateCenterDate(scale) {
if (!scale) return;
try {
const domain = scale.domain();
const centerTime = new Date((domain[0].getTime() + domain[1].getTime()) / 2);
if (Math.abs(centerTime.getTime() - currentCenterDate.getTime()) > 1000) {
currentCenterDate = centerTime;
}
} catch {
// ignore errors
}
}
function calculateOptimalScale(start, end) {
if (!start || !end) return 0.9;
const timeSpan = end - start;
const targetTimeSpan = timeSpan * (1 + MIN_ZOOM_OUT_PADDING * 2);
return Math.min(Math.max(width / targetTimeSpan, 0.7), 0.95);
}
// calculate optimal view based on events and current time
// returns { domainStart, domainEnd, centerTs } for the view
function calculateOptimalView(filteredEvents) {
const now = Date.now();
if (filteredEvents.length === 0) {
// no events: show last 24 hours centered on now
const dayMs = 24 * 60 * 60 * 1000;
return {
domainStart: new Date(now - dayMs),
domainEnd: new Date(now),
centerTs: now - dayMs / 2
};
}
// fast min/max calculation
let minTs = filteredEvents[0]._ts;
let maxTs = filteredEvents[0]._ts;
for (let i = 1; i < filteredEvents.length; i++) {
const ts = filteredEvents[i]._ts;
if (ts < minTs) minTs = ts;
if (ts > maxTs) maxTs = ts;
}
const timeSpan = maxTs - minTs;
const padding = Math.max(timeSpan * MIN_ZOOM_OUT_PADDING, 60000);
if (now > maxTs) {
// now is after last event: center view on middle of campaign
const campaignMiddle = minTs + timeSpan / 2;
return {
domainStart: new Date(minTs - padding),
domainEnd: new Date(maxTs + padding),
centerTs: campaignMiddle
};
} else {
// last event hasn't happened yet (scheduled): pan to NOW
// use same time span as campaign but centered on now
const viewSpan = Math.max(timeSpan + padding * 2, 60 * 60 * 1000); // at least 1 hour
return {
domainStart: new Date(now - viewSpan / 2),
domainEnd: new Date(now + viewSpan / 2),
centerTs: now
};
}
}
// cache circle nodes for fast iteration during zoom/pan
function cacheCircleNodes() {
if (!circlesGroup) {
circleNodes = [];
circleData = [];
return;
}
const selection = circlesGroup.selectAll('circle');
circleNodes = selection.nodes();
circleData = selection.data();
}
// render circles - only called on data change or zoom end
function renderCircles(scale) {
if (!scale || !circlesGroup) return;
const visibleData = getVisibleEvents(scale);
const strokeColor = isDarkMode ? '#374151' : '#fff';
const yPos = (HEIGHT - MARGIN.top - MARGIN.bottom) / 2;
const circles = circlesGroup.selectAll('circle').data(visibleData, (d) => d.id || d._ts);
circles.exit().remove();
const entered = circles
.enter()
.append('circle')
.attr('r', 6)
.attr('stroke', strokeColor)
.attr('stroke-width', 1)
.attr('cy', yPos)
.attr('cursor', 'pointer');
entered
.merge(circles)
.attr('cx', (d) => scale(d._ts))
.attr('fill', (d) => getEventColor(d.eventName));
// cache nodes after render
cacheCircleNodes();
}
// ultra-fast position update using cached nodes - no d3 overhead
function updateCirclePositionsFast(scale) {
const len = circleNodes.length;
for (let i = 0; i < len; i++) {
circleNodes[i].setAttribute('cx', scale(circleData[i]._ts));
}
}
// RAF-batched zoom handler
function scheduleZoomUpdate() {
if (pendingZoomUpdate) return;
pendingZoomUpdate = true;
rafId = requestAnimationFrame(() => {
pendingZoomUpdate = false;
if (pendingScale && circleNodes.length > 0) {
updateCirclePositionsFast(pendingScale);
}
});
}
function handleZoom(event) {
if (!xScale) return;
// skip all processing if this is a follow mode update
if (isFollowUpdating) {
currentTransform = event.transform;
pendingScale = currentTransform.rescaleX(xScale);
return;
}
currentTransform = event.transform;
pendingScale = currentTransform.rescaleX(xScale);
// schedule RAF update for circle positions
scheduleZoomUpdate();
// hide time indicator during interaction (only if user-initiated)
if (event.sourceEvent) {
if (currentTimeIndicator) currentTimeIndicator.attr('opacity', 0);
if (currentTimeWindow) currentTimeWindow.attr('opacity', 0);
}
// debounce expensive operations (axis, virtualization)
updateAxesDebounced(pendingScale);
}
function handleZoomEnd() {
if (!pendingScale) return;
// skip processing if this is a follow mode update to prevent infinite loop
if (isFollowUpdating) {
// but still update circle positions so events move with the timeline
updateCirclePositionsFast(pendingScale);
return;
}
// on zoom end, do full re-render with virtualization
renderCircles(pendingScale);
updateAxesImmediate(pendingScale);
updateCurrentTimeIndicatorImmediate();
}
function setupZoom() {
zoom = d3
.zoom()
.scaleExtent([0.1, 1000000])
.extent([
[0, 0],
[width, HEIGHT]
])
.filter((event) => {
if (event.type === 'wheel' && currentTransform && xScale) {
const newX = currentTransform.rescaleX(xScale);
const visibleTimeSpan = newX.domain()[1] - newX.domain()[0];
if (visibleTimeSpan <= MAX_ZOOM_IN && event.deltaY < 0) {
return false;
}
}
return true;
})
.on('zoom', handleZoom)
.on('end', handleZoomEnd);
d3.select(svg).call(zoom);
}
function updateTimeline() {
if (!initialized || !svg || !timelineGroup || !xScale) return;
const filteredEvents = getFilteredEvents();
if (filteredEvents.length === 0) {
if (circlesGroup) {
circlesGroup.selectAll('circle').remove();
circleNodes = [];
circleData = [];
}
updateCenterDate(xScale);
return;
}
// use pre-computed timestamps with fast min/max
const timestamps = filteredEvents.map((d) => d._ts);
let minTs = timestamps[0];
let maxTs = timestamps[0];
for (let i = 1; i < timestamps.length; i++) {
if (timestamps[i] < minTs) minTs = timestamps[i];
if (timestamps[i] > maxTs) maxTs = timestamps[i];
}
const timeSpan = maxTs - minTs;
const padding = Math.max(timeSpan * MIN_ZOOM_OUT_PADDING, 60000);
const domainStart = new Date(minTs - padding);
const domainEnd = new Date(maxTs + padding);
xScale = d3.scaleTime().domain([domainStart, domainEnd]).range([0, width]);
// use transformed scale if available, otherwise base scale
const activeScale = currentTransform ? currentTransform.rescaleX(xScale) : xScale;
renderCircles(activeScale);
updateAxesImmediate(activeScale);
updateCenterDate(xScale);
updateCurrentTimeIndicatorImmediate();
if (!currentTransform) {
const optimalScale = calculateOptimalScale(domainStart, domainEnd);
const initialTranslate = (width - width * optimalScale) / 2;
currentTransform = d3.zoomIdentity.translate(initialTranslate, 0).scale(optimalScale);
d3.select(svg).call(zoom.transform, currentTransform);
}
}
function handleGhost() {
if (!initialized || !timelineGroup) return;
if (isGhost) {
if (circlesGroup) circlesGroup.selectAll('circle').style('display', 'none');
if (timelineGroup.selectAll('.ghost-dot').empty()) {
const ghostCount = Math.min(12, Math.max(3, Math.floor(width / 80)));
const ghostData = Array.from({ length: ghostCount }, (_, i) => ({
x: (width / (ghostCount - 1)) * i,
id: `ghost-${i}`
}));
timelineGroup
.selectAll('.ghost-dot')
.data(ghostData)
.enter()
.append('circle')
.attr('class', 'ghost-dot')
.attr('r', 6)
.attr('cx', (d) => d.x)
.attr('cy', (HEIGHT - MARGIN.top - MARGIN.bottom) / 2)
.attr('fill', 'none')
.attr('stroke', isDarkMode ? '#6b7280' : '#9ca3af')
.attr('stroke-width', 1);
}
} else {
timelineGroup.selectAll('.ghost-dot').remove();
if (circlesGroup) circlesGroup.selectAll('circle').style('display', null);
updateTimeline();
}
}
function handleResize() {
if (resizeTimeoutId) clearTimeout(resizeTimeoutId);
resizeTimeoutId = setTimeout(() => {
if (container && initialized) {
const newWidth = container.offsetWidth - MARGIN.left - MARGIN.right;
if (newWidth > 0 && newWidth !== width) {
width = newWidth;
if (xScale) {
xScale.range([0, width]);
updateTimeline();
}
}
}
}, RESIZE_DEBOUNCE_MS);
}
function initializeTimeline() {
if (!svg || !container) return;
const containerWidth = container.offsetWidth;
width = containerWidth > 0 ? containerWidth - MARGIN.left - MARGIN.right : 800;
d3.select(svg).selectAll('*').remove();
timelineGroup = d3
.select(svg)
.append('g')
.attr('transform', `translate(${MARGIN.left}, ${MARGIN.top})`);
const yCenter = (HEIGHT - MARGIN.top - MARGIN.bottom) / 2;
// timeline base line
timelineGroup
.append('line')
.attr('class', 'timeline-line')
.attr('x1', 0)
.attr('y1', yCenter)
.attr('x2', width)
.attr('y2', yCenter)
.attr('stroke', isDarkMode ? '#374151' : '#e5e7eb')
.attr('stroke-width', 2);
// center reference line
timelineGroup
.append('line')
.attr('class', 'center-line')
.attr('x1', width / 2)
.attr('y1', 0)
.attr('x2', width / 2)
.attr('y2', HEIGHT - MARGIN.top - MARGIN.bottom)
.attr('stroke', isDarkMode ? '#6b7280' : '#d1d5db')
.attr('stroke-width', 1)
.attr('opacity', 0.4);
// current time window
currentTimeWindow = timelineGroup
.append('rect')
.attr('class', 'current-time-window')
.attr('y', 0)
.attr('height', HEIGHT - MARGIN.top - MARGIN.bottom)
.attr('fill', isDarkMode ? 'rgba(104, 135, 234, 0.15)' : 'rgba(68, 94, 204, 0.15)')
.attr('opacity', 0.5);
// current time indicator
currentTimeIndicator = timelineGroup
.append('line')
.attr('class', 'current-time-indicator')
.attr('y1', 0)
.attr('y2', HEIGHT - MARGIN.top - MARGIN.bottom)
.attr('stroke', isDarkMode ? '#6887ea' : '#445ecc')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '4,4')
.attr('opacity', 0.8);
// dedicated group for circles (better for transforms)
circlesGroup = timelineGroup.append('g').attr('class', 'circles-group');
// x-axis
d3.select(svg)
.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(${MARGIN.left}, ${HEIGHT - MARGIN.bottom})`);
// event delegation for tooltips on circles group
circlesGroup.on('mouseover', handleCircleMouseOver).on('mouseout', handleCircleMouseOut);
// initial scale using optimal view calculation
const filteredEvents = getFilteredEvents();
const optimalView = calculateOptimalView(filteredEvents);
xScale = d3
.scaleTime()
.domain([optimalView.domainStart, optimalView.domainEnd])
.range([0, width]);
setupZoom();
// theme observer
if (typeof MutationObserver !== 'undefined') {
themeObserver = new MutationObserver(() => {
checkTheme();
updateTimelineColors();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
// resize observer
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(container);
}
initialized = true;
updateAxesImmediate(xScale);
updateCenterDate(xScale);
if (!isGhost) {
updateTimeline();
} else {
handleGhost();
}
}
function handleCircleMouseOver(event) {
const target = event.target;
if (target.tagName !== 'circle') return;
const d = d3.select(target).datum();
if (!d?.eventName) return;
if (tooltipHideId) {
clearTimeout(tooltipHideId);
tooltipHideId = null;
}
const [x, y] = d3.pointer(event, container);
const tooltipWidth = 250;
const leftPosition = x + 10;
const rightEdge = leftPosition + tooltipWidth;
const adjustedLeft = rightEdge > container.offsetWidth ? x - tooltipWidth - 10 : leftPosition;
tooltipX = Math.max(10, adjustedLeft);
tooltipY = Math.max(10, y - 10);
tooltipEvent = d;
tooltipVisible = true;
}
function handleCircleMouseOut() {
if (tooltipHideId) clearTimeout(tooltipHideId);
tooltipHideId = setTimeout(() => {
tooltipVisible = false;
tooltipEvent = null;
}, TOOLTIP_HIDE_DELAY);
}
function resetZoom() {
if (!zoom || !svg || !xScale) return;
const filteredEvents = getFilteredEvents();
const optimalView = calculateOptimalView(filteredEvents);
// update scale domain
xScale.domain([optimalView.domainStart, optimalView.domainEnd]);
const optimalScale = calculateOptimalScale(optimalView.domainStart, optimalView.domainEnd);
// calculate translate to center on the desired point
const domainSpan = optimalView.domainEnd.getTime() - optimalView.domainStart.getTime();
const centerRatio = (optimalView.centerTs - optimalView.domainStart.getTime()) / domainSpan;
const centerX = centerRatio * width;
const targetCenterX = width / 2;
const translateX = targetCenterX - centerX * optimalScale;
const resetTransform = d3.zoomIdentity.translate(translateX, 0).scale(optimalScale);
d3.select(svg).transition().duration(750).call(zoom.transform, resetTransform);
}
function handleClickOutside(event) {
if (showFilterDropdown && !event.target.closest('.filter-dropdown')) {
showFilterDropdown = false;
}
}
function invalidateCacheAndUpdate() {
filteredEventsCache = null;
if (!isGhost && initialized) {
updateTimeline();
}
}
// computed tooltip values
$: tooltipColor = tooltipEvent ? getEventColor(tooltipEvent.eventName) : DEFAULT_COLOR;
$: tooltipIcon = tooltipEvent ? getEventIcon(tooltipEvent.eventName) : DEFAULT_ICON;
$: tooltipInfo = tooltipEvent ? toEvent(tooltipEvent.eventName) : null;
$: tooltipDate = tooltipEvent ? new Date(tooltipEvent.createdAt) : null;
$: tooltipFormattedDate = tooltipDate?.toLocaleDateString() || '';
$: tooltipFormattedTime =
tooltipDate?.toLocaleTimeString([], {
hour12: !use24Hour,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}) || '';
// lifecycle
onMount(() => {
checkTheme();
});
onDestroy(() => {
if (themeObserver) themeObserver.disconnect();
if (resizeObserver) resizeObserver.disconnect();
if (rafId) cancelAnimationFrame(rafId);
if (axisUpdateId) clearTimeout(axisUpdateId);
if (tooltipHideId) clearTimeout(tooltipHideId);
if (resizeTimeoutId) clearTimeout(resizeTimeoutId);
if (currentTimeIntervalId) clearInterval(currentTimeIntervalId);
window.removeEventListener('click', handleClickOutside);
});
// reactive: process events when they change
// use content-based hash to detect changes (not reference equality)
// include all event ids to detect deletions from any position
$: eventsHash = events?.map((e) => e.id).join(',') || '';
$: if (eventsHash !== lastEventsHash) {
lastEventsHash = eventsHash;
processedEvents = processEvents(events);
filteredEventsCache = null;
circleNodes = [];
circleData = [];
if (initialized && !isGhost) {
updateTimeline();
}
}
// reactive: initialize timeline
$: if (container && svg && !initialized) {
initializeTimeline();
}
// reactive: update interval for now indicator (same rate whether following or not)
$: if (initialized && xScale) {
if (currentTimeIntervalId) clearInterval(currentTimeIntervalId);
currentTimeIntervalId = setInterval(() => {
updateCurrentTimeIndicatorImmediate();
}, CURRENT_TIME_UPDATE_MS);
}
// reactive: ghost mode
$: if (initialized && timelineGroup) {
handleGhost();
}
// reactive: time format changed
$: if (initialized && xScale && use24Hour !== undefined) {
const scale = currentTransform ? currentTransform.rescaleX(xScale) : xScale;
updateAxesImmediate(scale);
}
// reactive: follow mode toggled on
$: {
if (
followCurrentTime !== previousFollowCurrentTime &&
followCurrentTime &&
initialized &&
xScale &&
zoom &&
svg
) {
previousFollowCurrentTime = followCurrentTime;
// when follow mode is enabled, immediately center on current time
const now = new Date();
centerOnTime(now);
} else if (!followCurrentTime && previousFollowCurrentTime !== followCurrentTime) {
previousFollowCurrentTime = followCurrentTime;
}
}
// reactive: filter dropdown click outside
$: if (showFilterDropdown) {
window.addEventListener('click', handleClickOutside);
} else {
window.removeEventListener('click', handleClickOutside);
}
</script>
<div class="relative cursor-move" bind:this={container}>
<div class="transition-opacity duration-300" class:animate-pulse={isGhost}>
<svg
bind:this={svg}
width="100%"
height={HEIGHT}
class="w-full bg-white dark:bg-gray-900/80 border border-gray-200 dark:border-gray-700/60 rounded-lg"
></svg>
<!-- svelte-native tooltip -->
{#if tooltipVisible && tooltipEvent}
<div
class="absolute z-50 bg-white dark:bg-gray-900 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700/60 max-w-xs overflow-hidden pointer-events-none"
style="left: {tooltipX}px; top: {tooltipY}px; will-change: transform;"
>
<div class="border-t-4" style="border-top-color: {tooltipColor}">
<div class="px-4 py-3 text-gray-800 dark:text-gray-200">
<div class="flex items-center space-x-2">
<div class="flex-shrink-0 w-5 h-5" style="color: {tooltipColor}">
{@html tooltipIcon}
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-bold truncate">{tooltipInfo?.name || 'Unknown'}</h3>
</div>
</div>
</div>
</div>
<div class="px-4 py-3 space-y-3">
{#if tooltipEvent.recipient?.email}
<div class="flex items-center space-x-2">
<div class="flex-shrink-0 w-4 h-4 text-gray-500 dark:text-gray-400">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
</div>
<span class="text-sm text-gray-700 dark:text-gray-300 truncate"
>{tooltipEvent.recipient.email}</span
>
</div>
{/if}
<div class="flex items-center space-x-2">
<div class="flex-shrink-0 w-4 h-4 text-gray-500 dark:text-gray-400">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="text-sm text-gray-700 dark:text-gray-300">
{tooltipFormattedDate.split(',')[0]}
{tooltipFormattedTime}
</div>
</div>
{#if tooltipEvent.ip || tooltipEvent.userAgent}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600 space-y-2">
{#if tooltipEvent.ip}
<div class="flex items-center space-x-2">
<div class="flex-shrink-0 w-4 h-4 text-gray-500 dark:text-gray-400">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
</div>
<span class="text-xs text-gray-600 dark:text-gray-400 truncate"
>{tooltipEvent.ip}</span
>
</div>
{/if}
{#if tooltipEvent.userAgent}
<div class="flex items-start space-x-2">
<div class="flex-shrink-0 w-4 h-4 text-gray-500 dark:text-gray-400 mt-0.5">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<span class="text-xs text-gray-600 dark:text-gray-400 break-words">
{tooltipEvent.userAgent.length > 80
? tooltipEvent.userAgent.substring(0, 80) + '...'
: tooltipEvent.userAgent}
</span>
</div>
{/if}
</div>
{/if}
{#if tooltipEvent.data}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600">
<div class="flex items-start space-x-2">
<div class="flex-shrink-0 w-4 h-4 text-gray-500 dark:text-gray-400 mt-0.5">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="text-xs text-gray-600 dark:text-gray-400 break-words">
{tooltipEvent.data.length > 100
? tooltipEvent.data.substring(0, 100) + '...'
: tooltipEvent.data}
</div>
</div>
</div>
{/if}
</div>
</div>
{/if}
{#if !isGhost}
<div class="absolute top-2 right-2 flex gap-2">
<div class="relative filter-dropdown">
<button
on:click|stopPropagation={() => (showFilterDropdown = !showFilterDropdown)}
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 focus:outline-none focus:border-slate-400 dark:focus:border-highlight-blue/80 transition-colors duration-200"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
></path>
</svg>
</button>
{#if showFilterDropdown}
<div
class="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-900/80 rounded-md shadow-lg border border-gray-200 dark:border-gray-700/60 z-50"
>
<div class="p-3">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-200 mb-3">
Filter Events
</h3>
<div class="space-y-3">
<div>
<h4 class="text-xs font-medium text-gray-600 dark:text-gray-300 mb-2">
Recipient Events
</h4>
<div class="space-y-1 pl-2">
{#each [['campaign_recipient_scheduled', 'Scheduled'], ['campaign_recipient_cancelled', 'Cancelled'], ['campaign_recipient_message_sent', 'Message Sent'], ['campaign_recipient_message_failed', 'Message Failed'], ['campaign_recipient_message_read', 'Message Read'], ['campaign_recipient_before_page_visited', 'Before Page Visited'], ['campaign_recipient_page_visited', 'Page Visited'], ['campaign_recipient_after_page_visited', 'After Page Visited'], ['campaign_recipient_deny_page_visited', 'Deny Page Visited'], ['campaign_recipient_submitted_data', 'Data Submitted'], ['campaign_recipient_reported', 'Reported']] as [key, label]}
<label class="flex items-center text-xs">
<input
type="checkbox"
bind:checked={eventFilters[key]}
on:change={invalidateCacheAndUpdate}
class="mr-2 rounded border-slate-300 dark:border-gray-700/60"
/>
<span class="text-gray-600 dark:text-gray-300">{label}</span>
</label>
{/each}
</div>
</div>
</div>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600 flex gap-2">
<button
on:click={() => {
Object.keys(eventFilters).forEach((k) => (eventFilters[k] = true));
invalidateCacheAndUpdate();
}}
class="flex-1 px-2 py-1 text-xs bg-cta-blue dark:bg-highlight-blue/80 text-white rounded hover:bg-blue-500 dark:hover:bg-highlight-blue focus:outline-none transition-colors duration-200"
>
Select All
</button>
<button
on:click={() => {
Object.keys(eventFilters).forEach((k) => (eventFilters[k] = false));
invalidateCacheAndUpdate();
}}
class="flex-1 px-2 py-1 text-xs bg-slate-500 dark:bg-gray-700 text-white rounded hover:bg-slate-600 dark:hover:bg-gray-600 focus:outline-none transition-colors duration-200"
>
Clear All
</button>
<button
on:click={() => (showFilterDropdown = false)}
class="px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
</button>
</div>
</div>
</div>
{/if}
</div>
<label
class="inline-flex items-center gap-1 px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors duration-200"
>
Follow:
<input type="checkbox" bind:checked={followCurrentTime} class="accent-blue-600" />
</label>
<button
on:click={resetZoom}
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 focus:outline-none focus:border-slate-400 dark:focus:border-highlight-blue/80 transition-colors duration-200"
>
Reset Zoom
</button>
<button
on:click={() => (use24Hour = !use24Hour)}
class="inline-flex items-center px-2 py-1 border border-slate-300 dark:border-gray-700/60 rounded-md text-xs font-medium text-slate-600 dark:text-gray-300 bg-grayblue-light dark:bg-gray-900/60 hover:bg-gray-100 dark:hover:bg-gray-700/60 focus:outline-none focus:border-slate-400 dark:focus:border-highlight-blue/80 transition-colors duration-200"
>
{use24Hour ? '12H' : '24H'}
</button>
</div>
<div
class="absolute top-2 left-2 text-xs text-slate-600 dark:text-gray-400 bg-grayblue-light dark:bg-gray-900/60 px-2 py-1 rounded border border-slate-300 dark:border-gray-700/60"
>
{currentCenterDate.toLocaleString()}
</div>
{/if}
</div>
</div>
<style>
:global(.x-axis text) {
fill: #6b7280;
font-size: 11px;
}
:global(.dark .x-axis text) {
fill: #9ca3af;
}
:global(.x-axis line) {
stroke: #e5e7eb;
}
:global(.dark .x-axis line) {
stroke: #374151;
}
:global(.x-axis path) {
stroke: #e5e7eb;
}
:global(.dark .x-axis path) {
stroke: #374151;
}
:global(.timeline-line) {
stroke: #e5e7eb;
}
:global(.dark .timeline-line) {
stroke: #374151;
}
:global(.ghost-dot) {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
:global(.center-line) {
pointer-events: none;
}
:global(.current-time-indicator) {
pointer-events: none;
filter: drop-shadow(0 0 2px rgba(68, 94, 204, 0.5));
}
:global(.dark .current-time-indicator) {
filter: drop-shadow(0 0 2px rgba(104, 135, 234, 0.6));
}
:global(.current-time-window) {
pointer-events: none;
}
:global(.circles-group circle) {
will-change: cx;
shape-rendering: geometricPrecision;
}
:global(.circles-group) {
contain: layout style;
}
</style>