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
+
+
+
@@ -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 + '' +
- '
' + 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 + '' + 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 + '' +
+ '
' + 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 + '' + 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) {