feat: add interactive photo timeline
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -495,7 +666,7 @@ function htmlPage(): string {
|
||||
<span class="spinner" id="activity-spinner" aria-hidden="true"></span>
|
||||
<div class="status" id="share-status">Ready to load a share.</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-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
@@ -517,8 +688,29 @@ function htmlPage(): string {
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<section class="map-panel">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -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,13 +1134,160 @@ 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;
|
||||
}
|
||||
|
||||
const photoDate = getPhotoTimestamp(photo);
|
||||
if (!photoDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return photoDate.getTime() >= selection.start && photoDate.getTime() < selection.end;
|
||||
}
|
||||
|
||||
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 total = state.timeline.end.getTime() - state.timeline.start.getTime();
|
||||
if (total <= 0) {
|
||||
timelineSelection.classList.remove("visible");
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -714,40 +1301,166 @@ function htmlPage(): string {
|
||||
);
|
||||
|
||||
marker.on("mouseover", () => marker.openPopup());
|
||||
marker.on("click", () => openOverlay(photo));
|
||||
marker.on("click", () => {
|
||||
state.activePhotoId = photo.id;
|
||||
renderVisiblePhotos({ fitMap: false });
|
||||
openOverlay(photo);
|
||||
});
|
||||
}
|
||||
|
||||
const item = document.createElement("article");
|
||||
item.className = "photo";
|
||||
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", () => openOverlay(photo));
|
||||
item.addEventListener("click", () => {
|
||||
state.activePhotoId = photo.id;
|
||||
renderVisiblePhotos({ fitMap: false });
|
||||
openOverlay(photo);
|
||||
});
|
||||
photoList.appendChild(item);
|
||||
|
||||
updateRouteView();
|
||||
}
|
||||
}
|
||||
|
||||
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] + "/"
|
||||
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 davShare = url.pathname.match(/\\\/public\\.php\\\/dav\\\/files\\\/([^/?#]+)\\\/?/);
|
||||
if (davShare) {
|
||||
return {
|
||||
origin: url.origin,
|
||||
davUrl: url.origin + "/public.php/dav/files/" + davShare[1] + "/"
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
throw new Error("Please enter a public Nextcloud share link.");
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user