Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions components/DaySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export function DayList({ days, dayStats, selectedDayId, onSelectDay, onStepDay,
<div className="space-y-2">
<button
onClick={() => onSelectDay(null)}
aria-pressed={selectedDayId === null}
className={cn(
"w-full rounded-xl border px-4 py-3 text-left transition-all duration-150 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-teal-700/20",
selectedDayId === null ? "border-teal-700/35 bg-teal-50 shadow-sm" : "border-stone-200 bg-white/75 hover:border-stone-300 hover:bg-white",
Expand All @@ -170,6 +171,7 @@ export function DayList({ days, dayStats, selectedDayId, onSelectDay, onStepDay,
<div key={day.id} className="group relative">
<button
onClick={() => onSelectDay(day.id)}
aria-pressed={selectedDayId === day.id}
className={cn(
"w-full rounded-xl border px-4 py-3 text-left transition-all duration-150 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-teal-700/20",
canPlay && "pr-14",
Expand Down
18 changes: 11 additions & 7 deletions components/TripLayers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import mapboxgl from "mapbox-gl";
import { useEffect, useMemo, useRef } from "react";
import { friendlyPersonName } from "@/lib/display-name";
import { noteFeatureCollection, photoFeatureCollection, placeFeatureCollection, routeFeatureCollection } from "@/lib/geo";
import { PHOTO_CLUSTER_MAX_ZOOM, PHOTO_CLUSTER_RADIUS, photoMarkerPresentation } from "@/lib/map-presentation";
import { formatDateTime } from "@/lib/utils";
import type { Note, Photo, Place, RouteSegment } from "@/types/trip";

Expand Down Expand Up @@ -104,8 +105,8 @@ export function TripLayers({ map, routes, photos, notes, places, visibility, cur
type: "geojson",
data: photoData,
cluster: true,
clusterMaxZoom: 17,
clusterRadius: 64,
clusterMaxZoom: PHOTO_CLUSTER_MAX_ZOOM,
clusterRadius: PHOTO_CLUSTER_RADIUS,
});
// This transparent layer gives queryRenderedFeatures a viewport-aware
// view of both clusters and individual photos. The visible markers are
Expand Down Expand Up @@ -248,10 +249,10 @@ export function TripLayers({ map, routes, photos, notes, places, visibility, cur
return closest;
}

function createMarkerElement(photo: Photo, count: number) {
function createMarkerElement(photo: Photo, count: number, zoom: number) {
const element = document.createElement("button");
element.type = "button";
element.className = `lofoten-photo-marker${count > 1 ? " lofoten-photo-marker-cluster" : ""}`;
element.className = photoMarkerPresentation(zoom, count).className;
const mediaNoun = photo.media_type === "video" ? "video" : "photo";
element.setAttribute("aria-label", count > 1 ? `View cluster of ${count} media items` : `View ${photo.caption || `trip ${mediaNoun}`}`);
const imageUrl = photo.media_type === "video" ? photo.thumbnail_url : (photo.thumbnail_url || photo.image_url);
Expand Down Expand Up @@ -286,6 +287,7 @@ export function TripLayers({ map, routes, photos, notes, places, visibility, cur
const features = activeMap.queryRenderedFeatures({ layers: ["photos-hit"] });
const seen = new Set<string>();
const canvas = activeMap.getCanvas();
const zoom = activeMap.getZoom();
for (const feature of features) {
if (!feature.geometry || feature.geometry.type !== "Point") continue;
const coordinates = feature.geometry.coordinates as [number, number];
Expand All @@ -306,7 +308,7 @@ export function TripLayers({ map, routes, photos, notes, places, visibility, cur

if (!markers.has(key)) {
const count = isCluster ? Number(feature.properties?.point_count) : 1;
const element = createMarkerElement(photo, count);
const element = createMarkerElement(photo, count, zoom);
element.addEventListener("click", (event) => {
event.stopPropagation();
if (!isCluster) {
Expand Down Expand Up @@ -339,8 +341,10 @@ export function TripLayers({ map, routes, photos, notes, places, visibility, cur
// genuine controls, so restore the button semantics after creation.
element.setAttribute("role", "button");
markers.set(key, marker);
} else if (key !== draggingKey) {
markers.get(key)?.setLngLat(coordinates);
} else {
const marker = markers.get(key);
marker?.getElement().classList.toggle("lofoten-photo-marker-overview", photoMarkerPresentation(zoom, isCluster ? Number(feature.properties?.point_count) : 1).isOverview);
if (key !== draggingKey) marker?.setLngLat(coordinates);
}
}

Expand Down
32 changes: 28 additions & 4 deletions components/map-overrides.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
.mapboxgl-ctrl button { color: #1c1917 !important; }

.lofoten-photo-marker {
width: 48px;
height: 48px;
width: 44px;
height: 44px;
cursor: pointer;
overflow: visible;
border: 3px solid #fffdf6;
Expand Down Expand Up @@ -75,8 +75,8 @@
}

.lofoten-photo-marker-cluster {
width: 56px;
height: 56px;
width: 52px;
height: 52px;
}

.lofoten-photo-marker-cluster::before {
Expand All @@ -96,6 +96,30 @@
background: #dbe7df;
}

/* At the whole-trip zoom, retain a 44px control while visually quieting the
thumbnail within it. Clusters use the same compact footprint, so a rich
archive reads as a handful of places rather than a wall of cards. */
.lofoten-photo-marker-overview {
width: 44px;
height: 44px;
padding: 2px;
box-shadow:
0 2px 4px rgba(28, 25, 23, 0.16),
0 7px 15px rgba(46, 61, 54, 0.15);
}

.lofoten-photo-marker-overview img {
border-radius: 7px;
}

.lofoten-photo-marker-cluster.lofoten-photo-marker-overview::before {
transform: translate(3px, -3px) rotate(5deg);
}

.lofoten-photo-marker-cluster.lofoten-photo-marker-overview::after {
transform: translate(-3px, 3px) rotate(-4deg);
}

/* Pulsing ring around the photo currently open in the editor. The outer div is
positioned by Mapbox (inline transform), so the animation lives on a child. */
.lofoten-photo-highlight {
Expand Down
8 changes: 6 additions & 2 deletions e2e/trip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@ test.describe("desktop", { tag: "@desktop" }, () => {

test("selecting a day filters and round-trips through the URL", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: /Day 2: Kjerkfjorden hike/ }).click();
const dayTwo = page.getByRole("button", { name: /Day 2: Kjerkfjorden hike/ }).first();
const allDays = page.getByRole("button", { name: /All days/ }).first();
await dayTwo.click();
await expect(page).toHaveURL(/day=/);
await expect(dayTwo).toHaveAttribute("aria-pressed", "true");
await expect(allDays).toHaveAttribute("aria-pressed", "false");

// A reload of the shared URL must restore the same selection.
await page.reload();
await expect(page.getByRole("button", { name: /Day 2: Kjerkfjorden hike/ })).toBeVisible();
await expect(page.getByRole("button", { name: /Day 2: Kjerkfjorden hike/ }).first()).toHaveAttribute("aria-pressed", "true");
});

test("day stepper walks from All days into day 1", async ({ page }) => {
Expand Down
44 changes: 44 additions & 0 deletions lib/map-presentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
PHOTO_CLUSTER_MAX_ZOOM,
PHOTO_CLUSTER_RADIUS,
PHOTO_MARKER_OVERVIEW_ZOOM,
photoMarkerPresentation,
} from "./map-presentation";

describe("photo marker presentation", () => {
it("uses the calmer overview treatment below the detail threshold", () => {
const presentation = photoMarkerPresentation(PHOTO_MARKER_OVERVIEW_ZOOM - 0.01, 1);
expect(presentation).toEqual({
isCluster: false,
isOverview: true,
className: "lofoten-photo-marker lofoten-photo-marker-overview",
});
});

it("switches to the detail treatment at the threshold", () => {
expect(photoMarkerPresentation(PHOTO_MARKER_OVERVIEW_ZOOM, 1).isOverview).toBe(false);
});

it("distinguishes clusters without making their class depend on count size", () => {
expect(photoMarkerPresentation(10, 2).className).toBe(
"lofoten-photo-marker lofoten-photo-marker-cluster lofoten-photo-marker-overview",
);
expect(photoMarkerPresentation(10, 10_000).className).toBe(
"lofoten-photo-marker lofoten-photo-marker-cluster lofoten-photo-marker-overview",
);
});

it("falls back to the detail treatment for invalid zoom and a single marker for invalid count", () => {
expect(photoMarkerPresentation(Number.NaN, Number.NaN)).toEqual({
isCluster: false,
isOverview: false,
className: "lofoten-photo-marker",
});
});

it("keeps the shared Mapbox cluster settings deliberate", () => {
expect(PHOTO_CLUSTER_RADIUS).toBe(80);
expect(PHOTO_CLUSTER_MAX_ZOOM).toBe(17);
});
});
25 changes: 25 additions & 0 deletions lib/map-presentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const PHOTO_CLUSTER_RADIUS = 80;
export const PHOTO_CLUSTER_MAX_ZOOM = 17;
export const PHOTO_MARKER_OVERVIEW_ZOOM = 12;

export type PhotoMarkerPresentation = {
isCluster: boolean;
isOverview: boolean;
className: string;
};

// Keep the zoom-dependent marker decision outside the Mapbox effect so the
// overview/detail boundary remains explicit and independently testable.
export function photoMarkerPresentation(zoom: number, count: number): PhotoMarkerPresentation {
const isCluster = Number.isFinite(count) && count > 1;
const isOverview = Number.isFinite(zoom) && zoom < PHOTO_MARKER_OVERVIEW_ZOOM;
return {
isCluster,
isOverview,
className: [
"lofoten-photo-marker",
isCluster ? "lofoten-photo-marker-cluster" : null,
isOverview ? "lofoten-photo-marker-overview" : null,
].filter(Boolean).join(" "),
};
}
Loading