Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list and the upgrade notes. Ten additions — the SEO package (site URL, robots.txt, sitemap.xml, JSON-LD), slideshow mode, opt-in original downloads, copy-link, previous/next album navigation, alt text from the Immich description, per-album location precision, the update notice, a Help tab, and a subpage cover grid separate from its photo grid. Two fixes: CJK album slugs, and the lightbox control bar. Photo of the Day (#476) was built on `dev` and reverted as not planned; it never appears in a release. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
108 lines
4.1 KiB
TypeScript
108 lines
4.1 KiB
TypeScript
/**
|
|
* How precisely an album's photographs may be placed on the map (#469).
|
|
*
|
|
* The map is the only public GPS surface: the EXIF route does not expose
|
|
* coordinates at all, and password-protected albums are dropped before
|
|
* aggregation. What was left was public albums, whose marker sits at the mean
|
|
* of the actual coordinates — and for an album shot in one place (a garden, a
|
|
* studio, a client's home, a fragile site) that mean *is* that place. The
|
|
* marker is named after the city and stands on the property. Nobody chose
|
|
* that; it follows from the camera writing GPS.
|
|
*
|
|
* Coordinates are quantised rather than snapped to city centres, so the
|
|
* project takes on no geodata dependency.
|
|
*/
|
|
|
|
export const LOCATION_PRECISIONS = ['exact', 'city', 'country', 'hidden'] as const;
|
|
|
|
export type LocationPrecision = (typeof LOCATION_PRECISIONS)[number];
|
|
|
|
export function isLocationPrecision(value: string): value is LocationPrecision {
|
|
return (LOCATION_PRECISIONS as readonly string[]).includes(value);
|
|
}
|
|
|
|
/**
|
|
* Grid each level snaps to. `decimals` trims the floating-point tail that
|
|
* dividing and re-multiplying leaves behind — without it 0.05° rounding emits
|
|
* values like 52.900000000000006, which would be a needlessly precise-looking
|
|
* number for a deliberately imprecise position.
|
|
*/
|
|
const GRID: Record<Exclude<LocationPrecision, 'hidden'>, { step: number; decimals: number }> = {
|
|
exact: { step: 0, decimals: 6 },
|
|
city: { step: 0.05, decimals: 2 }, // ≈ 5 km
|
|
country: { step: 1, decimals: 0 }, // ≈ 100 km
|
|
};
|
|
|
|
/** Strictness order — later wins when albums disagree. */
|
|
const RANK: Record<LocationPrecision, number> = {
|
|
exact: 0,
|
|
city: 1,
|
|
country: 2,
|
|
hidden: 3,
|
|
};
|
|
|
|
/**
|
|
* The strictest level among the albums contributing to one marker.
|
|
*
|
|
* One marker merges several albums, so the cautious setting has to govern the
|
|
* whole marker. Splitting the marker instead would itself reveal that one of
|
|
* its albums is set more cautiously — which is the thing being hidden.
|
|
*
|
|
* An empty list is `hidden`: nothing contributed, so there is nothing to show.
|
|
*/
|
|
export function strictestPrecision(levels: readonly LocationPrecision[]): LocationPrecision {
|
|
if (levels.length === 0) return 'hidden';
|
|
return levels.reduce((worst, level) => (RANK[level] > RANK[worst] ? level : worst), 'exact');
|
|
}
|
|
|
|
/**
|
|
* Snap a coordinate to the level's grid.
|
|
*
|
|
* A fixed global grid, not a random offset per request: jitter could be
|
|
* averaged away by asking repeatedly, which would hand back the true position
|
|
* to anyone patient enough to ask twice.
|
|
*/
|
|
export function quantiseCoordinate(value: number, level: LocationPrecision): number {
|
|
if (level === 'hidden') return 0;
|
|
const { step, decimals } = GRID[level];
|
|
if (step === 0) return value;
|
|
return Number((Math.round(value / step) * step).toFixed(decimals));
|
|
}
|
|
|
|
/**
|
|
* Apply a precision level to a position. Returns null for `hidden`, which the
|
|
* caller must treat as "this marker does not exist" rather than "this marker
|
|
* is at 0,0".
|
|
*/
|
|
export function applyPrecision(
|
|
position: { lat: number; lng: number },
|
|
level: LocationPrecision,
|
|
): { lat: number; lng: number } | null {
|
|
if (level === 'hidden') return null;
|
|
return {
|
|
lat: quantiseCoordinate(position.lat, level),
|
|
lng: quantiseCoordinate(position.lng, level),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The place name a marker may carry at this precision.
|
|
*
|
|
* Quantising the coordinates is only half of withholding a position: the
|
|
* marker is also *named*, and a city name is far more precise than the 1° grid
|
|
* `country` snaps to. At `country` the name is reduced to the country, which
|
|
* is what the coordinates already say. `city` keeps it — a 0.05° cell is
|
|
* roughly what naming a city discloses anyway.
|
|
*
|
|
* `hidden` returns neither, so a caller that forgets to drop the marker
|
|
* entirely cannot leak the place through its label.
|
|
*/
|
|
export function placeLabel(
|
|
level: LocationPrecision,
|
|
place: { city: string; country: string },
|
|
): { city: string; country: string } {
|
|
if (level === 'hidden') return { city: '', country: '' };
|
|
if (level === 'country') return { city: '', country: place.country };
|
|
return { city: place.city, country: place.country };
|
|
}
|