feat: add interactive photo timeline

This commit is contained in:
2026-06-07 19:56:42 +02:00
parent 800ee472b9
commit 4084db119b

View File

@@ -333,6 +333,11 @@ function htmlPage(): string {
font-size: 0.82rem; font-size: 0.82rem;
} }
.photo.active {
border-color: rgba(45, 108, 223, 0.42);
background: rgba(45, 108, 223, 0.12);
}
.empty-state { .empty-state {
padding: 14px; padding: 14px;
border-radius: 14px; border-radius: 14px;
@@ -346,12 +351,26 @@ function htmlPage(): string {
position: relative; position: relative;
min-height: 0; min-height: 0;
overflow: hidden; 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 { #map {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 560px; min-height: 460px;
} }
.map-label { .map-label {
@@ -437,14 +456,166 @@ function htmlPage(): string {
margin: 10px 12px; 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) { @media (max-width: 920px) {
.layout { .layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
main {
grid-template-rows: minmax(400px, 1fr) auto;
}
#map { #map {
min-height: 460px; min-height: 460px;
} }
.timeline-header,
.timeline-footer {
flex-direction: column;
align-items: flex-start;
}
} }
</style> </style>
</head> </head>
@@ -495,7 +666,7 @@ function htmlPage(): string {
<span class="spinner" id="activity-spinner" aria-hidden="true"></span> <span class="spinner" id="activity-spinner" aria-hidden="true"></span>
<div class="status" id="share-status">Ready to load a share.</div> <div class="status" id="share-status">Ready to load a share.</div>
</div> </div>
<div class="progress" aria-label="Ladefortschritt"> <div class="progress" aria-label="Loading progress">
<div class="progress-bar" aria-hidden="true"> <div class="progress-bar" aria-hidden="true">
<div class="progress-fill" id="progress-fill"></div> <div class="progress-fill" id="progress-fill"></div>
</div> </div>
@@ -517,8 +688,29 @@ function htmlPage(): string {
</aside> </aside>
<main> <main>
<div class="map-label">Hover: thumbnail · click: fullscreen · route: time sorted</div> <section class="map-panel">
<div id="map"></div> <div class="map-label">Hover: thumbnail · click: fullscreen · route: time sorted</div>
<div id="map"></div>
</section>
<section class="card timeline" aria-label="Photo timeline">
<div class="timeline-header">
<div>
<h2>Timeline</h2>
<div class="timeline-summary" id="timeline-summary">No photos loaded yet.</div>
</div>
<button class="secondary" id="timeline-clear" type="button" disabled>Clear range</button>
</div>
<div class="timeline-chart" id="timeline-chart">
<div class="timeline-bars" id="timeline-bars"></div>
<div class="timeline-selection" id="timeline-selection"></div>
<div class="timeline-brush" id="timeline-brush"></div>
</div>
<div class="timeline-axis" id="timeline-axis"></div>
<div class="timeline-footer">
<span id="timeline-range">All photos</span>
<span id="timeline-unit">Scale: day</span>
</div>
</section>
</main> </main>
</div> </div>
@@ -545,9 +737,18 @@ function htmlPage(): string {
const state = { const state = {
photos: [], photos: [],
visiblePhotos: [],
objectUrls: [], objectUrls: [],
processed: 0, processed: 0,
total: 0 total: 0,
activePhotoId: null,
timelineSelection: null,
timeline: {
unit: "day",
bins: [],
start: null,
end: null
}
}; };
let activeImportController = null; let activeImportController = null;
@@ -575,6 +776,15 @@ function htmlPage(): string {
const progressText = mustGet("progress-text"); const progressText = mustGet("progress-text");
const progressDetail = mustGet("progress-detail"); const progressDetail = mustGet("progress-detail");
const activitySpinner = mustGet("activity-spinner"); 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 shareUrl = mustGet("share-url");
const shareStatus = mustGet("share-status"); const shareStatus = mustGet("share-status");
const loadShare = mustGet("load-share"); const loadShare = mustGet("load-share");
@@ -590,10 +800,12 @@ function htmlPage(): string {
function formatDate(value) { function formatDate(value) {
if (!value) { if (!value) {
return "kein Zeitstempel"; return "no timestamp";
} }
const date = new Date(value); 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) { function normalizeExifDate(value) {
@@ -624,6 +836,214 @@ function htmlPage(): string {
return Number.isNaN(date.getTime()) ? null : date.toISOString(); 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") { function updateStatus(message, tone = "info") {
shareStatus.textContent = message; shareStatus.textContent = message;
shareStatus.style.color = tone === "error" ? "#9d174d" : "var(--muted)"; shareStatus.style.color = tone === "error" ? "#9d174d" : "var(--muted)";
@@ -652,10 +1072,30 @@ function htmlPage(): string {
function clearGallery() { function clearGallery() {
state.photos = []; state.photos = [];
state.visiblePhotos = [];
state.activePhotoId = null;
state.timelineSelection = null;
state.timeline = {
unit: "day",
bins: [],
start: null,
end: null
};
markers.clearLayers(); markers.clearLayers();
route.setLatLngs([]); route.setLatLngs([]);
photoList.replaceChildren(emptyState); photoList.replaceChildren(emptyState);
emptyState.textContent = "No images loaded yet.";
photoCount.textContent = "0 photos"; 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"); setProgress(0, 0, "ready");
} }
@@ -679,8 +1119,8 @@ function htmlPage(): string {
} }
}); });
function updateRouteView() { function updateRouteView(photos) {
const routePoints = state.photos const routePoints = photos
.filter((photo) => photo.latitude !== null && photo.longitude !== null && photo.capturedAt) .filter((photo) => photo.latitude !== null && photo.longitude !== null && photo.capturedAt)
.sort((a, b) => String(a.capturedAt).localeCompare(String(b.capturedAt))) .sort((a, b) => String(a.capturedAt).localeCompare(String(b.capturedAt)))
.map((photo) => [photo.latitude, photo.longitude]); .map((photo) => [photo.latitude, photo.longitude]);
@@ -694,60 +1134,333 @@ function htmlPage(): string {
} }
} }
function appendPhoto(photo) { function isPhotoInSelection(photo, selection = state.timelineSelection) {
state.photos.push(photo); if (!selection) {
photoCount.textContent = state.photos.length + (state.photos.length === 1 ? " photo" : " photos"); return true;
if (emptyState.isConnected) {
emptyState.remove();
} }
if (photo.latitude !== null && photo.longitude !== null) { const photoDate = getPhotoTimestamp(photo);
const marker = L.marker([photo.latitude, photo.longitude]).addTo(markers); if (!photoDate) {
return false;
marker.bindPopup(
'<div class="thumb">' +
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
'<strong>' + photo.name + '</strong>' +
'<small>' + formatDate(photo.capturedAt) + '</small>' +
'<button type="button" data-open="' + photo.id + '">Open</button>' +
'</div>'
);
marker.on("mouseover", () => marker.openPopup());
marker.on("click", () => openOverlay(photo));
} }
const item = document.createElement("article"); return photoDate.getTime() >= selection.start && photoDate.getTime() < selection.end;
item.className = "photo";
item.innerHTML =
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
'<div><strong>' + photo.name + '</strong><span>' + formatDate(photo.capturedAt) + '</span></div>';
item.addEventListener("click", () => openOverlay(photo));
photoList.appendChild(item);
updateRouteView();
} }
function parseShareInput(value) { function getVisiblePhotos() {
const trimmed = value.trim(); return state.photos.filter((photo) => isPhotoInSelection(photo));
const url = new URL(trimmed); }
const publicShare = url.pathname.match(/\\\/s\\\/([^/?#]+)/);
if (publicShare) { function syncTimelineSelectionOverlay() {
return { const selection = state.timelineSelection;
origin: url.origin, if (!selection || !state.timeline.start || !state.timeline.end) {
davUrl: url.origin + "/public.php/dav/files/" + publicShare[1] + "/" timelineSelection.classList.remove("visible");
}; timelineSelection.style.left = "0";
timelineSelection.style.width = "0";
return;
} }
const davShare = url.pathname.match(/\\\/public\\.php\\\/dav\\\/files\\\/([^/?#]+)\\\/?/); const total = state.timeline.end.getTime() - state.timeline.start.getTime();
if (davShare) { if (total <= 0) {
return { timelineSelection.classList.remove("visible");
origin: url.origin, return;
davUrl: url.origin + "/public.php/dav/files/" + davShare[1] + "/"
};
} }
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(
'<div class="thumb">' +
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
'<strong>' + photo.name + '</strong>' +
'<small>' + formatDate(photo.capturedAt) + '</small>' +
'<button type="button" data-open="' + photo.id + '">Open</button>' +
'</div>'
);
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 =
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
'<div><strong>' + photo.name + '</strong><span>' + formatDate(photo.capturedAt) + '</span></div>';
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) { function textContent(node, namespace, localName) {