From 4084db119bd490cc167bd0a8851844a79d0c5e23 Mon Sep 17 00:00:00 2001 From: Arne Baeumler Date: Sun, 7 Jun 2026 19:56:42 +0200 Subject: [PATCH] feat: add interactive photo timeline --- src/server/request-handler.ts | 819 +++++++++++++++++++++++++++++++--- 1 file changed, 766 insertions(+), 53 deletions(-) diff --git a/src/server/request-handler.ts b/src/server/request-handler.ts index 7b4bf92..be6fa91 100644 --- a/src/server/request-handler.ts +++ b/src/server/request-handler.ts @@ -333,6 +333,11 @@ function htmlPage(): string { font-size: 0.82rem; } + .photo.active { + border-color: rgba(45, 108, 223, 0.42); + background: rgba(45, 108, 223, 0.12); + } + .empty-state { padding: 14px; border-radius: 14px; @@ -346,12 +351,26 @@ function htmlPage(): string { position: relative; min-height: 0; overflow: hidden; + display: grid; + grid-template-rows: minmax(460px, 1fr) auto; + gap: 16px; + } + + .map-panel { + position: relative; + min-height: 0; + overflow: hidden; + border-radius: 20px; + border: 1px solid var(--border); + background: var(--panel); + backdrop-filter: blur(14px); + box-shadow: var(--shadow); } #map { width: 100%; height: 100%; - min-height: 560px; + min-height: 460px; } .map-label { @@ -437,14 +456,166 @@ function htmlPage(): string { margin: 10px 12px; } + .timeline { + padding: 16px; + display: grid; + gap: 12px; + } + + .timeline-header, + .timeline-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + } + + .timeline-header h2 { + margin: 0 0 4px; + font-size: 1rem; + } + + .timeline-summary { + color: var(--muted); + font-size: 0.88rem; + } + + .timeline-chart { + position: relative; + 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); + overflow: hidden; + touch-action: none; + user-select: none; + } + + .timeline-bars { + height: 124px; + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(10px, 1fr); + align-items: end; + gap: 3px; + } + + .timeline-bar { + position: relative; + height: 100%; + display: grid; + align-items: end; + cursor: pointer; + border-radius: 8px 8px 4px 4px; + } + + .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; + transform-origin: bottom; + } + + .timeline-bar:hover .timeline-bar-fill, + .timeline-bar.active .timeline-bar-fill { + background: linear-gradient(180deg, #f97316, #f59e0b); + } + + .timeline-bar.selected .timeline-bar-fill { + background: linear-gradient(180deg, #16a34a, #86efac); + } + + .timeline-bar.active .timeline-bar-fill { + box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.18); + } + + .timeline-bar.selected.active .timeline-bar-fill { + background: linear-gradient(180deg, #059669, #34d399); + } + + .timeline-bar-meta { + position: absolute; + left: 50%; + bottom: -18px; + transform: translateX(-50%); + white-space: nowrap; + font-size: 0.72rem; + color: var(--muted); + pointer-events: none; + } + + .timeline-axis { + position: relative; + height: 20px; + margin-top: -2px; + } + + .timeline-axis-label { + position: absolute; + top: 0; + transform: translateX(-50%); + font-size: 0.72rem; + color: var(--muted); + white-space: nowrap; + } + + .timeline-selection { + position: absolute; + top: 14px; + bottom: 34px; + border-radius: 10px; + border: 1px solid rgba(5, 150, 105, 0.45); + background: rgba(5, 150, 105, 0.16); + pointer-events: none; + opacity: 0; + transition: opacity 120ms ease; + } + + .timeline-selection.visible { + opacity: 1; + } + + .timeline-brush { + position: absolute; + top: 14px; + bottom: 34px; + border-radius: 10px; + border: 1px dashed rgba(45, 108, 223, 0.6); + background: rgba(45, 108, 223, 0.12); + pointer-events: none; + opacity: 0; + } + + .timeline-brush.visible { + opacity: 1; + } + + .timeline-footer { + font-size: 0.84rem; + color: var(--muted); + } + @media (max-width: 920px) { .layout { grid-template-columns: 1fr; } + main { + grid-template-rows: minmax(400px, 1fr) auto; + } + #map { min-height: 460px; } + + .timeline-header, + .timeline-footer { + flex-direction: column; + align-items: flex-start; + } } @@ -495,7 +666,7 @@ function htmlPage(): string {
Ready to load a share.
-
+
@@ -517,8 +688,29 @@ function htmlPage(): string {
-
Hover: thumbnail · click: fullscreen · route: time sorted
-
+
+
Hover: thumbnail · click: fullscreen · route: time sorted
+
+
+
+
+
+

Timeline

+
No photos loaded yet.
+
+ +
+
+
+
+
+
+
+ +
@@ -545,9 +737,18 @@ function htmlPage(): string { const state = { photos: [], + visiblePhotos: [], objectUrls: [], processed: 0, - total: 0 + total: 0, + activePhotoId: null, + timelineSelection: null, + timeline: { + unit: "day", + bins: [], + start: null, + end: null + } }; let activeImportController = null; @@ -575,6 +776,15 @@ function htmlPage(): string { const progressText = mustGet("progress-text"); const progressDetail = mustGet("progress-detail"); const activitySpinner = mustGet("activity-spinner"); + const timelineSummary = mustGet("timeline-summary"); + const timelineClear = mustGet("timeline-clear"); + const timelineChart = mustGet("timeline-chart"); + const timelineBars = mustGet("timeline-bars"); + const timelineSelection = mustGet("timeline-selection"); + const timelineBrush = mustGet("timeline-brush"); + const timelineAxis = mustGet("timeline-axis"); + const timelineRange = mustGet("timeline-range"); + const timelineUnit = mustGet("timeline-unit"); const shareUrl = mustGet("share-url"); const shareStatus = mustGet("share-status"); const loadShare = mustGet("load-share"); @@ -590,10 +800,12 @@ function htmlPage(): string { function formatDate(value) { if (!value) { - return "kein Zeitstempel"; + return "no timestamp"; } const date = new Date(value); - return Number.isNaN(date.getTime()) ? value : date.toLocaleString("de-DE"); + return Number.isNaN(date.getTime()) + ? value + : date.toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }); } function normalizeExifDate(value) { @@ -624,6 +836,214 @@ function htmlPage(): string { return Number.isNaN(date.getTime()) ? null : date.toISOString(); } + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + + function getPhotoTimestamp(photo) { + if (!photo.capturedAt) { + return null; + } + + const date = new Date(photo.capturedAt); + return Number.isNaN(date.getTime()) ? null : date; + } + + function startOfHour(date) { + const copy = new Date(date); + copy.setMinutes(0, 0, 0); + return copy; + } + + function startOfDay(date) { + const copy = new Date(date); + copy.setHours(0, 0, 0, 0); + return copy; + } + + function startOfWeek(date) { + const copy = startOfDay(date); + const day = copy.getDay(); + const offset = (day + 6) % 7; + copy.setDate(copy.getDate() - offset); + return copy; + } + + function startOfMonth(date) { + const copy = startOfDay(date); + copy.setDate(1); + return copy; + } + + function addUnit(date, unit) { + const copy = new Date(date); + + if (unit === "hour") { + copy.setHours(copy.getHours() + 1); + return copy; + } + + if (unit === "day") { + copy.setDate(copy.getDate() + 1); + return copy; + } + + if (unit === "week") { + copy.setDate(copy.getDate() + 7); + return copy; + } + + copy.setMonth(copy.getMonth() + 1); + return copy; + } + + function getUnitStart(date, unit) { + if (unit === "hour") { + return startOfHour(date); + } + + if (unit === "day") { + return startOfDay(date); + } + + if (unit === "week") { + return startOfWeek(date); + } + + return startOfMonth(date); + } + + function chooseTimelineUnit(spanMs) { + const hour = 60 * 60 * 1000; + const day = 24 * hour; + const week = 7 * day; + + if (spanMs <= 48 * hour) { + return "hour"; + } + + if (spanMs <= 21 * day) { + return "day"; + } + + if (spanMs <= 16 * week) { + return "week"; + } + + return "month"; + } + + function formatTimelineLabel(date, unit) { + if (unit === "hour") { + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }); + } + + if (unit === "day") { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric" + }); + } + + if (unit === "week") { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric" + }); + } + + return date.toLocaleDateString("en-US", { + month: "short", + year: "numeric" + }); + } + + function formatTimelineSummary(photos, unit) { + if (!photos.length) { + return "No photos loaded yet."; + } + + const count = photos.length + (photos.length === 1 ? " photo" : " photos"); + const unitLabel = { + hour: "hourly", + day: "daily", + week: "weekly", + month: "monthly" + }[unit] || unit; + + return count + " across a " + unitLabel + " timeline."; + } + + function formatSelectionRange(start, end) { + return ( + formatTimelineLabel(start, state.timeline.unit) + + " - " + + formatTimelineLabel(end, state.timeline.unit) + ); + } + + function buildTimeline(photos) { + const datedPhotos = photos + .map((photo) => ({ photo, date: getPhotoTimestamp(photo) })) + .filter((entry) => entry.date !== null) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + + if (!datedPhotos.length) { + return { + unit: "day", + bins: [], + start: null, + end: null + }; + } + + const startDate = datedPhotos[0].date; + const endDate = datedPhotos[datedPhotos.length - 1].date; + const spanMs = Math.max(1, endDate.getTime() - startDate.getTime()); + const unit = chooseTimelineUnit(spanMs); + const domainStart = getUnitStart(startDate, unit); + const domainEnd = addUnit(getUnitStart(endDate, unit), unit); + const bins = []; + + for (let cursor = new Date(domainStart); cursor < domainEnd; cursor = addUnit(cursor, unit)) { + const next = addUnit(cursor, unit); + bins.push({ + start: new Date(cursor), + end: next, + count: 0, + photoIds: [], + label: formatTimelineLabel(cursor, unit) + }); + } + + const binIndexByStart = new Map(); + bins.forEach((bin, index) => { + binIndexByStart.set(bin.start.getTime(), index); + }); + + for (const entry of datedPhotos) { + const bucketStart = getUnitStart(entry.date, unit).getTime(); + const binIndex = binIndexByStart.get(bucketStart); + if (binIndex !== undefined) { + const bin = bins[binIndex]; + bin.count += 1; + bin.photoIds.push(entry.photo.id); + } + } + + return { + unit, + bins, + start: domainStart, + end: domainEnd + }; + } + function updateStatus(message, tone = "info") { shareStatus.textContent = message; shareStatus.style.color = tone === "error" ? "#9d174d" : "var(--muted)"; @@ -652,10 +1072,30 @@ function htmlPage(): string { function clearGallery() { state.photos = []; + state.visiblePhotos = []; + state.activePhotoId = null; + state.timelineSelection = null; + state.timeline = { + unit: "day", + bins: [], + start: null, + end: null + }; markers.clearLayers(); route.setLatLngs([]); photoList.replaceChildren(emptyState); + emptyState.textContent = "No images loaded yet."; photoCount.textContent = "0 photos"; + timelineSummary.textContent = "No photos loaded yet."; + timelineRange.textContent = "All photos"; + timelineUnit.textContent = "Scale: day"; + timelineClear.disabled = true; + timelineBars.replaceChildren(); + timelineAxis.replaceChildren(); + timelineSelection.classList.remove("visible"); + timelineSelection.style.left = "0"; + timelineSelection.style.width = "0"; + timelineBrush.classList.remove("visible"); setProgress(0, 0, "ready"); } @@ -679,8 +1119,8 @@ function htmlPage(): string { } }); - function updateRouteView() { - const routePoints = state.photos + function updateRouteView(photos) { + const routePoints = photos .filter((photo) => photo.latitude !== null && photo.longitude !== null && photo.capturedAt) .sort((a, b) => String(a.capturedAt).localeCompare(String(b.capturedAt))) .map((photo) => [photo.latitude, photo.longitude]); @@ -694,60 +1134,333 @@ function htmlPage(): string { } } - function appendPhoto(photo) { - state.photos.push(photo); - photoCount.textContent = state.photos.length + (state.photos.length === 1 ? " photo" : " photos"); - if (emptyState.isConnected) { - emptyState.remove(); + function isPhotoInSelection(photo, selection = state.timelineSelection) { + if (!selection) { + return true; } - if (photo.latitude !== null && photo.longitude !== null) { - const marker = L.marker([photo.latitude, photo.longitude]).addTo(markers); - - marker.bindPopup( - '
' + - '' + photo.name + '' + - '' + photo.name + '' + - '' + formatDate(photo.capturedAt) + '' + - '' + - '
' - ); - - marker.on("mouseover", () => marker.openPopup()); - marker.on("click", () => openOverlay(photo)); + const photoDate = getPhotoTimestamp(photo); + if (!photoDate) { + return false; } - const item = document.createElement("article"); - item.className = "photo"; - item.innerHTML = - '' + photo.name + '' + - '
' + photo.name + '' + formatDate(photo.capturedAt) + '
'; - item.addEventListener("click", () => openOverlay(photo)); - photoList.appendChild(item); - - updateRouteView(); + return photoDate.getTime() >= selection.start && photoDate.getTime() < selection.end; } - function parseShareInput(value) { - const trimmed = value.trim(); - const url = new URL(trimmed); - const publicShare = url.pathname.match(/\\\/s\\\/([^/?#]+)/); - if (publicShare) { - return { - origin: url.origin, - davUrl: url.origin + "/public.php/dav/files/" + publicShare[1] + "/" - }; + function getVisiblePhotos() { + return state.photos.filter((photo) => isPhotoInSelection(photo)); + } + + function syncTimelineSelectionOverlay() { + const selection = state.timelineSelection; + if (!selection || !state.timeline.start || !state.timeline.end) { + timelineSelection.classList.remove("visible"); + timelineSelection.style.left = "0"; + timelineSelection.style.width = "0"; + return; } - const davShare = url.pathname.match(/\\\/public\\.php\\\/dav\\\/files\\\/([^/?#]+)\\\/?/); - if (davShare) { - return { - origin: url.origin, - davUrl: url.origin + "/public.php/dav/files/" + davShare[1] + "/" - }; + const total = state.timeline.end.getTime() - state.timeline.start.getTime(); + if (total <= 0) { + timelineSelection.classList.remove("visible"); + return; } - throw new Error("Please enter a public Nextcloud share link."); + const left = clamp((selection.start - state.timeline.start.getTime()) / total, 0, 1); + const right = clamp((selection.end - state.timeline.start.getTime()) / total, 0, 1); + const startPct = Math.min(left, right) * 100; + const widthPct = Math.max(1, (Math.max(left, right) - Math.min(left, right)) * 100); + timelineSelection.classList.add("visible"); + timelineSelection.style.left = startPct + "%"; + timelineSelection.style.width = widthPct + "%"; + } + + function setTimelineSelection(start, end) { + if (start === null || end === null) { + state.timelineSelection = null; + timelineClear.disabled = true; + } else { + const normalizedStart = Math.min(start, end); + const normalizedEnd = Math.max(start, end); + state.timelineSelection = { + start: normalizedStart, + end: normalizedEnd + }; + timelineClear.disabled = false; + } + + renderVisiblePhotos({ fitMap: true }); + } + + function clearTimelineSelection() { + setTimelineSelection(null, null); + } + + function renderTimeline() { + const timeline = buildTimeline(state.photos); + state.timeline = timeline; + timelineSummary.textContent = formatTimelineSummary(state.photos, timeline.unit); + timelineUnit.textContent = "Scale: " + timeline.unit; + + timelineBars.replaceChildren(); + timelineAxis.replaceChildren(); + + if (!timeline.bins.length) { + timelineRange.textContent = "All photos"; + syncTimelineSelectionOverlay(); + return; + } + + const maxCount = Math.max(...timeline.bins.map((bin) => bin.count), 1); + const labelStep = Math.max(1, Math.ceil(timeline.bins.length / 6)); + + timeline.bins.forEach((bin, index) => { + const bar = document.createElement("button"); + const isSelected = state.timelineSelection + ? bin.end.getTime() > state.timelineSelection.start && bin.start.getTime() < state.timelineSelection.end + : false; + const isActive = state.activePhotoId ? bin.photoIds.includes(state.activePhotoId) : false; + + bar.type = "button"; + bar.className = "timeline-bar" + (isSelected ? " selected" : "") + (isActive ? " active" : ""); + bar.title = bin.label + " - " + bin.count + (bin.count === 1 ? " photo" : " photos"); + bar.style.gridColumn = String(index + 1); + + const fill = document.createElement("div"); + fill.className = "timeline-bar-fill"; + fill.style.height = Math.max(4, Math.round((bin.count / maxCount) * 100)) + "%"; + bar.appendChild(fill); + + const meta = document.createElement("span"); + meta.className = "timeline-bar-meta"; + meta.textContent = index % labelStep === 0 || index === timeline.bins.length - 1 ? bin.label : ""; + bar.appendChild(meta); + + bar.addEventListener("click", () => { + if (timelineClickSuppressed) { + return; + } + setTimelineSelection(bin.start.getTime(), bin.end.getTime()); + }); + + timelineBars.appendChild(bar); + }); + + const selection = state.timelineSelection; + timelineRange.textContent = selection + ? formatSelectionRange(new Date(selection.start), new Date(selection.end)) + : "All photos"; + + const axisLabels = []; + timeline.bins.forEach((bin, index) => { + if (index % labelStep !== 0 && index !== timeline.bins.length - 1) { + return; + } + + const label = document.createElement("span"); + label.className = "timeline-axis-label"; + const position = timeline.bins.length === 1 ? 0 : index / (timeline.bins.length - 1); + label.style.left = position * 100 + "%"; + label.textContent = bin.label; + axisLabels.push(label); + }); + + axisLabels.forEach((label) => timelineAxis.appendChild(label)); + syncTimelineSelectionOverlay(); + } + + function renderVisiblePhotos(options = {}) { + const visiblePhotos = getVisiblePhotos(); + state.visiblePhotos = visiblePhotos; + markers.clearLayers(); + route.setLatLngs([]); + photoList.replaceChildren(); + + if (!visiblePhotos.length) { + emptyState.textContent = state.photos.length + ? "No photos match the selected range." + : "No images loaded yet."; + photoList.appendChild(emptyState); + photoCount.textContent = "0 photos"; + } else { + emptyState.textContent = "No images loaded yet."; + photoCount.textContent = + visiblePhotos.length + (visiblePhotos.length === 1 ? " photo shown" : " photos shown"); + + for (const photo of visiblePhotos) { + if (photo.latitude !== null && photo.longitude !== null) { + const marker = L.marker([photo.latitude, photo.longitude]).addTo(markers); + + marker.bindPopup( + '
' + + '' + photo.name + '' + + '' + photo.name + '' + + '' + formatDate(photo.capturedAt) + '' + + '' + + '
' + ); + + marker.on("mouseover", () => marker.openPopup()); + marker.on("click", () => { + state.activePhotoId = photo.id; + renderVisiblePhotos({ fitMap: false }); + openOverlay(photo); + }); + } + + const item = document.createElement("article"); + item.className = "photo" + (photo.id === state.activePhotoId ? " active" : ""); + item.innerHTML = + '' + photo.name + '' + + '
' + photo.name + '' + formatDate(photo.capturedAt) + '
'; + item.addEventListener("click", () => { + state.activePhotoId = photo.id; + renderVisiblePhotos({ fitMap: false }); + openOverlay(photo); + }); + photoList.appendChild(item); + } + } + + updateRouteView(visiblePhotos); + renderTimeline(); + + if (options.fitMap && visiblePhotos.length) { + const bounds = visiblePhotos.filter((photo) => photo.latitude !== null && photo.longitude !== null); + if (bounds.length === 1) { + map.setView([bounds[0].latitude, bounds[0].longitude], 13); + } else if (bounds.length > 1) { + map.fitBounds(bounds.map((photo) => [photo.latitude, photo.longitude]), { padding: [40, 40] }); + } + } + } + + let timelinePointerState = null; + let timelineClickSuppressed = false; + + function updateTimelineBrushOverlay(startX, endX) { + if (!state.timeline.start || !state.timeline.end) { + timelineBrush.classList.remove("visible"); + return; + } + + const width = timelineChart.getBoundingClientRect().width; + if (width <= 0) { + timelineBrush.classList.remove("visible"); + return; + } + + const leftX = clamp(Math.min(startX, endX), 0, width); + const rightX = clamp(Math.max(startX, endX), 0, width); + timelineBrush.classList.add("visible"); + timelineBrush.style.left = leftX + "px"; + timelineBrush.style.width = Math.max(1, rightX - leftX) + "px"; + } + + function clearTimelineBrushOverlay() { + timelineBrush.classList.remove("visible"); + timelineBrush.style.left = "0"; + timelineBrush.style.width = "0"; + } + + function timeFromTimelineX(x) { + if (!state.timeline.start || !state.timeline.end) { + return null; + } + + const width = timelineChart.getBoundingClientRect().width; + if (width <= 0) { + return null; + } + + const ratio = clamp(x / width, 0, 1); + const time = state.timeline.start.getTime() + ratio * (state.timeline.end.getTime() - state.timeline.start.getTime()); + return new Date(time); + } + + function selectTimelineBinAtX(x) { + if (!state.timeline.bins.length || !state.timeline.start || !state.timeline.end) { + return; + } + + const width = timelineChart.getBoundingClientRect().width; + if (width <= 0) { + return; + } + + const ratio = clamp(x / width, 0, 1); + const index = clamp(Math.floor(ratio * state.timeline.bins.length), 0, state.timeline.bins.length - 1); + const bin = state.timeline.bins[index]; + setTimelineSelection(bin.start.getTime(), bin.end.getTime()); + } + + timelineChart.addEventListener("pointerdown", (event) => { + if (!state.timeline.bins.length) { + return; + } + + const rect = timelineChart.getBoundingClientRect(); + const startX = event.clientX - rect.left; + timelinePointerState = { + pointerId: event.pointerId, + startX, + currentX: startX, + moved: false + }; + + timelineChart.setPointerCapture(event.pointerId); + updateTimelineBrushOverlay(startX, startX); + }); + + timelineChart.addEventListener("pointermove", (event) => { + if (!timelinePointerState || timelinePointerState.pointerId !== event.pointerId) { + return; + } + + const rect = timelineChart.getBoundingClientRect(); + const currentX = event.clientX - rect.left; + timelinePointerState.currentX = currentX; + timelinePointerState.moved = timelinePointerState.moved || Math.abs(currentX - timelinePointerState.startX) > 4; + updateTimelineBrushOverlay(timelinePointerState.startX, currentX); + }); + + function finishTimelinePointer(event) { + if (!timelinePointerState || timelinePointerState.pointerId !== event.pointerId) { + return; + } + + try { + timelineChart.releasePointerCapture(event.pointerId); + } catch { + // Pointer capture may already be released. + } + + if (timelinePointerState.moved) { + const startTime = timeFromTimelineX(timelinePointerState.startX); + const endTime = timeFromTimelineX(timelinePointerState.currentX); + if (startTime && endTime) { + setTimelineSelection(startTime.getTime(), endTime.getTime()); + } + timelineClickSuppressed = true; + setTimeout(() => { + timelineClickSuppressed = false; + }, 0); + } else { + selectTimelineBinAtX(timelinePointerState.currentX); + } + + clearTimelineBrushOverlay(); + timelinePointerState = null; + } + + timelineChart.addEventListener("pointerup", finishTimelinePointer); + timelineChart.addEventListener("pointercancel", finishTimelinePointer); + + timelineClear.addEventListener("click", clearTimelineSelection); + + function appendPhoto(photo) { + state.photos.push(photo); + renderVisiblePhotos({ fitMap: state.photos.length === 1 }); } function textContent(node, namespace, localName) {