@volter/twin-googlemaps 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +96 -0
  3. package/dist/src/cli.d.ts +2 -0
  4. package/dist/src/cli.js +23 -0
  5. package/dist/src/googlemaps-auth.d.ts +31 -0
  6. package/dist/src/googlemaps-auth.js +37 -0
  7. package/dist/src/googlemaps-budget.d.ts +61 -0
  8. package/dist/src/googlemaps-budget.js +142 -0
  9. package/dist/src/googlemaps-capabilities.d.ts +3 -0
  10. package/dist/src/googlemaps-capabilities.js +648 -0
  11. package/dist/src/googlemaps-conformance.d.ts +8 -0
  12. package/dist/src/googlemaps-conformance.js +18 -0
  13. package/dist/src/googlemaps-connector.d.ts +94 -0
  14. package/dist/src/googlemaps-connector.js +243 -0
  15. package/dist/src/googlemaps-data.d.ts +87 -0
  16. package/dist/src/googlemaps-data.js +370 -0
  17. package/dist/src/googlemaps-perform-harness.d.ts +5 -0
  18. package/dist/src/googlemaps-perform-harness.js +17 -0
  19. package/dist/src/googlemaps-server.d.ts +14 -0
  20. package/dist/src/googlemaps-server.js +25 -0
  21. package/dist/src/googlemaps-twin.d.ts +2 -0
  22. package/dist/src/googlemaps-twin.js +1116 -0
  23. package/dist/src/googlemaps-types.d.ts +45 -0
  24. package/dist/src/googlemaps-types.js +1 -0
  25. package/dist/src/index.d.ts +11 -0
  26. package/dist/src/index.js +65 -0
  27. package/package.json +52 -0
  28. package/src/cli.ts +22 -0
  29. package/src/googlemaps-auth.ts +52 -0
  30. package/src/googlemaps-budget.ts +168 -0
  31. package/src/googlemaps-capabilities.ts +766 -0
  32. package/src/googlemaps-conformance.ts +24 -0
  33. package/src/googlemaps-connector.ts +250 -0
  34. package/src/googlemaps-data.ts +399 -0
  35. package/src/googlemaps-perform-harness.ts +17 -0
  36. package/src/googlemaps-server.ts +33 -0
  37. package/src/googlemaps-twin.ts +1110 -0
  38. package/src/googlemaps-types.ts +47 -0
  39. package/src/index.ts +124 -0
