diff --git a/src/server/app-assets.ts b/src/server/app-assets.ts
new file mode 100644
index 0000000..b91757c
--- /dev/null
+++ b/src/server/app-assets.ts
@@ -0,0 +1,2164 @@
+export const APP_STYLES = ` :root {
+ --radius-xl: 0;
+ --radius-lg: 0;
+ --radius-md: 0;
+ --shadow: 0 12px 30px rgba(2, 6, 23, 0.14);
+ --shadow-soft: 0 8px 18px rgba(2, 6, 23, 0.1);
+ --chart-grid: rgba(255, 255, 255, 0.035);
+ --chart-band: rgba(100, 116, 139, 0.16);
+ }
+
+ body[data-theme="dark"] {
+ color-scheme: dark;
+ --bg: #0b1120;
+ --bg-accent-a: rgba(59, 130, 246, 0.12);
+ --bg-accent-b: rgba(30, 41, 59, 0.14);
+ --panel: rgba(15, 23, 42, 0.9);
+ --panel-float: rgba(15, 23, 42, 0.72);
+ --panel-strong: rgba(30, 41, 59, 0.96);
+ --surface: rgba(15, 23, 42, 0.72);
+ --surface-float: rgba(15, 23, 42, 0.54);
+ --surface-strong: rgba(30, 41, 59, 0.95);
+ --border: rgba(148, 163, 184, 0.16);
+ --border-strong: rgba(148, 163, 184, 0.26);
+ --text: #e2e8f0;
+ --muted: #94a3b8;
+ --accent: #94a3b8;
+ --accent-strong: #cbd5e1;
+ --input-bg: rgba(15, 23, 42, 0.92);
+ --button-secondary-bg: rgba(148, 163, 184, 0.12);
+ --button-secondary-hover: rgba(148, 163, 184, 0.18);
+ --danger-bg: rgba(239, 68, 68, 0.12);
+ --danger-hover: rgba(239, 68, 68, 0.18);
+ --danger-text: #fca5a5;
+ --status-tint: rgba(148, 163, 184, 0.16);
+ --progress-track: rgba(148, 163, 184, 0.16);
+ --photo-bg: rgba(15, 23, 42, 0.7);
+ --photo-active-bg: rgba(51, 65, 85, 0.76);
+ --photo-active-border: rgba(148, 163, 184, 0.34);
+ --empty-border: rgba(148, 163, 184, 0.22);
+ --overlay-bg: rgba(2, 6, 23, 0.9);
+ --card-border: rgba(148, 163, 184, 0.16);
+ --timeline-surface: rgba(15, 23, 42, 0.88);
+ --timeline-surface-soft: rgba(15, 23, 42, 0.76);
+ --timeline-surface-strong: rgba(30, 41, 59, 0.96);
+ --timeline-border: rgba(148, 163, 184, 0.18);
+ --timeline-border-strong: rgba(191, 219, 254, 0.28);
+ --timeline-text: #e2e8f0;
+ --timeline-text-muted: #94a3b8;
+ --timeline-bar-border: rgba(148, 163, 184, 0.48);
+ --timeline-bar-fill: rgba(148, 163, 184, 0.84);
+ --timeline-bar-fill-hover: rgba(125, 211, 252, 0.88);
+ --timeline-bar-fill-selected: rgba(186, 230, 253, 0.92);
+ --timeline-bar-fill-selected-active: rgba(226, 232, 240, 0.98);
+ --timeline-axis-color: rgba(148, 163, 184, 0.5);
+ --timeline-axis-label: #d1d5db;
+ --timeline-meta: rgba(209, 213, 219, 0.72);
+ --timeline-selection-border: rgba(148, 163, 184, 0.34);
+ --timeline-selection-bg: rgba(148, 163, 184, 0.08);
+ --timeline-brush-border: rgba(148, 163, 184, 0.56);
+ --timeline-brush-bg: rgba(148, 163, 184, 0.14);
+ --chart-grid: rgba(255, 255, 255, 0.05);
+ --accent-shadow: rgba(148, 163, 184, 0.18);
+ --spinner-track: rgba(125, 211, 252, 0.26);
+ --route-color: #7dd3fc;
+ --marker-border: rgba(15, 23, 42, 0.94);
+ --marker-border-active: rgba(191, 219, 254, 0.96);
+ --marker-shadow: 0 8px 18px rgba(2, 6, 23, 0.34);
+ --marker-active-shadow: 0 0 0 3px rgba(125, 211, 252, 0.18), 0 10px 22px rgba(2, 6, 23, 0.38);
+ }
+
+ body[data-theme="light"] {
+ color-scheme: light;
+ --bg: #eef2f6;
+ --bg-accent-a: rgba(100, 116, 139, 0.08);
+ --bg-accent-b: rgba(148, 163, 184, 0.06);
+ --panel: rgba(255, 255, 255, 0.9);
+ --panel-float: rgba(255, 255, 255, 0.68);
+ --panel-strong: rgba(255, 255, 255, 0.98);
+ --surface: rgba(255, 255, 255, 0.84);
+ --surface-float: rgba(255, 255, 255, 0.56);
+ --surface-strong: rgba(241, 245, 249, 0.98);
+ --border: rgba(15, 23, 42, 0.1);
+ --border-strong: rgba(15, 23, 42, 0.16);
+ --text: #111827;
+ --muted: #5b6472;
+ --accent: #64748b;
+ --accent-strong: #475569;
+ --input-bg: white;
+ --button-secondary-bg: rgba(15, 23, 42, 0.05);
+ --button-secondary-hover: rgba(15, 23, 42, 0.08);
+ --danger-bg: rgba(120, 53, 15, 0.08);
+ --danger-hover: rgba(120, 53, 15, 0.14);
+ --danger-text: #b91c1c;
+ --status-tint: rgba(100, 116, 139, 0.12);
+ --progress-track: rgba(15, 23, 42, 0.08);
+ --photo-bg: rgba(255, 255, 255, 0.84);
+ --photo-active-bg: rgba(100, 116, 139, 0.12);
+ --photo-active-border: rgba(100, 116, 139, 0.32);
+ --empty-border: rgba(15, 23, 42, 0.16);
+ --overlay-bg: rgba(8, 12, 18, 0.78);
+ --card-border: rgba(15, 23, 42, 0.11);
+ --timeline-surface: rgba(255, 255, 255, 0.84);
+ --timeline-surface-soft: rgba(255, 255, 255, 0.94);
+ --timeline-surface-strong: rgba(241, 245, 249, 0.98);
+ --timeline-border: rgba(148, 163, 184, 0.22);
+ --timeline-border-strong: rgba(100, 116, 139, 0.32);
+ --timeline-text: #303030;
+ --timeline-text-muted: rgba(48, 48, 48, 0.72);
+ --timeline-bar-border: rgba(75, 85, 99, 0.82);
+ --timeline-bar-fill: rgba(100, 116, 139, 0.82);
+ --timeline-bar-fill-hover: rgba(75, 85, 99, 0.92);
+ --timeline-bar-fill-selected: rgba(55, 65, 81, 0.92);
+ --timeline-bar-fill-selected-active: rgba(15, 23, 42, 0.96);
+ --timeline-axis-color: rgba(107, 114, 128, 0.52);
+ --timeline-axis-label: #303030;
+ --timeline-meta: rgba(48, 48, 48, 0.72);
+ --timeline-selection-border: rgba(107, 114, 128, 0.3);
+ --timeline-selection-bg: rgba(107, 114, 128, 0.08);
+ --timeline-brush-border: rgba(107, 114, 128, 0.52);
+ --timeline-brush-bg: rgba(107, 114, 128, 0.12);
+ --chart-grid: rgba(15, 23, 42, 0.06);
+ --accent-shadow: rgba(100, 116, 139, 0.16);
+ --spinner-track: rgba(96, 165, 250, 0.22);
+ --route-color: #2d6cdf;
+ --marker-border: rgba(255, 255, 255, 0.92);
+ --marker-border-active: rgba(100, 116, 139, 0.95);
+ --marker-shadow: 0 8px 18px rgba(15, 23, 42, 0.22);
+ --marker-active-shadow: 0 0 0 3px rgba(100, 116, 139, 0.18), 0 10px 22px rgba(15, 23, 42, 0.28);
+ }
+
+ * {
+ box-sizing: border-box;
+ }
+
+ html,
+ body {
+ margin: 0;
+ min-height: 100%;
+ }
+
+ body {
+ position: relative;
+ height: 100dvh;
+ overflow: hidden;
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: var(--text);
+ background:
+ radial-gradient(circle at top left, var(--bg-accent-a), transparent 28%),
+ radial-gradient(circle at bottom right, var(--bg-accent-b), transparent 26%),
+ var(--bg);
+ }
+
+ h1,
+ h2,
+ p {
+ margin: 0;
+ }
+
+ aside,
+ main,
+ .overlay-card {
+ background: var(--panel);
+ backdrop-filter: blur(14px);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow);
+ }
+
+ .muted {
+ color: var(--muted);
+ }
+
+ aside {
+ position: absolute;
+ top: 16px;
+ left: 16px;
+ bottom: 128px;
+ width: min(280px, calc(100vw - 32px));
+ min-height: 0;
+ min-width: 0;
+ padding: 8px;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 8px;
+ overflow: hidden;
+ z-index: 700;
+ backdrop-filter: blur(16px);
+ background: var(--timeline-surface-soft);
+ border-color: var(--timeline-border);
+ transition: width 180ms ease, transform 180ms ease, opacity 180ms ease, padding 180ms ease;
+ }
+
+ aside.sidebar-collapsed {
+ width: 44px;
+ padding: 6px;
+ }
+
+ aside.sidebar-collapsed .panel-stack {
+ padding: 0;
+ background: transparent;
+ border-color: transparent;
+ backdrop-filter: none;
+ }
+
+ aside.sidebar-collapsed .panel-stack > :not(.panel-toolbar),
+ aside.sidebar-collapsed .photo-card {
+ display: none;
+ }
+
+ aside.sidebar-collapsed .panel-toolbar {
+ justify-content: center;
+ }
+
+ aside.sidebar-collapsed .theme-toggle {
+ display: none;
+ }
+
+ .card {
+ min-width: 0;
+ padding: 10px;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--timeline-border);
+ background: var(--timeline-surface-soft);
+ color: var(--timeline-text);
+ backdrop-filter: blur(16px);
+ }
+
+ .photo-card {
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ overflow: hidden;
+ }
+
+ .card h2 {
+ font-size: 0.92rem;
+ margin-bottom: 6px;
+ }
+
+ .card p,
+ .card small {
+ color: var(--timeline-text-muted);
+ }
+
+ label {
+ display: grid;
+ gap: 6px;
+ margin-top: 10px;
+ font-size: 0.84rem;
+ }
+
+ input {
+ width: 100%;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-md);
+ padding: 9px 11px;
+ font: inherit;
+ color: var(--text);
+ background: var(--input-bg);
+ }
+
+ .button-row {
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+ flex-wrap: wrap;
+ }
+
+ .panel-stack {
+ display: grid;
+ gap: 8px;
+ }
+
+ .panel-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .theme-toggle {
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ justify-content: center;
+ }
+
+ .top-right-controls {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ z-index: 900;
+ display: grid;
+ gap: 8px;
+ }
+
+ .route-toggle {
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ justify-content: center;
+ }
+
+ .theme-toggle,
+ .route-toggle {
+ background: var(--timeline-surface-soft);
+ color: var(--timeline-text);
+ border: 1px solid var(--timeline-border);
+ box-shadow: var(--shadow-soft);
+ }
+
+ .theme-toggle:hover,
+ .route-toggle:hover {
+ background: var(--timeline-surface-strong);
+ }
+
+ .theme-toggle .button-icon,
+ .route-toggle .button-icon {
+ width: 18px;
+ height: 18px;
+ }
+
+ .panel-collapse {
+ width: 32px;
+ min-width: 32px;
+ height: 32px;
+ padding: 0;
+ justify-content: center;
+ flex: 0 0 auto;
+ }
+
+ button {
+ border: 0;
+ border-radius: var(--radius-md);
+ padding: 8px 11px;
+ font: inherit;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .button-icon {
+ width: 1em;
+ height: 1em;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ }
+
+ .button-icon svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+
+ .primary {
+ background: var(--accent);
+ color: white;
+ box-shadow: 0 6px 14px var(--accent-shadow);
+ }
+
+ .primary:hover {
+ background: var(--accent-strong);
+ }
+
+ .secondary {
+ background: var(--button-secondary-bg);
+ color: var(--text);
+ }
+
+ .secondary:hover {
+ background: var(--button-secondary-hover);
+ }
+
+ .danger {
+ background: var(--danger-bg);
+ color: var(--danger-text);
+ }
+
+ .danger:hover {
+ background: var(--danger-hover);
+ }
+
+ .status {
+ margin-top: 6px;
+ font-size: 0.8rem;
+ color: var(--muted);
+ overflow-wrap: anywhere;
+ }
+
+ .status-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 6px;
+ min-width: 0;
+ }
+
+ .status-row .status {
+ margin-top: 0;
+ }
+
+ .spinner {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ border: 2px solid var(--spinner-track);
+ border-top-color: var(--accent);
+ opacity: 0;
+ transform: scale(0.8);
+ transition: opacity 120ms ease, transform 120ms ease;
+ }
+
+ .spinner.active {
+ opacity: 1;
+ transform: scale(1);
+ animation: spin 0.8s linear infinite;
+ }
+
+ @keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+ }
+
+ .progress {
+ display: grid;
+ gap: 6px;
+ margin-top: 8px;
+ }
+
+ .progress-bar {
+ height: 8px;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ background: var(--progress-track);
+ }
+
+ .progress-fill {
+ width: 0%;
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, var(--accent), #67b2ff);
+ transition: width 180ms ease;
+ }
+
+ .progress-meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 0.76rem;
+ color: var(--muted);
+ min-width: 0;
+ }
+
+ .progress-meta span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ }
+
+ .list {
+ --photo-thumb-size: 64px;
+ display: flex;
+ flex-wrap: wrap;
+ align-content: flex-start;
+ gap: 4px;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scrollbar-gutter: stable;
+ padding-right: 2px;
+ }
+
+ .photo {
+ display: block;
+ position: relative;
+ flex: 0 0 var(--photo-thumb-size);
+ width: var(--photo-thumb-size);
+ height: var(--photo-thumb-size);
+ padding: 0;
+ border-radius: var(--radius-md);
+ background: var(--timeline-surface-soft);
+ border: 1px solid var(--timeline-border);
+ color: var(--timeline-text);
+ min-width: 0;
+ overflow: hidden;
+ }
+
+ .photo img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center center;
+ border-radius: 0;
+ display: block;
+ }
+
+ .photo::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(to top, rgba(15, 23, 42, 0.34), transparent 42%);
+ opacity: 0;
+ transition: opacity 140ms ease;
+ }
+
+ .photo:hover::after,
+ .photo.active::after {
+ opacity: 1;
+ }
+
+ .photo.active {
+ border-color: var(--timeline-border-strong);
+ background: var(--timeline-surface-strong);
+ }
+
+ .empty-state {
+ padding: 10px;
+ border-radius: var(--radius-md);
+ border: 1px dashed var(--timeline-border);
+ color: var(--timeline-text-muted);
+ background: var(--timeline-surface);
+ text-align: center;
+ }
+
+ main {
+ position: absolute;
+ inset: 0;
+ min-height: 0;
+ min-width: 0;
+ overflow: hidden;
+ }
+
+ .map-panel {
+ position: absolute;
+ inset: 0;
+ min-height: 0;
+ overflow: hidden;
+ border-radius: 0;
+ border: 0;
+ background: transparent;
+ backdrop-filter: none;
+ box-shadow: none;
+ }
+
+ #map {
+ width: 100%;
+ height: 100%;
+ }
+
+ .overlay {
+ position: fixed;
+ inset: 0;
+ display: none;
+ place-items: center;
+ background: var(--overlay-bg);
+ z-index: 1000;
+ padding: 24px;
+ }
+
+ .overlay.open {
+ display: grid;
+ }
+
+ .overlay-card {
+ display: inline-grid;
+ width: fit-content;
+ max-width: 100%;
+ overflow: hidden;
+ border-radius: 0;
+ background: var(--timeline-surface-soft);
+ border-color: var(--timeline-border);
+ color: var(--timeline-text);
+ }
+
+ .overlay-media {
+ position: relative;
+ width: fit-content;
+ max-width: 100%;
+ min-height: 48vh;
+ background: var(--timeline-surface-strong);
+ }
+
+ .overlay-card header {
+ padding: 16px 18px;
+ border-bottom: 1px solid var(--timeline-border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: var(--timeline-surface-strong);
+ color: var(--timeline-text);
+ }
+
+ .overlay-card img {
+ display: block;
+ width: auto;
+ max-width: min(1120px, calc(100vw - 48px));
+ max-height: 78vh;
+ object-fit: contain;
+ background: var(--timeline-surface-strong);
+ transition: opacity 120ms ease;
+ }
+
+ .overlay-card.loading img {
+ opacity: 0;
+ }
+
+ .overlay-loading {
+ position: absolute;
+ inset: 0;
+ display: none;
+ place-items: center;
+ align-content: center;
+ gap: 10px;
+ padding: 24px;
+ min-width: 240px;
+ text-align: center;
+ color: var(--timeline-text-muted);
+ }
+
+ .overlay-loading.active {
+ display: grid;
+ }
+
+ .overlay-loading .spinner {
+ opacity: 1;
+ transform: scale(1);
+ animation: spin 0.8s linear infinite;
+ }
+
+ .overlay-loading.error .spinner {
+ animation: none;
+ opacity: 0.45;
+ }
+
+ .overlay-loading strong {
+ color: var(--timeline-text);
+ font-size: 0.92rem;
+ font-weight: 600;
+ }
+
+ .close {
+ background: var(--timeline-surface);
+ color: var(--timeline-text);
+ }
+
+ .thumb {
+ width: min(210px, 100%);
+ box-sizing: border-box;
+ padding: 8px;
+ display: grid;
+ gap: 8px;
+ background: var(--timeline-surface-soft);
+ border: 1px solid var(--timeline-border);
+ color: var(--timeline-text);
+ }
+
+ .thumb small {
+ color: var(--timeline-text-muted);
+ }
+
+ .thumb img {
+ width: 100%;
+ aspect-ratio: 4 / 3;
+ object-fit: cover;
+ border-radius: 0;
+ }
+
+ .leaflet-popup-content-wrapper,
+ .leaflet-popup-tip {
+ background: var(--timeline-surface-strong);
+ color: var(--timeline-text);
+ }
+
+ .leaflet-popup-content-wrapper {
+ border-radius: 6px;
+ overflow: hidden;
+ }
+
+ .leaflet-popup-content {
+ margin: 14px 12px 12px;
+ color: var(--timeline-text);
+ }
+
+ .leaflet-popup-content small {
+ color: var(--timeline-text-muted);
+ }
+
+ .photo-map-marker {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ overflow: hidden;
+ border: 2px solid var(--marker-border);
+ box-shadow: var(--marker-shadow);
+ background: var(--timeline-surface-strong);
+ }
+
+ .photo-map-marker img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .photo-map-marker.active {
+ border-color: var(--marker-border-active);
+ box-shadow: var(--marker-active-shadow);
+ }
+
+ .timeline {
+ position: absolute;
+ left: 8px;
+ right: 8px;
+ bottom: 6px;
+ padding: 4px;
+ display: grid;
+ gap: 4px;
+ z-index: 650;
+ backdrop-filter: none;
+ background: transparent;
+ opacity: 1;
+ border-color: var(--timeline-border);
+ transition: height 180ms ease, transform 180ms ease, opacity 180ms ease, padding 180ms ease;
+ }
+
+ .timeline.timeline-collapsed {
+ height: 40px;
+ padding: 4px;
+ overflow: hidden;
+ }
+
+ .timeline.timeline-collapsed .timeline-summary,
+ .timeline.timeline-collapsed .timeline-chart,
+ .timeline.timeline-collapsed .timeline-body {
+ display: none;
+ }
+
+ .timeline-header h2 {
+ margin: 0 0 4px;
+ font-size: 0.84rem;
+ }
+
+ .timeline-summary {
+ color: var(--timeline-text-muted);
+ font-size: 0.72rem;
+ }
+
+ .timeline-body {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 4px;
+ align-items: stretch;
+ }
+
+ .timeline-chart {
+ position: relative;
+ min-height: 64px;
+ padding: 4px 6px 4px;
+ border-radius: 0;
+ border: 0;
+ background: var(--timeline-surface);
+ overflow: hidden;
+ touch-action: none;
+ user-select: none;
+ box-shadow: none;
+ }
+
+ .timeline-selection,
+ .timeline-brush {
+ position: absolute;
+ top: 4px;
+ bottom: 18px;
+ left: 0;
+ width: 0;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms ease, background 120ms ease, border-color 120ms ease;
+ }
+
+ .timeline-selection {
+ background: var(--timeline-selection-bg);
+ border: 1px solid var(--timeline-selection-border);
+ z-index: 0;
+ }
+
+ .timeline-selection.visible {
+ opacity: 1;
+ }
+
+ .timeline-brush {
+ background: var(--timeline-brush-bg);
+ border: 1px solid var(--timeline-brush-border);
+ z-index: 2;
+ }
+
+ .timeline-brush.visible {
+ opacity: 1;
+ }
+
+ .timeline-chart::before {
+ content: none;
+ }
+
+ .timeline-bars {
+ height: 36px;
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(8px, 1fr);
+ align-items: end;
+ gap: 0;
+ position: relative;
+ z-index: 1;
+ }
+
+ .timeline-bar {
+ position: relative;
+ height: 100%;
+ display: grid;
+ align-items: end;
+ cursor: pointer;
+ border-radius: 0;
+ background: transparent;
+ padding: 0;
+ border: 0;
+ }
+
+ .timeline-bar-fill {
+ width: 100%;
+ border-radius: inherit;
+ min-height: 3px;
+ background: var(--timeline-bar-fill);
+ border: 1px solid var(--timeline-bar-border);
+ transition: transform 140ms ease, background 140ms ease, opacity 140ms ease, box-shadow 140ms ease;
+ transform-origin: bottom;
+ box-shadow: none;
+ box-sizing: border-box;
+ }
+
+ .timeline-bar:hover .timeline-bar-fill,
+ .timeline-bar.active .timeline-bar-fill {
+ background: var(--timeline-bar-fill-hover);
+ }
+
+ .timeline-bar.selected .timeline-bar-fill {
+ background: var(--timeline-bar-fill-selected);
+ }
+
+ .timeline-bar.active .timeline-bar-fill {
+ transform: translateY(-1px);
+ }
+
+ .timeline-bar.selected.active .timeline-bar-fill {
+ background: var(--timeline-bar-fill-selected-active);
+ }
+
+ .timeline-bar-meta {
+ position: absolute;
+ left: 50%;
+ bottom: -12px;
+ transform: translateX(-50%);
+ white-space: nowrap;
+ font-size: 0.62rem;
+ color: var(--timeline-axis-label);
+ font-variant-numeric: tabular-nums;
+ pointer-events: none;
+ }
+
+ .timeline-actions {
+ display: flex;
+ align-items: flex-start;
+ gap: 4px;
+ justify-content: flex-end;
+ padding-top: 0;
+ }
+
+ .timeline-actions .panel-collapse {
+ width: 28px;
+ min-width: 28px;
+ height: 28px;
+ padding: 0;
+ justify-content: center;
+ background: var(--timeline-surface-soft);
+ color: var(--timeline-text);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
+ border: 1px solid var(--timeline-border);
+ }
+
+ @media (max-width: 920px) {
+ body {
+ height: auto;
+ min-height: 100dvh;
+ overflow: auto;
+ }
+
+ aside {
+ position: static;
+ width: auto;
+ margin: 12px;
+ bottom: auto;
+ right: auto;
+ top: auto;
+ overflow: visible;
+ }
+
+ .theme-toggle {
+ top: 12px;
+ right: 12px;
+ }
+
+ .top-right-controls {
+ top: 12px;
+ right: 12px;
+ }
+
+ .list {
+ max-height: min(52vh, 420px);
+ --photo-thumb-size: 64px;
+ gap: 4px;
+ }
+
+ main {
+ position: static;
+ inset: auto;
+ min-height: 60vh;
+ }
+
+ #map {
+ min-height: 60vh;
+ }
+
+ .timeline {
+ position: static;
+ left: auto;
+ right: auto;
+ bottom: auto;
+ margin: 0 12px 12px;
+ }
+ }
+`;
+
+export const CLIENT_SCRIPT = ` import exifr from "https://cdn.jsdelivr.net/npm/exifr@7.1.3/+esm";
+
+ const state = {
+ photos: [],
+ visiblePhotos: [],
+ objectUrls: [],
+ processed: 0,
+ total: 0,
+ activePhotoId: null,
+ timelineSelection: null,
+ timelineViewport: null,
+ timeline: {
+ intervalMs: 15 * 60 * 1000,
+ bins: [],
+ start: null,
+ end: null
+ }
+ };
+ let activeImportController = null;
+ const IMPORT_RENDER_INTERVAL_MS = 180;
+ let scheduledRenderFrame = null;
+ let scheduledRenderTimer = null;
+ let scheduledRenderOptions = { fitMap: false };
+ let lastRenderAt = 0;
+ const SHARE_PARAM_NAME = "share";
+
+ const map = L.map("map", { zoomControl: true }).setView([52.5208, 13.4095], 13);
+ const lightMapLayer = L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
+ maxZoom: 20,
+ subdomains: "abcd",
+ attribution: "© OpenStreetMap contributors © CARTO"
+ });
+ const darkMapLayer = L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
+ maxZoom: 20,
+ subdomains: "abcd",
+ attribution: "© OpenStreetMap contributors © CARTO"
+ });
+ const mapLayers = {
+ light: lightMapLayer,
+ dark: darkMapLayer
+ };
+ let activeMapLayer = lightMapLayer;
+ activeMapLayer.addTo(map);
+
+ const markers = L.layerGroup().addTo(map);
+ const route = L.polyline([], {
+ color: "#2d6cdf",
+ weight: 4,
+ opacity: 0.85
+ });
+ const routeColors = {
+ light: "#2d6cdf",
+ dark: "#7dd3fc"
+ };
+ const ROUTE_VISIBLE_STORAGE_KEY = "routeVisible";
+ let routeVisible = localStorage.getItem(ROUTE_VISIBLE_STORAGE_KEY) !== "false";
+
+ const overlay = mustGet("overlay");
+ const overlayTitle = mustGet("overlay-title");
+ const overlayImage = mustGet("overlay-image");
+ const overlayLoading = mustGet("overlay-loading");
+ const overlayLoadingText = mustGet("overlay-loading-text");
+ const closeOverlay = mustGet("close-overlay");
+ const routeToggle = mustGet("route-toggle");
+ const routeToggleIcon = mustGet("route-toggle-icon");
+ const photoList = mustGet("photo-list");
+ const emptyState = mustGet("empty-state");
+ const photoCount = mustGet("photo-count");
+ const progressFill = mustGet("progress-fill");
+ const progressText = mustGet("progress-text");
+ const progressDetail = mustGet("progress-detail");
+ const activitySpinner = mustGet("activity-spinner");
+ const timelineSummary = mustGet("timeline-summary");
+ const timelineClearButton = mustGet("timeline-clear");
+ const timelineChart = mustGet("timeline-chart");
+ const timelineBars = mustGet("timeline-bars");
+ const timelineSelection = mustGet("timeline-selection");
+ const timelineBrush = mustGet("timeline-brush");
+ const themeToggle = mustGet("theme-toggle");
+ const themeToggleIcon = mustGet("theme-toggle-icon");
+ const sidebarCollapseButton = mustGet("sidebar-collapse");
+ const sidebarCollapseIcon = mustGet("sidebar-collapse-icon");
+ const timelineCollapseButton = mustGet("timeline-collapse");
+ const timelineCollapseIcon = mustGet("timeline-collapse-icon");
+ const importUrlInput = mustGet("share-url");
+ const importStatus = mustGet("share-status");
+ const importButton = mustGet("load-share");
+ const stopImportButton = mustGet("cancel-share");
+ let overlayLoadToken = 0;
+
+ function mustGet(id) {
+ const element = document.getElementById(id);
+ if (!element) {
+ throw new Error("Missing UI element: " + id);
+ }
+ return element;
+ }
+
+ function escapeHtml(value) {
+ return String(value ?? "").replace(/[&<>"']/g, (character) => ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'"
+ })[character]);
+ }
+
+ function buildRemoteImageUrl(shareUrlValue, entryHref) {
+ const params = new URLSearchParams({
+ share: shareUrlValue.trim(),
+ url: entryHref
+ });
+ return "/api/nextcloud/blob?" + params.toString();
+ }
+
+ async function loadImageElement(blob) {
+ const objectUrl = URL.createObjectURL(blob);
+ const image = new Image();
+
+ try {
+ await new Promise((resolve, reject) => {
+ image.onload = () => resolve();
+ image.onerror = () => reject(new Error("Could not decode image."));
+ image.src = objectUrl;
+ });
+ return image;
+ } finally {
+ URL.revokeObjectURL(objectUrl);
+ }
+ }
+
+ async function createThumbnailObjectUrl(blob) {
+ const maxDimension = 320;
+ const quality = 0.82;
+ let width = 0;
+ let height = 0;
+ let drawSource = null;
+ let closeBitmap = null;
+ let sourceImage = null;
+
+ if (typeof createImageBitmap === "function") {
+ const bitmap = await createImageBitmap(blob);
+ width = bitmap.width;
+ height = bitmap.height;
+ drawSource = bitmap;
+ closeBitmap = () => bitmap.close();
+ } else {
+ sourceImage = await loadImageElement(blob);
+ width = sourceImage.naturalWidth;
+ height = sourceImage.naturalHeight;
+ drawSource = sourceImage;
+ }
+
+ const scale = Math.min(1, maxDimension / Math.max(width, height));
+ const targetWidth = Math.max(1, Math.round(width * scale));
+ const targetHeight = Math.max(1, Math.round(height * scale));
+ const canvas = document.createElement("canvas");
+ const context = canvas.getContext("2d");
+
+ if (!context) {
+ if (closeBitmap) {
+ closeBitmap();
+ }
+ throw new Error("Canvas 2D context unavailable.");
+ }
+
+ canvas.width = targetWidth;
+ canvas.height = targetHeight;
+ context.drawImage(drawSource, 0, 0, targetWidth, targetHeight);
+
+ const thumbnailBlob = await new Promise((resolve, reject) => {
+ canvas.toBlob(
+ (result) => {
+ if (result) {
+ resolve(result);
+ return;
+ }
+ reject(new Error("Could not create thumbnail."));
+ },
+ "image/jpeg",
+ quality
+ );
+ });
+
+ if (closeBitmap) {
+ closeBitmap();
+ }
+
+ if (sourceImage) {
+ sourceImage.removeAttribute("src");
+ }
+
+ return URL.createObjectURL(thumbnailBlob);
+ }
+
+ function setTheme(theme, persist = true) {
+ const resolvedTheme = theme === "light" ? "light" : "dark";
+ document.body.dataset.theme = resolvedTheme;
+ if (persist) {
+ localStorage.setItem("theme", resolvedTheme);
+ }
+ setMapTheme(resolvedTheme);
+
+ if (resolvedTheme === "dark") {
+ themeToggle.setAttribute("aria-label", "Switch to light mode");
+ themeToggleIcon.innerHTML =
+ '";
+ } else {
+ themeToggle.setAttribute("aria-label", "Switch to dark mode");
+ themeToggleIcon.innerHTML =
+ '";
+ }
+ }
+
+ function setMapTheme(theme) {
+ const resolvedTheme = theme === "dark" ? "dark" : "light";
+ const nextMapLayer = mapLayers[resolvedTheme];
+
+ if (activeMapLayer !== nextMapLayer) {
+ map.removeLayer(activeMapLayer);
+ activeMapLayer = nextMapLayer;
+ activeMapLayer.addTo(map);
+ }
+
+ route.setStyle({
+ color: routeColors[resolvedTheme]
+ });
+ }
+
+ function setRouteVisible(visible) {
+ routeVisible = Boolean(visible);
+ localStorage.setItem(ROUTE_VISIBLE_STORAGE_KEY, routeVisible ? "true" : "false");
+
+ if (routeVisible) {
+ if (!map.hasLayer(route)) {
+ route.addTo(map);
+ }
+ routeToggle.setAttribute("aria-label", "Hide route line");
+ routeToggleIcon.innerHTML =
+ '";
+ } else {
+ if (map.hasLayer(route)) {
+ map.removeLayer(route);
+ }
+ routeToggle.setAttribute("aria-label", "Show route line");
+ routeToggleIcon.innerHTML =
+ '";
+ }
+ }
+
+ function scheduleMapResize() {
+ window.clearTimeout(scheduleMapResize.timeoutId);
+ scheduleMapResize.timeoutId = window.setTimeout(() => {
+ map.invalidateSize();
+ }, 220);
+ }
+ scheduleMapResize.timeoutId = 0;
+
+ function setSidebarCollapsed(collapsed) {
+ const isCollapsed = Boolean(collapsed);
+ document.querySelector("aside")?.classList.toggle("sidebar-collapsed", isCollapsed);
+ localStorage.setItem("sidebarCollapsed", isCollapsed ? "true" : "false");
+ sidebarCollapseButton.setAttribute("aria-label", isCollapsed ? "Expand left panel" : "Collapse left panel");
+ sidebarCollapseIcon.innerHTML = isCollapsed
+ ? ''
+ : '';
+ scheduleMapResize();
+ }
+
+ function setTimelineCollapsed(collapsed) {
+ const isCollapsed = Boolean(collapsed);
+ document.querySelector(".timeline")?.classList.toggle("timeline-collapsed", isCollapsed);
+ localStorage.setItem("timelineCollapsed", isCollapsed ? "true" : "false");
+ timelineCollapseButton.setAttribute("aria-label", isCollapsed ? "Expand timeline" : "Collapse timeline");
+ timelineCollapseIcon.innerHTML = isCollapsed
+ ? ''
+ : '';
+ scheduleMapResize();
+ }
+
+ function getShareUrlFromLocation() {
+ const value = new URL(window.location.href).searchParams.get(SHARE_PARAM_NAME);
+ return value ? value.trim() : "";
+ }
+
+ function syncShareUrlInLocation(shareUrlValue) {
+ const nextUrl = new URL(window.location.href);
+ const trimmed = shareUrlValue.trim();
+ if (trimmed) {
+ nextUrl.searchParams.set(SHARE_PARAM_NAME, trimmed);
+ } else {
+ nextUrl.searchParams.delete(SHARE_PARAM_NAME);
+ }
+
+ if (nextUrl.toString() !== window.location.href) {
+ window.history.replaceState({}, "", nextUrl);
+ }
+ }
+
+ const themeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ const storedTheme = localStorage.getItem("theme");
+ const initialTheme = storedTheme === "dark" || (storedTheme !== "light" && themeMediaQuery.matches) ? "dark" : "light";
+ setTheme(initialTheme, false);
+ themeToggle.addEventListener("click", () => {
+ setTheme(document.body.dataset.theme === "dark" ? "light" : "dark");
+ });
+ if (storedTheme === null) {
+ themeMediaQuery.addEventListener("change", (event) => {
+ setTheme(event.matches ? "dark" : "light", false);
+ });
+ }
+ setRouteVisible(routeVisible);
+ routeToggle.addEventListener("click", () => {
+ setRouteVisible(!routeVisible);
+ });
+
+ const storedSidebarCollapsed = localStorage.getItem("sidebarCollapsed") === "true";
+ setSidebarCollapsed(storedSidebarCollapsed);
+ sidebarCollapseButton.addEventListener("click", () => {
+ setSidebarCollapsed(!document.querySelector("aside")?.classList.contains("sidebar-collapsed"));
+ });
+
+ setTimelineCollapsed(false);
+ timelineCollapseButton.addEventListener("click", () => {
+ setTimelineCollapsed(!document.querySelector(".timeline")?.classList.contains("timeline-collapsed"));
+ });
+
+ function formatDate(value) {
+ if (!value) {
+ return "no timestamp";
+ }
+ const date = new Date(value);
+ return Number.isNaN(date.getTime())
+ ? value
+ : date.toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
+ }
+
+ function normalizeExifDate(value) {
+ if (typeof value !== "string") {
+ return null;
+ }
+ const match = value.match(/^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
+ if (!match) {
+ return null;
+ }
+ const [, year, month, day, hour, minute, second] = match;
+ const date = new Date(
+ Number(year),
+ Number(month) - 1,
+ Number(day),
+ Number(hour),
+ Number(minute),
+ Number(second)
+ );
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
+ }
+
+ function parseDateOrNull(value) {
+ if (!value) {
+ return null;
+ }
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
+ }
+
+ function resolveDavBaseUrl(shareUrlValue) {
+ const url = new URL(shareUrlValue.trim());
+ const publicShare = url.pathname.match(/\\/s\\/([^/?#]+)/);
+ if (publicShare) {
+ return url.origin + "/public.php/dav/files/" + publicShare[1] + "/";
+ }
+
+ const davShare = url.pathname.match(/\\/public\\.php\\/dav\\/files\\/([^/?#]+)\\/?/);
+ if (davShare) {
+ return url.origin + "/public.php/dav/files/" + davShare[1] + "/";
+ }
+
+ throw new Error("Please enter a public Nextcloud share link.");
+ }
+
+ 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 getTimelineIntervalMs(spanMs) {
+ const minute = 60 * 1000;
+ const hour = 60 * minute;
+
+ if (spanMs <= 12 * hour) {
+ return 5 * minute;
+ }
+
+ if (spanMs <= 2 * 24 * hour) {
+ return 15 * minute;
+ }
+
+ if (spanMs <= 7 * 24 * hour) {
+ return 30 * minute;
+ }
+
+ if (spanMs <= 21 * 24 * hour) {
+ return 60 * minute;
+ }
+
+ if (spanMs <= 60 * 24 * hour) {
+ return 2 * hour;
+ }
+
+ return 4 * hour;
+ }
+
+ function floorToInterval(date, intervalMs) {
+ return new Date(Math.floor(date.getTime() / intervalMs) * intervalMs);
+ }
+
+ function ceilToInterval(date, intervalMs) {
+ return new Date(Math.ceil(date.getTime() / intervalMs) * intervalMs);
+ }
+
+ function formatTimelineLabel(date) {
+ return date.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit"
+ });
+ }
+
+ function formatTimelineSummary(photos) {
+ if (!photos.length) {
+ return "No photos imported yet.";
+ }
+
+ const count = photos.length + (photos.length === 1 ? " photo" : " photos");
+ return count + " across a compact timeline.";
+ }
+
+ function formatSelectionRange(start, end) {
+ return (
+ formatTimelineLabel(start) +
+ " - " +
+ formatTimelineLabel(end)
+ );
+ }
+
+ function buildTimeline(photos, viewport = null) {
+ 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 {
+ intervalMs: 15 * 60 * 1000,
+ bins: [],
+ start: null,
+ end: null
+ };
+ }
+
+ const dataStart = datedPhotos[0].date;
+ const dataEnd = datedPhotos[datedPhotos.length - 1].date;
+ const startDate = viewport ? new Date(viewport.start) : dataStart;
+ const endDate = viewport ? new Date(viewport.end) : dataEnd;
+ const spanMs = Math.max(1, endDate.getTime() - startDate.getTime());
+ const intervalMs = getTimelineIntervalMs(spanMs);
+ const domainStart = floorToInterval(startDate, intervalMs);
+ const domainEnd = ceilToInterval(endDate, intervalMs);
+ const bins = [];
+
+ for (let cursor = new Date(domainStart); cursor < domainEnd; cursor = new Date(cursor.getTime() + intervalMs)) {
+ const next = new Date(cursor.getTime() + intervalMs);
+ bins.push({
+ start: new Date(cursor),
+ end: next,
+ count: 0,
+ photoIds: [],
+ label: formatTimelineLabel(cursor)
+ });
+ }
+
+ const binIndexByStart = new Map();
+ bins.forEach((bin, index) => {
+ binIndexByStart.set(bin.start.getTime(), index);
+ });
+
+ for (const entry of datedPhotos) {
+ if (viewport && (entry.date.getTime() < viewport.start || entry.date.getTime() >= viewport.end)) {
+ continue;
+ }
+
+ const bucketStart = floorToInterval(entry.date, intervalMs).getTime();
+ const binIndex = binIndexByStart.get(bucketStart);
+ if (binIndex !== undefined) {
+ const bin = bins[binIndex];
+ bin.count += 1;
+ bin.photoIds.push(entry.photo.id);
+ }
+ }
+
+ return {
+ intervalMs,
+ bins,
+ start: domainStart,
+ end: domainEnd
+ };
+ }
+
+ function setImportStatus(message, tone = "info") {
+ importStatus.textContent = message;
+ importStatus.style.color = tone === "error" ? "#9d174d" : "var(--muted)";
+ }
+
+ function setProgress(processed, total, detail) {
+ state.processed = processed;
+ state.total = total;
+ progressFill.style.width = total > 0 ? Math.round((processed / total) * 100) + "%" : "0%";
+ progressText.textContent = processed + " / " + total;
+ progressDetail.textContent = detail;
+ }
+
+ function setImporting(isImporting) {
+ activitySpinner.classList.toggle("active", isImporting);
+ importButton.disabled = isImporting;
+ stopImportButton.disabled = !isImporting;
+ }
+
+ function clearObjectUrls() {
+ for (const url of state.objectUrls) {
+ URL.revokeObjectURL(url);
+ }
+ state.objectUrls = [];
+ }
+
+ function clearGallery() {
+ cancelScheduledRender();
+ closeOverlayView();
+ state.photos = [];
+ state.visiblePhotos = [];
+ state.activePhotoId = null;
+ state.timelineSelection = null;
+ state.timelineViewport = null;
+ state.timeline = {
+ intervalMs: 15 * 60 * 1000,
+ bins: [],
+ start: null,
+ end: null
+ };
+ markers.clearLayers();
+ route.setLatLngs([]);
+ photoList.replaceChildren(emptyState);
+ emptyState.textContent = "No images imported yet.";
+ photoCount.textContent = "0 photos";
+ timelineSummary.textContent = "No photos imported yet.";
+ timelineClearButton.disabled = true;
+ timelineBars.replaceChildren();
+ timelineSelection.classList.remove("visible");
+ timelineSelection.style.left = "0";
+ timelineSelection.style.width = "0";
+ timelineBrush.classList.remove("visible");
+ setProgress(0, 0, "idle");
+ }
+
+ function openOverlay(photo) {
+ const requestToken = ++overlayLoadToken;
+ overlay.classList.add("open");
+ overlay.setAttribute("aria-hidden", "false");
+ overlayTitle.textContent = photo.name;
+ overlayImage.alt = photo.name;
+ overlay.classList.add("loading");
+ overlayLoading.classList.add("active");
+ overlayLoading.classList.remove("error");
+ overlayLoadingText.textContent = "Loading full-size image...";
+ overlayImage.onload = () => {
+ if (requestToken !== overlayLoadToken) {
+ return;
+ }
+ overlay.classList.remove("loading");
+ overlayLoading.classList.remove("active");
+ overlayLoading.classList.remove("error");
+ };
+ overlayImage.onerror = () => {
+ if (requestToken !== overlayLoadToken) {
+ return;
+ }
+ overlayLoadingText.textContent = "Could not load full-size image.";
+ overlay.classList.remove("loading");
+ overlayLoading.classList.add("error");
+ overlayLoading.classList.add("active");
+ };
+ overlayImage.removeAttribute("src");
+ overlayImage.src = photo.fullUrl;
+ }
+
+ function createPhotoMarkerIcon(photo) {
+ const activeClass = photo.id === state.activePhotoId ? " active" : "";
+ return L.divIcon({
+ className: "photo-map-marker" + activeClass,
+ html:
+ '',
+ iconSize: [44, 44],
+ iconAnchor: [22, 22],
+ popupAnchor: [0, -22]
+ });
+ }
+
+ function closeOverlayView() {
+ overlayLoadToken += 1;
+ overlay.classList.remove("open");
+ overlay.setAttribute("aria-hidden", "true");
+ overlay.classList.remove("loading");
+ overlayLoading.classList.remove("active");
+ overlayLoading.classList.remove("error");
+ overlayLoadingText.textContent = "Loading full-size image...";
+ overlayImage.removeAttribute("src");
+ }
+
+ closeOverlay.addEventListener("click", closeOverlayView);
+ overlay.addEventListener("click", (event) => {
+ if (event.target === overlay) {
+ closeOverlayView();
+ }
+ });
+
+ 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]);
+
+ route.setLatLngs(routePoints);
+
+ if (routePoints.length === 1) {
+ map.setView(routePoints[0], 13);
+ } else if (routePoints.length > 1) {
+ map.fitBounds(route.getBounds(), { padding: [40, 40] });
+ }
+ }
+
+ 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;
+ state.timelineViewport = null;
+ timelineClearButton.disabled = true;
+ } else {
+ const normalizedStart = Math.min(start, end);
+ const normalizedEnd = Math.max(start, end);
+ state.timelineSelection = {
+ start: normalizedStart,
+ end: normalizedEnd
+ };
+ state.timelineViewport = {
+ start: normalizedStart,
+ end: normalizedEnd
+ };
+ timelineClearButton.disabled = false;
+ }
+
+ renderVisiblePhotos({ fitMap: true });
+ }
+
+ function clearTimelineSelection() {
+ setTimelineSelection(null, null);
+ }
+
+ function renderTimeline() {
+ const timelinePhotos = state.timelineSelection ? state.visiblePhotos : state.photos;
+ const timeline = buildTimeline(timelinePhotos, state.timelineViewport);
+ state.timeline = timeline;
+ timelineSummary.textContent = formatTimelineSummary(state.photos);
+
+ timelineBars.replaceChildren();
+
+ if (!timeline.bins.length) {
+ 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);
+ });
+
+ 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 imported yet.";
+ photoList.appendChild(emptyState);
+ photoCount.textContent = "0 photos";
+ } else {
+ emptyState.textContent = "No images imported 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], {
+ icon: createPhotoMarkerIcon(photo)
+ }).addTo(markers);
+
+ marker.bindPopup(
+ '