feat: add interactive photo timeline
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user