feat: add kibana-like zoomable timeline

This commit is contained in:
2026-06-07 20:07:03 +02:00
parent cf2b7684b8
commit 465b214da4

View File

@@ -485,11 +485,28 @@ function htmlPage(): string {
min-height: 170px;
padding: 14px 12px 10px;
border-radius: 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.55);
border: 1px solid rgba(148, 163, 184, 0.18);
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(15, 23, 42, 0.88)),
radial-gradient(circle at top left, rgba(59, 130, 246, 0.18), transparent 42%);
overflow: hidden;
touch-action: none;
user-select: none;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
inset 0 -1px 0 rgba(255, 255, 255, 0.03);
}
.timeline-chart::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(to top, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 100% 33.333%, 10% 100%;
opacity: 0.35;
pointer-events: none;
}
.timeline-bars {
@@ -498,7 +515,9 @@ function htmlPage(): string {
grid-auto-flow: column;
grid-auto-columns: minmax(10px, 1fr);
align-items: end;
gap: 3px;
gap: 2px;
position: relative;
z-index: 1;
}
.timeline-bar {
@@ -507,33 +526,37 @@ function htmlPage(): string {
display: grid;
align-items: end;
cursor: pointer;
border-radius: 8px 8px 4px 4px;
border-radius: 6px 6px 3px 3px;
}
.timeline-bar-fill {
width: 100%;
border-radius: inherit;
min-height: 4px;
background: linear-gradient(180deg, rgba(45, 108, 223, 0.92), rgba(45, 108, 223, 0.36));
transition: transform 140ms ease, background 140ms ease, opacity 140ms ease;
background: linear-gradient(180deg, rgba(96, 165, 250, 0.98), rgba(59, 130, 246, 0.36));
transition: transform 140ms ease, background 140ms ease, opacity 140ms ease, box-shadow 140ms ease;
transform-origin: bottom;
box-shadow: 0 0 0 1px rgba(30, 64, 175, 0.28);
}
.timeline-bar:hover .timeline-bar-fill,
.timeline-bar.active .timeline-bar-fill {
background: linear-gradient(180deg, #f97316, #f59e0b);
background: linear-gradient(180deg, #38bdf8, #0ea5e9);
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.38), 0 0 18px rgba(14, 165, 233, 0.18);
}
.timeline-bar.selected .timeline-bar-fill {
background: linear-gradient(180deg, #16a34a, #86efac);
background: linear-gradient(180deg, #22c55e, #86efac);
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.35), 0 0 18px rgba(34, 197, 94, 0.18);
}
.timeline-bar.active .timeline-bar-fill {
box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.18);
transform: translateY(-1px);
}
.timeline-bar.selected.active .timeline-bar-fill {
background: linear-gradient(180deg, #059669, #34d399);
background: linear-gradient(180deg, #34d399, #10b981);
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.38), 0 0 22px rgba(16, 185, 129, 0.24);
}
.timeline-bar-meta {
@@ -543,7 +566,8 @@ function htmlPage(): string {
transform: translateX(-50%);
white-space: nowrap;
font-size: 0.72rem;
color: var(--muted);
color: rgba(226, 232, 240, 0.82);
font-variant-numeric: tabular-nums;
pointer-events: none;
}
@@ -551,6 +575,7 @@ function htmlPage(): string {
position: relative;
height: 20px;
margin-top: -2px;
color: rgba(226, 232, 240, 0.72);
}
.timeline-axis-label {
@@ -558,7 +583,8 @@ function htmlPage(): string {
top: 0;
transform: translateX(-50%);
font-size: 0.72rem;
color: var(--muted);
color: rgba(226, 232, 240, 0.78);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
@@ -567,11 +593,16 @@ function htmlPage(): string {
top: 14px;
bottom: 34px;
border-radius: 10px;
border: 1px solid rgba(5, 150, 105, 0.45);
background: rgba(5, 150, 105, 0.16);
border: 1px solid rgba(56, 189, 248, 0.55);
background:
linear-gradient(180deg, rgba(56, 189, 248, 0.18), rgba(14, 165, 233, 0.08)),
rgba(14, 165, 233, 0.12);
pointer-events: none;
opacity: 0;
transition: opacity 120ms ease;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
0 0 22px rgba(14, 165, 233, 0.12);
}
.timeline-selection.visible {
@@ -583,10 +614,11 @@ function htmlPage(): string {
top: 14px;
bottom: 34px;
border-radius: 10px;
border: 1px dashed rgba(45, 108, 223, 0.6);
background: rgba(45, 108, 223, 0.12);
border: 1px dashed rgba(56, 189, 248, 0.9);
background: rgba(56, 189, 248, 0.16);
pointer-events: none;
opacity: 0;
box-shadow: 0 0 20px rgba(56, 189, 248, 0.12);
}
.timeline-brush.visible {
@@ -596,6 +628,11 @@ function htmlPage(): string {
.timeline-footer {
font-size: 0.84rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.timeline-footer span:last-child {
color: rgba(51, 65, 85, 0.92);
}
@media (max-width: 920px) {
@@ -743,6 +780,7 @@ function htmlPage(): string {
total: 0,
activePhotoId: null,
timelineSelection: null,
timelineViewport: null,
timeline: {
unit: "day",
bins: [],
@@ -1002,7 +1040,7 @@ function htmlPage(): string {
);
}
function buildTimeline(photos) {
function buildTimeline(photos, viewport = null) {
const datedPhotos = photos
.map((photo) => ({ photo, date: getPhotoTimestamp(photo) }))
.filter((entry) => entry.date !== null)
@@ -1017,8 +1055,10 @@ function htmlPage(): string {
};
}
const startDate = datedPhotos[0].date;
const endDate = datedPhotos[datedPhotos.length - 1].date;
const dataStart = datedPhotos[0].date;
const dataEnd = datedPhotos[datedPhotos.length - 1].date;
const startDate = viewport ? new Date(viewport.start) : dataStart;
const endDate = viewport ? new Date(viewport.end) : dataEnd;
const spanMs = Math.max(1, endDate.getTime() - startDate.getTime());
const unit = chooseTimelineUnit(spanMs);
const domainStart = getUnitStart(startDate, unit);
@@ -1042,6 +1082,10 @@ function htmlPage(): string {
});
for (const entry of datedPhotos) {
if (viewport && (entry.date.getTime() < viewport.start || entry.date.getTime() >= viewport.end)) {
continue;
}
const bucketStart = getUnitStart(entry.date, unit).getTime();
const binIndex = binIndexByStart.get(bucketStart);
if (binIndex !== undefined) {
@@ -1090,6 +1134,7 @@ function htmlPage(): string {
state.visiblePhotos = [];
state.activePhotoId = null;
state.timelineSelection = null;
state.timelineViewport = null;
state.timeline = {
unit: "day",
bins: [],
@@ -1193,6 +1238,7 @@ function htmlPage(): string {
function setTimelineSelection(start, end) {
if (start === null || end === null) {
state.timelineSelection = null;
state.timelineViewport = null;
timelineClear.disabled = true;
} else {
const normalizedStart = Math.min(start, end);
@@ -1201,6 +1247,10 @@ function htmlPage(): string {
start: normalizedStart,
end: normalizedEnd
};
state.timelineViewport = {
start: normalizedStart,
end: normalizedEnd
};
timelineClear.disabled = false;
}
@@ -1212,10 +1262,11 @@ function htmlPage(): string {
}
function renderTimeline() {
const timeline = buildTimeline(state.photos);
const timelinePhotos = state.timelineSelection ? state.visiblePhotos : state.photos;
const timeline = buildTimeline(timelinePhotos, state.timelineViewport);
state.timeline = timeline;
timelineSummary.textContent = formatTimelineSummary(state.photos, timeline.unit);
timelineUnit.textContent = "Scale: " + timeline.unit;
timelineUnit.textContent = "Scale: " + timeline.unit + (state.timelineSelection ? " · zoomed" : "");
timelineBars.replaceChildren();
timelineAxis.replaceChildren();