release: v0.13.0 (#535)
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>
This commit is contained in:
107
lib/mapPrecision.ts
Normal file
107
lib/mapPrecision.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user