Files
RecipesCam/docker/backend/src/place.ts
T
3dtours 428e7fa682 web: a film sim is colour and tone only
The ten PHOTO STYLE sims now carry nothing but their stock's own grade, and
each is named for the stock it stands for: PROVIA, VELVIA, CLASSIC CHROME,
CLASSIC VIVID (Velvia spliced with Classic Chrome at the blue row), CLASSIC
NEGATIVE, ASTIA, ETERNA, ACROS, LC STREETLIFE CLASSIC, LC STREETLIFE VIVID.
Grain, clarity, saturation and light moves were dropped from their
`adjustments`, so a sim is a clean starting point and the general knobs read
their defaults while the look still lands on the pixels.

LC STREETLIFE VIVID keeps the one brightness step its stock needs, but as
SIM_EXPOSURE_BIAS in colorUtils rather than as an adjustment: it is folded in
where the Exposure slider applies, so the picture gets the lift and the
parameter stays at 0.

Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the
WATERMARK chip column, the real admin stats, and the fix that stopped presets
from doubling and a frame from refusing to come off when a photo was reopened
(/file is the finished render, /base the editable pixels).
2026-09-22 08:32:28 +07:00

62 lines
2.8 KiB
TypeScript

// Place names for a coordinate. The phone app asks the OS geocoder, and a
// browser has no twin of that, so the lookup lives here: one HTTP call behind a
// cache, and the same naming rule the app uses, so a stamp reads the same on
// both.
import { findPlace, savePlace } from './db';
// ~11m at the equator: fixes from the same spot share a cache row.
const key = (lat: number, lng: number) => `${lat.toFixed(4)},${lng.toFixed(4)}`;
// Nominatim (OpenStreetMap). Its usage policy asks for one request per second,
// a caller that identifies itself, and answers that are kept — hence the queue
// below and the SQLite row every answer leaves behind.
const ENDPOINT = 'https://nominatim.openstreetmap.org/reverse';
const AGENT = 'RecipesCam/1.2 (https://recipescam.labz.io.vn)';
const GAP_MS = 1100;
const TIMEOUT_MS = 8000;
// One lookup at a time, never closer together than GAP_MS.
let queue: Promise<unknown> = Promise.resolve();
// Android answers with the commune in `subregion` and the city in `region`;
// Nominatim says the same thing with `suburb` and `city`. Same rule as the app's
// localityName: "COMMUNE, CITY", and a name that fills both slots prints once.
export function nameOf(address: Record<string, string | undefined>): string | null {
const district =
address.suburb || address.city_district || address.district || address.quarter || address.neighbourhood || address.county;
const city = address.city || address.town || address.municipality || address.village || address.state;
const parts = district && district !== city ? [district, city] : [district || city];
const name = parts.filter(Boolean).join(', ');
return name ? name.toUpperCase() : null;
}
export async function placeName(lat: number, lng: number): Promise<string | null> {
const cached = findPlace(key(lat, lng));
if (cached !== undefined) return cached || null;
const call = queue.then(async () => {
await new Promise((r) => setTimeout(r, GAP_MS));
const url = `${ENDPOINT}?format=jsonv2&zoom=14&addressdetails=1&lat=${lat}&lon=${lng}`;
const res = await fetch(url, {
headers: { 'user-agent': AGENT, accept: 'application/json' },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) throw new Error(`geocoder HTTP ${res.status}`);
const body = (await res.json()) as { address?: Record<string, string> };
return body.address ? nameOf(body.address) : null;
});
// The queue must not inherit a rejection, or every later lookup would fail
// with it; the caller gets the failure through `call` itself.
queue = call.catch(() => undefined);
try {
const name = await call;
// Only an answer that came back is worth keeping — a timeout or an outage
// must not pin "no name here" on the coordinate for good.
savePlace(key(lat, lng), name ?? '');
return name;
} catch {
return null;
}
}