@@ -0,0 +1,24 @@
1
+ import { handleGoogleMapsTwinRequest } from './googlemaps-twin.ts';
2
+
3
+ export type GoogleMapsConformanceReport = { ok: boolean; checksRun: number; failures: string[] };
4
+
5
+ export async function checkGoogleMapsConformance(options: { root?: string } = {}): Promise<GoogleMapsConformanceReport> {
6
+ const failures: string[] = [];
7
+ let checksRun = 0;
8
+ const check = (name: string, ok: boolean) => { checksRun += 1; if (!ok) failures.push(name); };
9
+
10
+ const geo = await handleGoogleMapsTwinRequest({ method: 'GET', path: '/maps/api/geocode/json?address=Los%20Angeles', root: options.root });
11
+ check('geocode returns OK with a location', geo.status === 200
12
+ && (geo.body as any).status === 'OK'
13
+ && typeof (geo.body as any).results?.[0]?.geometry?.location?.lat === 'number');
14
+
15
+ const dm = await handleGoogleMapsTwinRequest({ method: 'GET', path: '/maps/api/distancematrix/json?origins=34.05,-118.24&destinations=40.71,-74.0', root: options.root });
16
+ check('distancematrix returns OK rows', dm.status === 200
17
+ && (dm.body as any).status === 'OK'
18
+ && (dm.body as any).rows?.[0]?.elements?.[0]?.status === 'OK');
19
+
20
+ const bad = await handleGoogleMapsTwinRequest({ method: 'GET', path: '/maps/api/geocode/json', root: options.root });
21
+ check('missing address rejects with INVALID_REQUEST', (bad.body as any).status === 'INVALID_REQUEST');
22
+
23
+ return { ok: failures.length === 0, checksRun, failures };
24
+ }
@@ -0,0 +1,250 @@
1
+ // Google Maps CONNECTOR — pull/push against an INJECTED `GoogleMapsExecute`, so every test and
2
+ // every capability verify runs offline against a deterministic fake.
3
+ //
4
+ // `liveGoogleMapsExecute` below is the pack's ONE construction site for a real, network-calling
5
+ // execute — the choke point the rate budget sits inside. Before it existed there was nowhere for a
6
+ // guard to live: the pack only ever received an execute, so a budget could only be something the
7
+ // caller remembered to opt into, and "the caller remembered" is exactly the assumption that cost a
8
+ // ~4.5-day Figma token lockout (2026-07-25). See `googlemaps-budget.ts` for the numbers and the
9
+ // documented limits behind them.
10
+ //
11
+ // The injected contract is UNCHANGED: everything below still takes a plain `GoogleMapsExecute`.
12
+ import { observeResources, projectResources } from '@volter/world-core';
13
+ import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
14
+ import { normalizeAddress, stableHash } from './googlemaps-data.ts';
15
+ import type { GeocodeResult } from './googlemaps-types.ts';
16
+ import { GoogleMapsBudget, GoogleMapsBudgetError, googleMapsCallWeight, type GoogleMapsBudgetOptions } from './googlemaps-budget.ts';
17
+
18
+ export type GoogleMapsExecute = (req: { method: string; path: string; body?: unknown }) => Promise<{ status: number; data: unknown }>;
19
+
20
+ const DEFAULT_OCCURRED_AT = '1970-01-01T00:00:00.000Z';
21
+
22
+ /** The real Google Maps Platform web-service host. The ONLY place this pack names it as a target. */
23
+ export const GOOGLEMAPS_API_BASE = 'https://maps.googleapis.com';
24
+
25
+ /**
26
+ * THE CHOKE POINT — the one place this pack builds a `GoogleMapsExecute` that really reaches
27
+ * maps.googleapis.com, and therefore the one place the rate budget has to be enforced.
28
+ *
29
+ * Reads the API key from `GOOGLE_MAPS_API_KEY` (never a literal, never a credentials file) and sends
30
+ * it the way Google documents for each surface: as the `key=` query parameter on the legacy
31
+ * `/maps/api/...` web services, and as the `X-Goog-Api-Key` header otherwise (Places API New /
32
+ * Address Validation) — see `googlemaps-auth.ts`, which models the same two forms.
33
+ *
34
+ * EVERY request is guarded: the budget is charged BEFORE it goes out (`checkBudget`, which THROWS
35
+ * instead of returning once the ceiling or a persisted cooldown says stop) and the response is fed
36
+ * back (`recordCall`) so a `Retry-After` / 429 / rate-limit-exhaustion signal becomes a persisted
37
+ * cooldown that makes every later call fail fast WITHOUT touching Google. There is deliberately NO
38
+ * option to disable the guard.
39
+ *
40
+ * No vendor SDK is imported: real-transport `fetch` keeps `@googlemaps/google-maps-services-js` a
41
+ * dev-only dependency, which the architecture guardrail requires.
42
+ */
43
+ export function liveGoogleMapsExecute(
44
+ opts: {
45
+ apiKey?: string;
46
+ baseUrl?: string;
47
+ fetchImpl?: typeof fetch;
48
+ /** An existing budget to share across executes. Omit and one is constructed. Cannot be null. */
49
+ budget?: GoogleMapsBudget;
50
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
51
+ budgetOptions?: GoogleMapsBudgetOptions;
52
+ } = {},
53
+ ): GoogleMapsExecute {
54
+ const apiKey = opts.apiKey ?? process.env.GOOGLE_MAPS_API_KEY ?? '';
55
+ if (!apiKey) throw new Error('GOOGLE_MAPS_API_KEY is not set — the live Google Maps client needs an API key');
56
+ const baseUrl = opts.baseUrl ?? GOOGLEMAPS_API_BASE;
57
+ const doFetch = opts.fetchImpl ?? fetch;
58
+ // There is no value a caller can pass to end up with an unguarded execute. `null`/`undefined` (or
59
+ // omitting it) build the default budget; anything that is not a REAL `GoogleMapsBudget` is refused
60
+ // loudly rather than trusted — a duck-typed stand-in with a no-op `checkBudget` would otherwise be
61
+ // the one clean way around the guard.
62
+ if (opts.budget !== undefined && opts.budget !== null && !(opts.budget instanceof GoogleMapsBudget)) {
63
+ throw new Error('liveGoogleMapsExecute: `budget` must be a GoogleMapsBudget — refusing to build a live Google Maps client around an unverified rate guard');
64
+ }
65
+ // The default ledger is keyed by a hash of THIS key — Google's quotas are per project/key, so a
66
+ // cwd-scoped ledger would hand the same key a fresh allowance per checkout/worktree/CI leg.
67
+ const budget = opts.budget instanceof GoogleMapsBudget
68
+ ? opts.budget
69
+ : new GoogleMapsBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
70
+ return async (req) => {
71
+ requireVendorPath(req.path);
72
+ const method = (req.method || 'GET').toUpperCase();
73
+ const weight = googleMapsCallWeight(method, req.path);
74
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
75
+ const reservation = budget.checkBudget(weight);
76
+ const legacy = req.path.startsWith('/maps/api/');
77
+ const url = new URL(req.path, baseUrl);
78
+ if (legacy) url.searchParams.set('key', apiKey);
79
+ const res = await doFetch(url.toString(), {
80
+ method,
81
+ headers: {
82
+ Accept: 'application/json',
83
+ ...(legacy ? {} : { 'X-Goog-Api-Key': apiKey }),
84
+ ...(req.body === undefined ? {} : { 'Content-Type': 'application/json' }),
85
+ },
86
+ ...(req.body === undefined ? {} : { body: JSON.stringify(req.body) }),
87
+ });
88
+ const headers = lowerCasedHeaders(res.headers);
89
+ const text = await res.text();
90
+ let data: unknown = null;
91
+ try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
92
+ // Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
93
+ // Retry-After beyond the cap is not something to sleep off) — the cooldown is persisted first
94
+ // either way, so the refusal survives the throw.
95
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
96
+ // call that louder refusal wins; an answer Google Maps ACCEPTED is kept, so a write that landed is
97
+ // never recorded as failed and performed again on retry.
98
+ try {
99
+ budget.recordCall(weight, headers, { status: res.status, reservation });
100
+ } catch (error) {
101
+ if (!(error instanceof GoogleMapsBudgetError) || !res.ok) throw error;
102
+ }
103
+ return { status: res.status, data };
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Refuse a `path` that is not a same-origin ABSOLUTE PATH.
109
+ *
110
+ * `new URL(path, base)` treats `https://evil.test/x` AND the protocol-relative `//evil.test/x` as
111
+ * absolute and silently retargets the host — and this factory attaches the credential
112
+ * unconditionally, so a caller-supplied absolute path would exfiltrate it to an arbitrary server.
113
+ * Figma's guarded client sidesteps this by concatenating rather than resolving; this execute takes an
114
+ * arbitrary `{ path }` from its caller, so it validates instead. (§9 finding, 2026-07-26.)
115
+ *
116
+ * `/twin/...` is refused for a different reason: those are the twin's OWN seed routes
117
+ * (`*RequestForAction` builds them), which the real vendor has never heard of. Sending one live is a
118
+ * guaranteed 404 that still burns budget and puts the live credential on the wire for nothing.
119
+ */
120
+ function requireVendorPath(path: string): void {
121
+ if (typeof path !== 'string' || !/^\/(?!\/)/.test(path)) {
122
+ throw new Error(`liveGoogleMapsExecute: path must be a same-origin absolute path beginning with a single "/" (got ${String(path)}) — refusing to let a caller-supplied absolute URL retarget the host with the credential attached`);
123
+ }
124
+ if (path === '/twin' || path.startsWith('/twin/')) {
125
+ throw new Error(`liveGoogleMapsExecute: refusing to send the twin-only path ${path} to real Google Maps — that route exists only in the local twin`);
126
+ }
127
+ }
128
+
129
+ /** Response headers as a plain lower-cased record — what the kernel's back-off reader expects. */
130
+ function lowerCasedHeaders(h: Headers): Record<string, string> {
131
+ const out: Record<string, string> = {};
132
+ h.forEach((v: string, k: string) => { out[k.toLowerCase()] = v; });
133
+ return out;
134
+ }
135
+
136
+ /** Fetch + map geocode results into SyncResource[] (no fold) — shared by pull + syncFromReal. */
137
+ async function collectGoogleMapsGeocodes(execute: GoogleMapsExecute, addresses: string[]): Promise<SyncResource[]> {
138
+ const resources: SyncResource[] = [];
139
+ for (const address of addresses) {
140
+ const res = await execute({ method: 'GET', path: `/maps/api/geocode/json?address=${encodeURIComponent(address)}` });
141
+ if (res.status < 200 || res.status >= 300) continue;
142
+ const data = res.data as { status?: string; results?: GeocodeResult[] };
143
+ if (data.status !== 'OK' || !data.results?.length) continue;
144
+ const norm = normalizeAddress(address);
145
+ const id = `geocode_${stableHash(norm)}`;
146
+ resources.push({ type: 'geocode', id, fields: { id, normalized: norm, address, result: data.results[0], source: 'connector' } });
147
+ }
148
+ return resources;
149
+ }
150
+
151
+ /** Fetch + map place details into SyncResource[] (no fold) — shared by pull + syncFromReal. */
152
+ async function collectGoogleMapsPlaces(execute: GoogleMapsExecute, placeIds: string[]): Promise<SyncResource[]> {
153
+ const resources: SyncResource[] = [];
154
+ for (const placeId of placeIds) {
155
+ const res = await execute({ method: 'GET', path: `/maps/api/place/details/json?place_id=${encodeURIComponent(placeId)}` });
156
+ if (res.status < 200 || res.status >= 300) continue;
157
+ const data = res.data as { status?: string; result?: Record<string, unknown> };
158
+ if (data.status !== 'OK' || !data.result) continue;
159
+ resources.push({ type: 'place', id: `place_${placeId}`, fields: { id: `place_${placeId}`, placeId, ...data.result, source: 'connector' } });
160
+ }
161
+ return resources;
162
+ }
163
+
164
+ /**
165
+ * Pull geocode results for a set of addresses through an injected client (the
166
+ * real Maps Geocoding API in prod, a fake in tests) and fold each OK result into
167
+ * local state as a `geocode` override. Idempotent on the normalized address.
168
+ */
169
+ export async function pullGoogleMapsGeocodes(execute: GoogleMapsExecute, addresses: string[], opts: { root?: string; occurredAt?: string } = {}): Promise<number> {
170
+ const resources = await collectGoogleMapsGeocodes(execute, addresses);
171
+ fold(resources, opts);
172
+ return resources.length;
173
+ }
174
+
175
+ /**
176
+ * Pull place details for a set of place ids, folding each into local state as a
177
+ * `place` resource snapshot. Idempotent on the place id.
178
+ */
179
+ export async function pullGoogleMapsPlaces(execute: GoogleMapsExecute, placeIds: string[], opts: { root?: string; occurredAt?: string } = {}): Promise<number> {
180
+ const resources = await collectGoogleMapsPlaces(execute, placeIds);
181
+ fold(resources, opts);
182
+ return resources.length;
183
+ }
184
+
185
+ /**
186
+ * D7 consumer-facing pull entry point: pull from the real Maps API (geocodes for the given
187
+ * addresses + details for the given place ids) and fold into the twin in ONE shadow-diffed
188
+ * one observed batch, returning the standard `{ observed, deltasAppended }` result. The inputs ride on the
189
+ * options object (this is an input-driven stateless twin). Idempotent — a re-pull of identical
190
+ * state appends nothing (deltasAppended drops to 0).
191
+ */
192
+ export async function syncGoogleMapsFromReal(
193
+ execute: GoogleMapsExecute,
194
+ opts: { addresses?: string[]; placeIds?: string[]; root?: string; occurredAt?: string } = {},
195
+ ): Promise<{ observed: number; deltasAppended: number }> {
196
+ const geocodes = await collectGoogleMapsGeocodes(execute, opts.addresses ?? []);
197
+ const places = await collectGoogleMapsPlaces(execute, opts.placeIds ?? []);
198
+ const resources = [...geocodes, ...places];
199
+ const result = fold(resources, opts);
200
+ return { observed: result.observed, deltasAppended: result.appended };
201
+ }
202
+
203
+ /** One fold onto the head: protocol 2's observe, one batch, one instant. */
204
+ function fold(resources: SyncResource[], opts: { root?: string; occurredAt?: string }) {
205
+ const at = opts.occurredAt ?? DEFAULT_OCCURRED_AT;
206
+ return observeResources('googlemaps', resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), {
207
+ ...(opts.root !== undefined ? { root: opts.root } : {}), at, batch: `obs:googlemaps:${at}`,
208
+ });
209
+ }
210
+
211
+ /** A pending `geocode.upsert` action is pushed as a twin-only seed POST. */
212
+ export function googleMapsRequestForAction(action: TwinAction): { method: string; path: string; body?: unknown } | null {
213
+ if (action.operation !== 'geocode.upsert') return null;
214
+ const fields = action.fields as Record<string, unknown>;
215
+ return { method: 'POST', path: '/twin/geocode', body: { address: fields.address, result: fields.result } };
216
+ }
217
+
218
+ export async function pushGoogleMapsAction(action: TwinAction, execute: GoogleMapsExecute): Promise<boolean> {
219
+ const req = googleMapsRequestForAction(action);
220
+ if (!req) return false;
221
+ const result = await execute(req);
222
+ return result.status >= 200 && result.status < 300;
223
+ }
224
+
225
+ // ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
226
+ /** The pack's executor over the kernel's. */
227
+ export function googleMapsExecuteOver(execute: RemoteExecute): GoogleMapsExecute {
228
+ return async ({ method, path, body }) => {
229
+ const res = await execute({ method, path, headers: { accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
230
+ let data: unknown = {};
231
+ if (res.body) { try { data = JSON.parse(res.body); } catch { data = { error_message: res.body }; } }
232
+ return { status: res.status, data };
233
+ };
234
+ }
235
+ /** The refresh adapter: re-read every address the tree already holds a geocode for. The Geocoding API has no
236
+ * inventory to list — what a world knows to ask about IS the addresses it has seen. */
237
+ export async function syncGoogleMapsFromRemote(execute: RemoteExecute, opts: { root?: string; origin?: string; occurredAt?: string } = {}): Promise<{ observed: number; deltasAppended: number }> {
238
+ const addresses: string[] = [];
239
+ for (const r of projectResources('googlemaps', opts.root)) {
240
+ if (r.type !== 'geocode') continue;
241
+ const address = (r as Record<string, unknown>).address;
242
+ if (typeof address === 'string' && address) addresses.push(address);
243
+ }
244
+ return syncGoogleMapsFromReal(googleMapsExecuteOver(execute), { addresses, ...(opts.root !== undefined ? { root: opts.root } : {}), ...(opts.occurredAt !== undefined ? { occurredAt: opts.occurredAt } : {}) });
245
+ }
246
+ /** The perform adapter. The Google Maps APIs this twin serves are READ-ONLY: they answer questions about
247
+ * the world and accept no writes, so no entry ever crosses — each settles with that reason. */
248
+ export async function performGoogleMapsAction(_execute: RemoteExecute, action: TwinAction, _ctx: PerformContext): Promise<PushOutcome> {
249
+ return { externalId: action.subject.id, data: { performed: false, reason: `${action.operation ?? action.subject.type}: the Google Maps APIs answer questions and accept no writes — nothing to perform` } };
250
+ }
@@ -0,0 +1,399 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { AddressComponent, GeocodeResult, Geometry, LatLng } from './googlemaps-types.ts';
3
+
4
+ /**
5
+ * Deterministic, NON-real geodata. A Google Maps twin cannot reproduce Google's
6
+ * real geospatial index, live traffic, or imagery; instead it returns faithful
7
+ * response SHAPES with stable, deterministic values. A given query always maps
8
+ * to the same canned result so verifies and tests are repeatable offline.
9
+ */
10
+
11
+ /** Stable FNV-ish hash → hex, used to derive deterministic ids/coords. */
12
+ export function stableHash(input: unknown): string {
13
+ const s = typeof input === 'string' ? input : JSON.stringify(input);
14
+ return createHash('sha256').update(s).digest('hex').slice(0, 16);
15
+ }
16
+
17
+ /** Normalize a free-text address for stable keying (lowercase, collapse ws). */
18
+ export function normalizeAddress(address: string): string {
19
+ return address.trim().toLowerCase().replace(/\s+/g, ' ');
20
+ }
21
+
22
+ /** Deterministic lat/lng in a plausible range derived from a seed string. */
23
+ export function deterministicLatLng(seed: string): LatLng {
24
+ const h = stableHash(seed);
25
+ const a = parseInt(h.slice(0, 8), 16);
26
+ const b = parseInt(h.slice(8, 16), 16);
27
+ // lat in [-85, 85], lng in [-180, 180], rounded to 7 decimals like Google.
28
+ const lat = Math.round((((a % 17000000) / 100000) - 85) * 1e7) / 1e7;
29
+ const lng = Math.round((((b % 36000000) / 100000) - 180) * 1e7) / 1e7;
30
+ return { lat, lng };
31
+ }
32
+
33
+ export function viewportAround(loc: LatLng, delta = 0.01): Geometry['viewport'] {
34
+ return {
35
+ northeast: { lat: Math.round((loc.lat + delta) * 1e7) / 1e7, lng: Math.round((loc.lng + delta) * 1e7) / 1e7 },
36
+ southwest: { lat: Math.round((loc.lat - delta) * 1e7) / 1e7, lng: Math.round((loc.lng - delta) * 1e7) / 1e7 },
37
+ };
38
+ }
39
+
40
+ /** A small named place dataset (canned, not real index lookups). */
41
+ export type CannedPlace = {
42
+ place_id: string;
43
+ formatted_address: string;
44
+ location: LatLng;
45
+ types: string[];
46
+ components: AddressComponent[];
47
+ name: string;
48
+ };
49
+
50
+ // Place ids use Google's `ChIJ`-prefixed opaque token shape. These are canned.
51
+ const PLACES: CannedPlace[] = [
52
+ {
53
+ place_id: 'ChIJE9on3F3HwoAR9AhGJW_fL-I',
54
+ name: 'Los Angeles',
55
+ formatted_address: 'Los Angeles, CA, USA',
56
+ location: { lat: 34.0522342, lng: -118.2436849 },
57
+ types: ['locality', 'political'],
58
+ components: [
59
+ { long_name: 'Los Angeles', short_name: 'Los Angeles', types: ['locality', 'political'] },
60
+ { long_name: 'Los Angeles County', short_name: 'Los Angeles County', types: ['administrative_area_level_2', 'political'] },
61
+ { long_name: 'California', short_name: 'CA', types: ['administrative_area_level_1', 'political'] },
62
+ { long_name: 'United States', short_name: 'US', types: ['country', 'political'] },
63
+ ],
64
+ },
65
+ {
66
+ place_id: 'ChIJOwg_06VPwokRYv534QaPC8g',
67
+ name: 'New York',
68
+ formatted_address: 'New York, NY, USA',
69
+ location: { lat: 40.7127753, lng: -74.0059728 },
70
+ types: ['locality', 'political'],
71
+ components: [
72
+ { long_name: 'New York', short_name: 'New York', types: ['locality', 'political'] },
73
+ { long_name: 'New York', short_name: 'NY', types: ['administrative_area_level_1', 'political'] },
74
+ { long_name: 'United States', short_name: 'US', types: ['country', 'political'] },
75
+ ],
76
+ },
77
+ {
78
+ place_id: 'ChIJj61dQgK6j4AR4GeTYWZsKWw',
79
+ name: 'Mountain View',
80
+ formatted_address: '1600 Amphitheatre Pkwy, Mountain View, CA 94043, USA',
81
+ location: { lat: 37.4220656, lng: -122.0840897 },
82
+ types: ['street_address'],
83
+ components: [
84
+ { long_name: '1600', short_name: '1600', types: ['street_number'] },
85
+ { long_name: 'Amphitheatre Parkway', short_name: 'Amphitheatre Pkwy', types: ['route'] },
86
+ { long_name: 'Mountain View', short_name: 'Mountain View', types: ['locality', 'political'] },
87
+ { long_name: 'California', short_name: 'CA', types: ['administrative_area_level_1', 'political'] },
88
+ { long_name: 'United States', short_name: 'US', types: ['country', 'political'] },
89
+ { long_name: '94043', short_name: '94043', types: ['postal_code'] },
90
+ ],
91
+ },
92
+ {
93
+ place_id: 'ChIJdd4hrwug2EcRmSrV3Vo6llI',
94
+ name: 'London',
95
+ formatted_address: 'London, UK',
96
+ location: { lat: 51.5072178, lng: -0.1275862 },
97
+ types: ['locality', 'political'],
98
+ components: [
99
+ { long_name: 'London', short_name: 'London', types: ['locality', 'political'] },
100
+ { long_name: 'United Kingdom', short_name: 'GB', types: ['country', 'political'] },
101
+ ],
102
+ },
103
+ {
104
+ place_id: 'ChIJ51cu8IcbXWARiRtXIothAS4',
105
+ name: 'Tokyo',
106
+ formatted_address: 'Tokyo, Japan',
107
+ location: { lat: 35.6761919, lng: 139.6503106 },
108
+ types: ['locality', 'political'],
109
+ components: [
110
+ { long_name: 'Tokyo', short_name: 'Tokyo', types: ['locality', 'political'] },
111
+ { long_name: 'Japan', short_name: 'JP', types: ['country', 'political'] },
112
+ ],
113
+ },
114
+ ];
115
+
116
+ const ZERO_RESULTS_MARKERS = ['nowhere', 'zzzznoresult', 'zero_results', 'this-place-does-not-exist'];
117
+
118
+ /** Returns true for a query the twin deliberately maps to ZERO_RESULTS. */
119
+ export function isZeroResultsQuery(q: string): boolean {
120
+ const n = normalizeAddress(q);
121
+ return ZERO_RESULTS_MARKERS.some((m) => n.includes(m));
122
+ }
123
+
124
+ /** Find a canned place by fuzzy name/address substring match. */
125
+ export function findPlaceByText(query: string): CannedPlace | null {
126
+ const n = normalizeAddress(query);
127
+ for (const p of PLACES) {
128
+ if (n.includes(normalizeAddress(p.name))) return p;
129
+ if (normalizeAddress(p.formatted_address).includes(n) && n.length >= 3) return p;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ export function findPlaceById(placeId: string): CannedPlace | null {
135
+ return PLACES.find((p) => p.place_id === placeId) ?? null;
136
+ }
137
+
138
+ /** The country short_name (ccTLD-ish) for a canned place, for component filtering. */
139
+ export function countryOf(p: CannedPlace): string | undefined {
140
+ return p.components.find((c) => c.types.includes('country'))?.short_name?.toLowerCase();
141
+ }
142
+
143
+ /** The locality long_name for a canned place (used in plus-code compound codes). */
144
+ export function localityOf(p: CannedPlace): string {
145
+ return p.components.find((c) => c.types.includes('locality'))?.long_name
146
+ ?? p.components.find((c) => c.types.includes('administrative_area_level_1'))?.long_name
147
+ ?? p.name;
148
+ }
149
+
150
+ /** Nearest canned place to a lat/lng (for reverse geocoding). */
151
+ export function findPlaceByLatLng(loc: LatLng): CannedPlace {
152
+ let best = PLACES[0]!;
153
+ let bestD = Number.POSITIVE_INFINITY;
154
+ for (const p of PLACES) {
155
+ const d = (p.location.lat - loc.lat) ** 2 + (p.location.lng - loc.lng) ** 2;
156
+ if (d < bestD) { bestD = d; best = p; }
157
+ }
158
+ return best;
159
+ }
160
+
161
+ export function allPlaces(): CannedPlace[] {
162
+ return PLACES;
163
+ }
164
+
165
+ /**
166
+ * Localized display names for canned places, keyed by language code. The Geocoding
167
+ * API returns localized `formatted_address` / component names per the `language`
168
+ * parameter; the twin models this faithfully for a small set of languages and
169
+ * falls back to the English name otherwise.
170
+ */
171
+ const LOCALIZED_NAMES: Record<string, Record<string, string>> = {
172
+ 'Tokyo, Japan': { ja: '日本、東京都', fr: 'Tokyo, Japon', es: 'Tokio, Japón', de: 'Tokio, Japan' },
173
+ 'London, UK': { ja: 'イギリス ロンドン', fr: 'Londres, Royaume-Uni', es: 'Londres, Reino Unido', de: 'London, Vereinigtes Königreich' },
174
+ 'Los Angeles, CA, USA': { ja: 'アメリカ合衆国 カリフォルニア州 ロサンゼルス', fr: 'Los Angeles, Californie, États-Unis', es: 'Los Ángeles, California, EE. UU.', de: 'Los Angeles, Kalifornien, USA' },
175
+ 'New York, NY, USA': { ja: 'アメリカ合衆国 ニューヨーク州 ニューヨーク', fr: 'New York, État de New York, États-Unis', es: 'Nueva York, EE. UU.', de: 'New York, USA' },
176
+ };
177
+
178
+ /** Localize a formatted_address for a `language` code (no-op for English/unknown). */
179
+ export function localizeFormattedAddress(formatted_address: string, language: string | null | undefined): string {
180
+ if (!language) return formatted_address;
181
+ const lang = language.toLowerCase().split('-')[0]!;
182
+ if (lang === 'en') return formatted_address;
183
+ return LOCALIZED_NAMES[formatted_address]?.[lang] ?? formatted_address;
184
+ }
185
+
186
+ /** Build a faithful GeocodeResult from a canned place. */
187
+ export function geocodeResultFromPlace(p: CannedPlace, locationType: Geometry['location_type'] = 'ROOFTOP'): GeocodeResult {
188
+ return {
189
+ address_components: p.components,
190
+ formatted_address: p.formatted_address,
191
+ geometry: {
192
+ location: p.location,
193
+ location_type: locationType,
194
+ viewport: viewportAround(p.location),
195
+ },
196
+ place_id: p.place_id,
197
+ types: p.types,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Synthesize a deterministic GeocodeResult for an arbitrary (non-canned but
203
+ * valid) address so the twin never falsely 404s a well-formed query. Coordinates
204
+ * are derived from the address hash; shape is faithful.
205
+ */
206
+ export function synthGeocodeResult(address: string): GeocodeResult {
207
+ const loc = deterministicLatLng(normalizeAddress(address));
208
+ const placeId = `ChIJ${stableHash(address)}twin`;
209
+ return {
210
+ address_components: [
211
+ { long_name: address, short_name: address, types: ['route'] },
212
+ { long_name: 'United States', short_name: 'US', types: ['country', 'political'] },
213
+ ],
214
+ formatted_address: address,
215
+ geometry: { location: loc, location_type: 'GEOMETRIC_CENTER', viewport: viewportAround(loc) },
216
+ place_id: placeId,
217
+ types: ['route'],
218
+ };
219
+ }
220
+
221
+ /** Great-circle distance (haversine) in meters between two points. */
222
+ export function haversineMeters(a: LatLng, b: LatLng): number {
223
+ const R = 6371000;
224
+ const toRad = (d: number) => (d * Math.PI) / 180;
225
+ const dLat = toRad(b.lat - a.lat);
226
+ const dLng = toRad(b.lng - a.lng);
227
+ const lat1 = toRad(a.lat);
228
+ const lat2 = toRad(b.lat);
229
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
230
+ return Math.round(2 * R * Math.asin(Math.sqrt(h)));
231
+ }
232
+
233
+ /** Human "5.0 mi" / "1.2 km" text for a distance in meters (imperial default). */
234
+ export function distanceText(meters: number, metric = false): string {
235
+ if (metric) {
236
+ const km = meters / 1000;
237
+ return km >= 1 ? `${km.toFixed(1)} km` : `${meters} m`;
238
+ }
239
+ const mi = meters / 1609.344;
240
+ return mi >= 0.1 ? `${mi.toFixed(1)} mi` : `${Math.round(meters * 3.28084)} ft`;
241
+ }
242
+
243
+ /** Human "15 mins" / "1 hour 5 mins" text for a duration in seconds. */
244
+ export function durationText(seconds: number): string {
245
+ const mins = Math.round(seconds / 60);
246
+ if (mins < 60) return `${mins} min${mins === 1 ? '' : 's'}`;
247
+ const h = Math.floor(mins / 60);
248
+ const m = mins % 60;
249
+ return m === 0 ? `${h} hour${h === 1 ? '' : 's'}` : `${h} hour${h === 1 ? '' : 's'} ${m} min${m === 1 ? '' : 's'}`;
250
+ }
251
+
252
+ /** Deterministic driving duration (seconds) for a distance — ~40 km/h average. */
253
+ export function deterministicDuration(meters: number): number {
254
+ return Math.max(60, Math.round((meters / 1000 / 40) * 3600));
255
+ }
256
+
257
+ /**
258
+ * A tiny polyline encoder (Google's Encoded Polyline Algorithm Format), used to
259
+ * produce a faithful `overview_polyline.points` for Directions responses.
260
+ */
261
+ export function encodePolyline(points: LatLng[]): string {
262
+ let last = [0, 0];
263
+ let out = '';
264
+ const enc = (cur: number, prev: number): string => {
265
+ let v = Math.round(cur * 1e5) - Math.round(prev * 1e5);
266
+ v = v < 0 ? ~(v << 1) : v << 1;
267
+ let s = '';
268
+ while (v >= 0x20) {
269
+ s += String.fromCharCode((0x20 | (v & 0x1f)) + 63);
270
+ v >>= 5;
271
+ }
272
+ s += String.fromCharCode(v + 63);
273
+ return s;
274
+ };
275
+ for (const p of points) {
276
+ out += enc(p.lat, last[0]!);
277
+ out += enc(p.lng, last[1]!);
278
+ last = [p.lat, p.lng];
279
+ }
280
+ return out;
281
+ }
282
+
283
+ /** Deterministic elevation (meters) for a lat/lng. */
284
+ export function deterministicElevation(loc: LatLng): number {
285
+ const h = parseInt(stableHash(`${loc.lat},${loc.lng}`).slice(0, 8), 16);
286
+ return Math.round(((h % 400000) / 100 - 100) * 1e2) / 1e2; // -100m .. 3900m
287
+ }
288
+
289
+ /**
290
+ * Deterministic IANA time zone id + offsets for a lat/lng. When a UTC `timestamp`
291
+ * (seconds) is supplied, northern-hemisphere DST is computed faithfully: zones
292
+ * that observe DST (US/UK) return a non-zero `dstOffset` for timestamps that fall
293
+ * between mid-March and early November; UTC/Japan never observe DST.
294
+ */
295
+ export function deterministicTimeZone(loc: LatLng, timestamp?: number): { timeZoneId: string; timeZoneName: string; rawOffset: number; dstOffset: number } {
296
+ const zones = [
297
+ { timeZoneId: 'America/Los_Angeles', stdName: 'Pacific Standard Time', dstName: 'Pacific Daylight Time', rawOffset: -28800, observesDst: true },
298
+ { timeZoneId: 'America/New_York', stdName: 'Eastern Standard Time', dstName: 'Eastern Daylight Time', rawOffset: -18000, observesDst: true },
299
+ { timeZoneId: 'Europe/London', stdName: 'Greenwich Mean Time', dstName: 'British Summer Time', rawOffset: 0, observesDst: true },
300
+ { timeZoneId: 'Asia/Tokyo', stdName: 'Japan Standard Time', dstName: 'Japan Standard Time', rawOffset: 32400, observesDst: false },
301
+ ];
302
+ const idx = parseInt(stableHash(`${loc.lat},${loc.lng}`).slice(0, 4), 16) % zones.length;
303
+ const z = zones[idx]!;
304
+ let dstOffset = 0;
305
+ if (z.observesDst && timestamp !== undefined && Number.isFinite(timestamp)) {
306
+ const month = new Date(timestamp * 1000).getUTCMonth(); // 0..11
307
+ // Northern-hemisphere DST window: roughly mid-March (2) .. October (9).
308
+ if (month >= 2 && month <= 9) dstOffset = 3600;
309
+ }
310
+ return {
311
+ timeZoneId: z.timeZoneId,
312
+ timeZoneName: dstOffset > 0 ? z.dstName : z.stdName,
313
+ rawOffset: z.rawOffset,
314
+ dstOffset,
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Open Location Code (Plus Code) encoder. Produces a faithful global_code (the
320
+ * 11th char is '+' after position 8) and a compound_code (global suffix + a
321
+ * locality reference). This is the real OLC algorithm over a deterministic grid.
322
+ */
323
+ const OLC_ALPHABET = '23456789CFGHJMPQRVWX';
324
+ export function encodePlusCode(loc: LatLng, codeLength = 10): string {
325
+ const lat = Math.min(Math.max(loc.lat, -90), 90) + 90;
326
+ const lng = ((loc.lng + 180) % 360 + 360) % 360;
327
+ let code = '';
328
+ // First 10 chars: 5 pairs, each a base-20 digit for lat then lng.
329
+ let latPrecision = 20;
330
+ let lngPrecision = 20;
331
+ for (let i = 0; i < 5; i++) {
332
+ const latDigit = Math.floor(lat / latPrecision) % 20;
333
+ const lngDigit = Math.floor(lng / lngPrecision) % 20;
334
+ code += OLC_ALPHABET[latDigit];
335
+ code += OLC_ALPHABET[lngDigit];
336
+ if (i === 3) code += '+';
337
+ latPrecision /= 20;
338
+ lngPrecision /= 20;
339
+ }
340
+ return code.slice(0, codeLength + 1);
341
+ }
342
+
343
+ export function plusCodeFor(loc: LatLng, localityName: string): { global_code: string; compound_code: string } {
344
+ const global_code = encodePlusCode(loc);
345
+ // compound_code drops the 4-char area prefix and appends a locality reference.
346
+ const compound_code = `${global_code.slice(4)} ${localityName}`;
347
+ return { global_code, compound_code };
348
+ }
349
+
350
+ /** Deterministic next_page_token <-> offset codec for Places pagination. */
351
+ export function encodePageToken(query: string, offset: number): string {
352
+ return Buffer.from(`${normalizeAddress(query)}::${offset}`).toString('base64url');
353
+ }
354
+ export function decodePageToken(token: string): { query: string; offset: number } | null {
355
+ try {
356
+ const [query, offsetStr] = Buffer.from(token, 'base64url').toString('utf8').split('::');
357
+ const offset = Number(offsetStr);
358
+ if (query === undefined || !Number.isFinite(offset)) return null;
359
+ return { query, offset };
360
+ } catch {
361
+ return null;
362
+ }
363
+ }
364
+
365
+ /** A faithful opening_hours block (weekday_text + periods) for a place. */
366
+ export function openingHoursFor(p: CannedPlace): Record<string, unknown> {
367
+ const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
368
+ const periods = days.map((_d, i) => ({
369
+ open: { day: i, time: '0900' },
370
+ close: { day: i, time: '1700' },
371
+ }));
372
+ return {
373
+ open_now: true,
374
+ periods,
375
+ weekday_text: days.map((d) => `${d}: 9:00 AM – 5:00 PM`),
376
+ };
377
+ }
378
+
379
+ /** A faithful, deterministic set of reviews for a place. */
380
+ export function reviewsFor(p: CannedPlace): Record<string, unknown>[] {
381
+ return [
382
+ {
383
+ author_name: 'A. Traveler',
384
+ rating: 5,
385
+ relative_time_description: 'a month ago',
386
+ text: `Great experience at ${p.name}.`,
387
+ time: 1700000000,
388
+ language: 'en',
389
+ },
390
+ {
391
+ author_name: 'B. Local',
392
+ rating: 4,
393
+ relative_time_description: '2 months ago',
394
+ text: `Solid spot in ${p.name}.`,
395
+ time: 1697000000,
396
+ language: 'en',
397
+ },
398
+ ];
399
+ }