@acms-subir/core 0.3.6 → 0.3.8
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.
- package/CHANGELOG.md +27 -0
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +146 -2
- package/src/components/EmailSettings.tsx +6 -0
- package/src/components/EventPage.astro +25 -5
- package/src/components/booking/DurationEditor.astro +156 -0
- package/src/components/calendar/BookingsHead.astro +4 -3
- package/src/components/calendar/RoomHolds.tsx +13 -8
- package/src/components/editor/EventPanel.tsx +9 -383
- package/src/components/editor/LivePane.tsx +16 -3
- package/src/components/editor/PostEditor.tsx +59 -46
- package/src/components/editor/PreviewOverlay.tsx +7 -1
- package/src/components/editor/event/EventSheet.tsx +136 -0
- package/src/components/editor/event/LocationField.tsx +266 -0
- package/src/components/editor/event/OrganizeCard.tsx +87 -0
- package/src/components/editor/event/RegistrationCard.tsx +114 -0
- package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
- package/src/components/editor/event/Section.tsx +27 -0
- package/src/components/editor/event/WhenCard.tsx +217 -0
- package/src/components/editor/event/dates.ts +28 -0
- package/src/components/editor/event/registration.ts +39 -0
- package/src/components/editor/extensions/form.tsx +60 -0
- package/src/components/editor/extensions/slash.tsx +5 -0
- package/src/components/editor/frameNav.ts +20 -0
- package/src/components/forms/Canvas.tsx +149 -0
- package/src/components/forms/FormBuildPage.tsx +155 -0
- package/src/components/forms/FormBuilder.tsx +283 -0
- package/src/components/forms/FormHeader.astro +31 -0
- package/src/components/forms/InsertMenu.tsx +77 -0
- package/src/components/forms/Inspector.tsx +232 -0
- package/src/components/forms/Outline.tsx +93 -0
- package/src/components/forms/ResponsesTable.tsx +123 -0
- package/src/components/forms/TypeIcon.tsx +24 -0
- package/src/components/forms/dnd.ts +3 -0
- package/src/components/forms/meta.ts +84 -0
- package/src/components/forms/useFormHistory.ts +41 -0
- package/src/layouts/Admin.astro +3 -2
- package/src/lib/backup/content.ts +10 -5
- package/src/lib/booking/forms.ts +67 -0
- package/src/lib/booking/service.ts +6 -2
- package/src/lib/calendar/agent-schema.ts +7 -1
- package/src/lib/calendar/agent.ts +28 -3
- package/src/lib/calendar/api.ts +2 -0
- package/src/lib/calendar/ics-write.ts +2 -0
- package/src/lib/calendar/ics.ts +4 -0
- package/src/lib/calendar/import.ts +3 -3
- package/src/lib/calendar/schedule.ts +6 -2
- package/src/lib/calendar/seo.ts +4 -1
- package/src/lib/content-service.ts +30 -4
- package/src/lib/drafts.ts +9 -1
- package/src/lib/dynamicBlocks.ts +14 -3
- package/src/lib/editor/documents.ts +12 -3
- package/src/lib/editor/schemas.ts +4 -3
- package/src/lib/email/readiness.ts +14 -0
- package/src/lib/email/service.ts +8 -3
- package/src/lib/email/templates.ts +8 -2
- package/src/lib/email/types.ts +1 -0
- package/src/lib/forms/events.ts +40 -0
- package/src/lib/forms/export.ts +62 -0
- package/src/lib/forms/fields.ts +50 -0
- package/src/lib/forms/http.ts +29 -0
- package/src/lib/forms/render.ts +11 -0
- package/src/lib/forms/schema.ts +56 -0
- package/src/lib/forms/service.ts +38 -0
- package/src/lib/forms/store.ts +78 -0
- package/src/lib/forms/validate.ts +71 -0
- package/src/lib/maps/config.ts +64 -0
- package/src/lib/maps/migration.ts +12 -0
- package/src/lib/maps/parse.ts +180 -0
- package/src/lib/maps/places.ts +109 -0
- package/src/lib/maps/providers/geoapify.ts +36 -0
- package/src/lib/maps/providers/google.ts +54 -0
- package/src/lib/maps/providers/http.ts +96 -0
- package/src/lib/maps/providers/index.ts +37 -0
- package/src/lib/maps/providers/locationiq.ts +35 -0
- package/src/lib/maps/providers/mapbox.ts +38 -0
- package/src/lib/maps/providers/osm.ts +59 -0
- package/src/lib/maps/providers/pelias.ts +51 -0
- package/src/lib/maps/service.ts +281 -0
- package/src/lib/maps/types.ts +35 -0
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/schemas.ts +2 -2
- package/src/lib/repo/migrations.ts +8 -0
- package/src/lib/repo/sql.ts +99 -10
- package/src/lib/repo/types.ts +21 -1
- package/src/lib/schema.ts +38 -0
- package/src/lib/umami.ts +8 -1
- package/src/pages/admin/calendar/bookings.astro +23 -12
- package/src/pages/admin/calendar/events/[id].astro +5 -2
- package/src/pages/admin/calendar/events/index.astro +3 -0
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/admin/calendar/resources/link.astro +95 -49
- package/src/pages/admin/calendar/settings.astro +140 -3
- package/src/pages/admin/forms/[id]/share.astro +121 -0
- package/src/pages/admin/forms/[id]/submissions.astro +143 -0
- package/src/pages/admin/forms/[id].astro +27 -0
- package/src/pages/admin/forms/index.astro +198 -0
- package/src/pages/admin/integrations.astro +57 -0
- package/src/pages/admin/settings.astro +1 -1
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -0
- package/src/pages/api/calendar/events/[id].ts +1 -1
- package/src/pages/api/calendar/events/index.ts +1 -1
- package/src/pages/api/email/settings.ts +4 -2
- package/src/pages/api/mcp.ts +1 -1
- package/src/pages/book/manage/[token].astro +4 -4
- package/src/pages/events/[slug].astro +24 -10
- package/src/pages/forms/[id].astro +27 -0
- package/src/styles/admin.css +2 -2
- package/src/styles/editor.css +275 -34
- package/src/styles/form-block.css +38 -0
- package/src/styles/forms-admin.css +184 -0
- package/src/styles/forms.css +281 -0
- package/src/styles/site.css +43 -0
- package/src/virtual.d.ts +2 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Pasted map links → a location, with no network. Handles Google Maps (long
|
|
2
|
+
// links, ?q=/query=/ll=, embed iframes), Apple Maps, OpenStreetMap, plain
|
|
3
|
+
// "lat, lng" text, and online-meeting links (Zoom, Meet, Teams…), which come
|
|
4
|
+
// back as kind 'meeting' with no pin. Short links (maps.app.goo.gl) need a
|
|
5
|
+
// server-side redirect walk first — see resolveMapLink in ./service.
|
|
6
|
+
|
|
7
|
+
import type { ParsedLocation } from './types';
|
|
8
|
+
|
|
9
|
+
const MEETING = /(^|\.)(zoom\.us|zoom\.com|meet\.google\.com|teams\.microsoft\.com|teams\.live\.com|webex\.com|whereby\.com|meet\.jit\.si|jit\.si)$/i;
|
|
10
|
+
const GOOGLE = /(^|\.)google\.[a-z.]{2,6}$/i;
|
|
11
|
+
const APPLE = /^maps\.apple(\.com)?$/i;
|
|
12
|
+
const OSM = /(^|\.)openstreetmap\.org$/i;
|
|
13
|
+
|
|
14
|
+
const num = (s: string | null | undefined): number | null => {
|
|
15
|
+
if (s === null || s === undefined || s.trim() === '') return null;
|
|
16
|
+
const n = Number(s);
|
|
17
|
+
return Number.isFinite(n) ? n : null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const validLat = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= -90 && n <= 90;
|
|
21
|
+
export const validLng = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= -180 && n <= 180;
|
|
22
|
+
|
|
23
|
+
// "40.7484, -73.9857" → pin (both in range), else null
|
|
24
|
+
export function parseLatLng(s: string | null | undefined): { lat: number; lng: number } | null {
|
|
25
|
+
const m = s?.trim().match(/^(-?\d{1,3}(?:\.\d+)?)\s*,\s*(-?\d{1,3}(?:\.\d+)?)$/);
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
const lat = Number(m[1]);
|
|
28
|
+
const lng = Number(m[2]);
|
|
29
|
+
return validLat(lat) && validLng(lng) ? { lat, lng } : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const decode = (s: string) => {
|
|
33
|
+
try { return decodeURIComponent(s.replace(/\+/g, ' ')); } catch { return s.replace(/\+/g, ' '); }
|
|
34
|
+
};
|
|
35
|
+
const clean = (s: string) => s.replace(/\s+/g, ' ').trim();
|
|
36
|
+
|
|
37
|
+
// "Empire State Building, 20 W 34th St., New York, NY 10001" → name + address.
|
|
38
|
+
// A first part that starts with a number is a street, not a name: the whole
|
|
39
|
+
// string is the address then.
|
|
40
|
+
export function splitPlace(text: string): { name: string; address: string } {
|
|
41
|
+
const t = clean(text);
|
|
42
|
+
if (!t) return { name: '', address: '' };
|
|
43
|
+
const parts = t.split(',').map((p) => p.trim()).filter(Boolean);
|
|
44
|
+
if (parts.length < 2) return /^\d/.test(t) ? { name: '', address: t } : { name: t, address: '' };
|
|
45
|
+
if (/^\d/.test(parts[0])) return { name: '', address: parts.join(', ') };
|
|
46
|
+
return { name: parts[0], address: parts.slice(1).join(', ') };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function result(kind: ParsedLocation['kind'], fields: Partial<ParsedLocation>): ParsedLocation {
|
|
50
|
+
const lat = validLat(fields.lat) && validLng(fields.lng) ? fields.lat : null;
|
|
51
|
+
const lng = lat === null ? null : (fields.lng as number);
|
|
52
|
+
return { kind, name: fields.name ?? '', address: fields.address ?? '', lat, lng, url: fields.url ?? '' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// a short Google Maps link that only a redirect can expand
|
|
56
|
+
export function isShortMapLink(input: string): boolean {
|
|
57
|
+
let u: URL;
|
|
58
|
+
try { u = new URL(input.trim()); } catch { return false; }
|
|
59
|
+
const host = u.hostname.toLowerCase();
|
|
60
|
+
if (host === 'maps.app.goo.gl') return true;
|
|
61
|
+
return host === 'goo.gl' && u.pathname.startsWith('/maps');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// the src of a pasted <iframe …>, else the input itself
|
|
65
|
+
function urlFrom(input: string): { url: string; embed: boolean } {
|
|
66
|
+
const s = input.trim();
|
|
67
|
+
if (/^<iframe[\s>]/i.test(s) || /<iframe[\s>]/i.test(s)) {
|
|
68
|
+
const m = s.match(/\ssrc\s*=\s*(["'])(.*?)\1/i) ?? s.match(/\ssrc\s*=\s*([^\s>]+)/i);
|
|
69
|
+
const src = m ? (m[2] ?? m[1]) : '';
|
|
70
|
+
return { url: src.replace(/&/g, '&'), embed: true };
|
|
71
|
+
}
|
|
72
|
+
return { url: s, embed: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseGoogle(u: URL, embed: boolean, original: string): ParsedLocation | null {
|
|
76
|
+
const path = u.pathname;
|
|
77
|
+
const href = u.href;
|
|
78
|
+
const q = u.searchParams;
|
|
79
|
+
// embed iframes: /maps/embed?pb=!1m18…!2d<lng>!3d<lat>…!2s<name>…
|
|
80
|
+
const pb = q.get('pb');
|
|
81
|
+
if (pb !== null || path.startsWith('/maps/embed')) {
|
|
82
|
+
if (pb) {
|
|
83
|
+
const lng = num(pb.match(/!2d(-?\d+(?:\.\d+)?)/)?.[1]);
|
|
84
|
+
const lat = num(pb.match(/!3d(-?\d+(?:\.\d+)?)/)?.[1]);
|
|
85
|
+
// the place name is the first !2s value that isn't a 2-letter region code
|
|
86
|
+
let name = '';
|
|
87
|
+
for (const m of pb.matchAll(/!2s([^!]*)/g)) {
|
|
88
|
+
const v = clean(decode(m[1]));
|
|
89
|
+
if (v.length > 2 && !/^0x[0-9a-f]+:0x[0-9a-f]+$/i.test(v)) { name = v; break; }
|
|
90
|
+
}
|
|
91
|
+
const parts = splitPlace(name);
|
|
92
|
+
if (lat !== null || name) return result('map', { ...parts, lat, lng, url: '' });
|
|
93
|
+
}
|
|
94
|
+
// Maps Embed API: /maps/embed/v1/place?q=…
|
|
95
|
+
const query = q.get('q') ?? '';
|
|
96
|
+
const pin = parseLatLng(query) ?? parseLatLng(q.get('center'));
|
|
97
|
+
if (pin) return result('map', { ...pin, url: '' });
|
|
98
|
+
if (query) return result('map', { ...splitPlace(query), url: '' });
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
// the dropped pin of a place: …/data=!3m1!4b1!4m6!3m5!…!8m2!3d<lat>!4d<lng>
|
|
102
|
+
const dLat = num(href.match(/!3d(-?\d+(?:\.\d+)?)/)?.[1]);
|
|
103
|
+
const dLng = num(href.match(/!4d(-?\d+(?:\.\d+)?)/)?.[1]);
|
|
104
|
+
// the viewport centre: …/@<lat>,<lng>,17z
|
|
105
|
+
const at = path.match(/@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/);
|
|
106
|
+
let lat = dLat ?? num(at?.[1]);
|
|
107
|
+
let lng = dLat !== null ? dLng : num(at?.[2]);
|
|
108
|
+
let text = '';
|
|
109
|
+
const seg = path.match(/\/maps\/(?:place|search)\/([^/@]+)/);
|
|
110
|
+
if (seg) text = decode(seg[1]);
|
|
111
|
+
const query = q.get('q') ?? q.get('query') ?? q.get('daddr') ?? q.get('destination') ?? '';
|
|
112
|
+
if (!text && query) {
|
|
113
|
+
const pin = parseLatLng(query);
|
|
114
|
+
if (pin) { lat = lat ?? pin.lat; lng = lng ?? pin.lng; } else text = query;
|
|
115
|
+
}
|
|
116
|
+
const ll = parseLatLng(q.get('ll')) ?? parseLatLng(q.get('sll'));
|
|
117
|
+
if (lat === null && ll) { lat = ll.lat; lng = ll.lng; }
|
|
118
|
+
// a pin typed as the place ("/maps/place/40.7,-73.9") is a pin, not a name
|
|
119
|
+
const textPin = parseLatLng(text);
|
|
120
|
+
if (textPin) { text = ''; if (lat === null) { lat = textPin.lat; lng = textPin.lng; } }
|
|
121
|
+
if (!validLat(lat) || !validLng(lng)) { lat = null; lng = null; }
|
|
122
|
+
if (lat === null && !text) return null;
|
|
123
|
+
return result('map', { ...splitPlace(text), lat, lng, url: embed ? '' : original });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseApple(u: URL, original: string): ParsedLocation | null {
|
|
127
|
+
const q = u.searchParams;
|
|
128
|
+
const pin = parseLatLng(q.get('coordinate')) ?? parseLatLng(q.get('ll')) ?? parseLatLng(q.get('sll')) ?? parseLatLng(q.get('q'));
|
|
129
|
+
const query = q.get('name') ?? q.get('q') ?? '';
|
|
130
|
+
const name = parseLatLng(query) ? '' : clean(query);
|
|
131
|
+
const address = clean(q.get('address') ?? q.get('daddr') ?? '');
|
|
132
|
+
if (!pin && !name && !address) return null;
|
|
133
|
+
const parts = !address && name ? splitPlace(name) : { name, address };
|
|
134
|
+
return result('map', { ...parts, ...(pin ?? {}), url: original });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseOsm(u: URL, original: string): ParsedLocation | null {
|
|
138
|
+
const q = u.searchParams;
|
|
139
|
+
let lat = num(q.get('mlat'));
|
|
140
|
+
let lng = num(q.get('mlon'));
|
|
141
|
+
if (lat === null || lng === null) {
|
|
142
|
+
const m = u.hash.match(/map=\d+(?:\.\d+)?\/(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)/);
|
|
143
|
+
lat = num(m?.[1]);
|
|
144
|
+
lng = num(m?.[2]);
|
|
145
|
+
}
|
|
146
|
+
const query = clean(q.get('query') ?? '');
|
|
147
|
+
if (!validLat(lat) || !validLng(lng)) { lat = null; lng = null; }
|
|
148
|
+
if (lat === null && !query) return null;
|
|
149
|
+
return result('map', { ...splitPlace(query), lat, lng, url: original });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A pasted map link, map embed (<iframe> HTML) or "lat, lng" → location.
|
|
154
|
+
* null = not something we understand (or a short link — resolve it first).
|
|
155
|
+
*/
|
|
156
|
+
export function parseMapLink(input: string): ParsedLocation | null {
|
|
157
|
+
if (!input || input.length > 10_000) return null;
|
|
158
|
+
const pin = parseLatLng(input);
|
|
159
|
+
if (pin) return result('map', pin);
|
|
160
|
+
const { url, embed } = urlFrom(input);
|
|
161
|
+
let u: URL;
|
|
162
|
+
try { u = new URL(url); } catch { return null; }
|
|
163
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
|
|
164
|
+
const host = u.hostname.toLowerCase();
|
|
165
|
+
if (MEETING.test(host)) return result('meeting', { url: u.href });
|
|
166
|
+
if (isShortMapLink(u.href)) return null;
|
|
167
|
+
const original = embed ? '' : url.trim();
|
|
168
|
+
if (GOOGLE.test(host) && (u.pathname.startsWith('/maps') || host.startsWith('maps.'))) return parseGoogle(u, embed, original);
|
|
169
|
+
if (APPLE.test(host) || (host === 'apple.com' && u.pathname.startsWith('/maps'))) return parseApple(u, original);
|
|
170
|
+
if (OSM.test(host)) return parseOsm(u, original);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// a link that opens a map app (not a meeting, not some random page)
|
|
175
|
+
export function isMapLink(url: string): boolean {
|
|
176
|
+
if (!url) return false;
|
|
177
|
+
if (isShortMapLink(url)) return true;
|
|
178
|
+
const p = parseMapLink(url);
|
|
179
|
+
return p !== null && p.kind === 'map';
|
|
180
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Saved places: save (with map images), keep upcoming events in step, count
|
|
2
|
+
// usage, and start new events at the default place.
|
|
3
|
+
import type { ContentRepository } from '../repo/types';
|
|
4
|
+
import { EventLocation, type CalEvent, type Place, type Settings } from '../schema';
|
|
5
|
+
import { ApiError } from '../api';
|
|
6
|
+
import { featuredOccurrence } from '../calendar/events';
|
|
7
|
+
import { mergeEventDraft } from '../drafts';
|
|
8
|
+
import { validLat, validLng } from './parse';
|
|
9
|
+
import { ensureMapImages, type MapsContext } from './service';
|
|
10
|
+
|
|
11
|
+
export interface PlaceInput {
|
|
12
|
+
id?: string;
|
|
13
|
+
name: string;
|
|
14
|
+
address: string;
|
|
15
|
+
url: string;
|
|
16
|
+
lat: number | null;
|
|
17
|
+
lng: number | null;
|
|
18
|
+
notes: string;
|
|
19
|
+
isDefault: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// An event counts as upcoming while any occurrence hasn't ended (a repeating
|
|
23
|
+
// event with no end date always does). Past events keep their copy of a
|
|
24
|
+
// place: changing HQ's address must not rewrite last year's history.
|
|
25
|
+
export function isUpcoming(e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number): boolean {
|
|
26
|
+
const o = featuredOccurrence(e, settings, now);
|
|
27
|
+
return Boolean(o && o.end > now);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** upcoming events per place id (the staged version of each event counts) */
|
|
31
|
+
export function placeUsage(events: CalEvent[], settings: Pick<Settings, 'timezone'>, now = Date.now()): Map<string, number> {
|
|
32
|
+
const counts = new Map<string, number>();
|
|
33
|
+
for (const e of events) {
|
|
34
|
+
const m = mergeEventDraft(e);
|
|
35
|
+
const id = m.location.placeId;
|
|
36
|
+
if (id && isUpcoming(m, settings, now)) counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
37
|
+
}
|
|
38
|
+
return counts;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const placeFields = (p: Place) => ({ name: p.name, address: p.address, url: p.url, lat: p.lat, lng: p.lng, mapImage: p.mapImage, mapImageDark: p.mapImageDark });
|
|
42
|
+
const differs = (loc: EventLocation, p: Place) => Object.entries(placeFields(p)).some(([k, v]) => loc[k as keyof EventLocation] !== v);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Copy a saved place into every upcoming event that uses it. Returns how many
|
|
46
|
+
* events changed.
|
|
47
|
+
*
|
|
48
|
+
* Drafts: a published event's staged edits are a separate copy. Both copies
|
|
49
|
+
* that point at this place are refreshed — the live page shows the new
|
|
50
|
+
* address right away, and pressing Update later won't bring the old one
|
|
51
|
+
* back. A staged copy that switched to another place (or to typed text) is
|
|
52
|
+
* left alone. Live writes go through setEventLocation, which keeps the
|
|
53
|
+
* staged draft intact (a normal live update would discard it), and guests
|
|
54
|
+
* with an RSVP are told when the name or address actually changed.
|
|
55
|
+
*/
|
|
56
|
+
export async function syncPlaceToEvents(repo: ContentRepository, place: Place, now = Date.now()): Promise<number> {
|
|
57
|
+
const settings = await repo.getSettings();
|
|
58
|
+
let changed = 0;
|
|
59
|
+
for (const e of await repo.listEvents()) {
|
|
60
|
+
const liveHit = e.location.placeId === place.id && isUpcoming(e, settings, now) && differs(e.location, place);
|
|
61
|
+
const staged = e.draft && 'location' in e.draft ? mergeEventDraft(e) : null;
|
|
62
|
+
const draftHit = Boolean(staged && staged.location.placeId === place.id && isUpcoming(staged, settings, now) && differs(staged.location, place));
|
|
63
|
+
if (!liveHit && !draftHit) continue;
|
|
64
|
+
await repo.setEventLocation(e.id, {
|
|
65
|
+
...(liveHit ? { live: EventLocation.parse({ ...e.location, ...placeFields(place) }) } : {}),
|
|
66
|
+
...(draftHit && staged ? { draft: EventLocation.parse({ ...staged.location, ...placeFields(place) }) } : {}),
|
|
67
|
+
notify: liveHit && (e.location.name !== place.name || e.location.address !== place.address),
|
|
68
|
+
});
|
|
69
|
+
changed++;
|
|
70
|
+
}
|
|
71
|
+
return changed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** create or update a place (map images included), then refresh its upcoming events */
|
|
75
|
+
export async function savePlace(repo: ContentRepository, input: PlaceInput, ctx: MapsContext = {}): Promise<{ place: Place; updatedEvents: number }> {
|
|
76
|
+
const existing = input.id ? await repo.getPlace(input.id) : null;
|
|
77
|
+
if (input.id && !existing) throw new ApiError(404, 'That place no longer exists.');
|
|
78
|
+
const name = input.name.trim().slice(0, 300);
|
|
79
|
+
if (!name) throw new ApiError(400, 'Give the place a name.');
|
|
80
|
+
const url = input.url.trim().slice(0, 1000);
|
|
81
|
+
if (url && !/^https?:\/\//i.test(url)) throw new ApiError(400, 'The map link must start with https://.');
|
|
82
|
+
const pinOk = validLat(input.lat) && validLng(input.lng);
|
|
83
|
+
if ((input.lat !== null || input.lng !== null) && !pinOk) throw new ApiError(400, 'That pin isn’t a valid latitude and longitude.');
|
|
84
|
+
const lat = pinOk ? input.lat : null;
|
|
85
|
+
const lng = pinOk ? input.lng : null;
|
|
86
|
+
const images = await ensureMapImages(repo, {
|
|
87
|
+
name, address: input.address.trim(), lat, lng, showMap: true, placeId: '',
|
|
88
|
+
mapImage: existing?.mapImage ?? '', mapImageDark: existing?.mapImageDark ?? '',
|
|
89
|
+
}, ctx);
|
|
90
|
+
const data = {
|
|
91
|
+
name, address: input.address.trim().slice(0, 500), url, lat, lng, notes: input.notes.slice(0, 2000), isDefault: input.isDefault,
|
|
92
|
+
mapImage: images.mapImage, mapImageDark: images.mapImageDark,
|
|
93
|
+
};
|
|
94
|
+
let place: Place;
|
|
95
|
+
if (existing) {
|
|
96
|
+
await repo.updatePlace(existing.id, data);
|
|
97
|
+
place = (await repo.getPlace(existing.id))!;
|
|
98
|
+
} else {
|
|
99
|
+
place = await repo.createPlace(data);
|
|
100
|
+
}
|
|
101
|
+
const updatedEvents = await syncPlaceToEvents(repo, place);
|
|
102
|
+
return { place, updatedEvents };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** the default place as an event location (null = none set) */
|
|
106
|
+
export async function defaultPlaceLocation(repo: ContentRepository): Promise<EventLocation | null> {
|
|
107
|
+
const p = (await repo.listPlaces()).find((x) => x.isDefault);
|
|
108
|
+
return p ? EventLocation.parse({ ...placeFields(p), placeId: p.id, showMap: true }) : null;
|
|
109
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Geoapify (OpenStreetMap data): autocomplete, reverse geocoding and static
|
|
2
|
+
// maps with one API key. https://apidocs.geoapify.com
|
|
3
|
+
import type { GeoResult } from '../types';
|
|
4
|
+
import { arr, geo, getImage, getJson, obj, qs, s, usable, type MapsProvider, type ProviderEnv } from './http';
|
|
5
|
+
|
|
6
|
+
export function geoapifyResults(json: unknown): GeoResult[] {
|
|
7
|
+
return arr(obj(json).results).map((raw) => {
|
|
8
|
+
const r = obj(raw);
|
|
9
|
+
const name = s(r.name);
|
|
10
|
+
return geo(name, name ? s(r.address_line2) || s(r.formatted) : s(r.formatted), r.lat, r.lon);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function geoapify(env: ProviderEnv): MapsProvider {
|
|
15
|
+
const base = 'https://api.geoapify.com/v1/geocode';
|
|
16
|
+
return {
|
|
17
|
+
id: 'geoapify',
|
|
18
|
+
label: 'Geoapify',
|
|
19
|
+
attribution: 'Powered by Geoapify · © OpenMapTiles · © OpenStreetMap contributors',
|
|
20
|
+
async search(q, opts = {}) {
|
|
21
|
+
const json = await getJson(`${base}/autocomplete?${qs({ text: q, format: 'json', limit: opts.limit ?? 5, bias: opts.near ? `proximity:${opts.near.lng},${opts.near.lat}` : undefined, apiKey: env.key })}`, env);
|
|
22
|
+
return usable(geoapifyResults(json), opts.limit ?? 5);
|
|
23
|
+
},
|
|
24
|
+
async reverse(lat, lng) {
|
|
25
|
+
const json = await getJson(`${base}/reverse?${qs({ lat, lon: lng, format: 'json', limit: 1, apiKey: env.key })}`, env);
|
|
26
|
+
return geoapifyResults(json)[0] ?? null;
|
|
27
|
+
},
|
|
28
|
+
async staticMap(lat, lng, { width, height, dark }) {
|
|
29
|
+
const params = qs({
|
|
30
|
+
style: dark ? 'dark-matter' : 'osm-bright', width: Math.round(width / 2), height: Math.round(height / 2), scaleFactor: 2,
|
|
31
|
+
center: `lonlat:${lng},${lat}`, zoom: 15, marker: `lonlat:${lng},${lat};color:#e03131;size:large`, apiKey: env.key,
|
|
32
|
+
});
|
|
33
|
+
return getImage(`https://maps.geoapify.com/v1/staticmap?${params}`, env);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Google Maps Platform: Places API (New) Text Search for suggestions (it
|
|
2
|
+
// returns the pin in the same call, unlike Autocomplete + Details), the
|
|
3
|
+
// Geocoding API for reverse lookups, and the Maps Static API.
|
|
4
|
+
import type { GeoResult } from '../types';
|
|
5
|
+
import { arr, geo, getImage, getJson, obj, qs, s, usable, type MapsProvider, type ProviderEnv } from './http';
|
|
6
|
+
|
|
7
|
+
export function googlePlacesResults(json: unknown): GeoResult[] {
|
|
8
|
+
return arr(obj(json).places).map((raw) => {
|
|
9
|
+
const p = obj(raw);
|
|
10
|
+
const loc = obj(p.location);
|
|
11
|
+
return geo(s(obj(p.displayName).text), s(p.formattedAddress), loc.latitude, loc.longitude, s(p.googleMapsUri));
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function googleGeocodeResults(json: unknown): GeoResult[] {
|
|
16
|
+
return arr(obj(json).results).map((raw) => {
|
|
17
|
+
const r = obj(raw);
|
|
18
|
+
const loc = obj(obj(r.geometry).location);
|
|
19
|
+
return geo('', s(r.formatted_address), loc.lat, loc.lng);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// a muted night palette for the dark image (the Static API has no dark style)
|
|
24
|
+
const DARK = [
|
|
25
|
+
'element:geometry|color:0x242f3e', 'element:labels.text.stroke|color:0x242f3e', 'element:labels.text.fill|color:0x8a8f98',
|
|
26
|
+
'feature:road|element:geometry|color:0x38414e', 'feature:road|element:geometry.stroke|color:0x212a37',
|
|
27
|
+
'feature:water|element:geometry|color:0x17263c', 'feature:poi|element:geometry|color:0x2b3544',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function google(env: ProviderEnv): MapsProvider {
|
|
31
|
+
return {
|
|
32
|
+
id: 'google',
|
|
33
|
+
label: 'Google Maps',
|
|
34
|
+
attribution: 'Map data © Google',
|
|
35
|
+
async search(q, opts = {}) {
|
|
36
|
+
const body: Record<string, unknown> = { textQuery: q, pageSize: opts.limit ?? 5 };
|
|
37
|
+
if (opts.near) body.locationBias = { circle: { center: { latitude: opts.near.lat, longitude: opts.near.lng }, radius: 50000 } };
|
|
38
|
+
const json = await getJson('https://places.googleapis.com/v1/places:searchText', env, {
|
|
39
|
+
method: 'POST', body: JSON.stringify(body),
|
|
40
|
+
headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': env.key, 'X-Goog-FieldMask': 'places.displayName,places.formattedAddress,places.location,places.googleMapsUri' },
|
|
41
|
+
});
|
|
42
|
+
return usable(googlePlacesResults(json), opts.limit ?? 5);
|
|
43
|
+
},
|
|
44
|
+
async reverse(lat, lng) {
|
|
45
|
+
const json = await getJson(`https://maps.googleapis.com/maps/api/geocode/json?${qs({ latlng: `${lat},${lng}`, key: env.key })}`, env);
|
|
46
|
+
return googleGeocodeResults(json)[0] ?? null;
|
|
47
|
+
},
|
|
48
|
+
async staticMap(lat, lng, { width, height, dark }) {
|
|
49
|
+
const params = qs({ center: `${lat},${lng}`, zoom: 15, size: `${Math.round(width / 2)}x${Math.round(height / 2)}`, scale: 2, markers: `color:red|${lat},${lng}`, key: env.key });
|
|
50
|
+
const style = dark ? DARK.map((d) => `&style=${encodeURIComponent(d)}`).join('') : '';
|
|
51
|
+
return getImage(`https://maps.googleapis.com/maps/api/staticmap?${params}${style}`, env);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Shared plumbing for the maps adapters: one timeout, one identifying
|
|
2
|
+
// User-Agent, and response mapping that never throws on odd payloads.
|
|
3
|
+
import type { GeoResult } from '../types';
|
|
4
|
+
|
|
5
|
+
export interface ProviderEnv {
|
|
6
|
+
key: string;
|
|
7
|
+
// self-hosted base URL (photon / nominatim / pelias), no trailing slash
|
|
8
|
+
url: string;
|
|
9
|
+
// "acms (+https://example.org)"
|
|
10
|
+
userAgent: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SearchOptions {
|
|
14
|
+
near?: { lat: number; lng: number };
|
|
15
|
+
limit?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StaticMapOptions {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
dark: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StaticImage {
|
|
25
|
+
body: ArrayBuffer;
|
|
26
|
+
contentType: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MapsProvider {
|
|
30
|
+
id: string;
|
|
31
|
+
label: string;
|
|
32
|
+
// credit shown under static map images (and in the admin)
|
|
33
|
+
attribution: string;
|
|
34
|
+
search(q: string, opts?: SearchOptions): Promise<GeoResult[]>;
|
|
35
|
+
reverse(lat: number, lng: number): Promise<GeoResult | null>;
|
|
36
|
+
staticMap?(lat: number, lng: number, opts: StaticMapOptions): Promise<StaticImage | null>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const TIMEOUT_MS = 6000;
|
|
40
|
+
// static map images are small PNG/JPEGs; anything bigger is not a map
|
|
41
|
+
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
export async function getJson(url: string, env: ProviderEnv, init: { method?: string; headers?: Record<string, string>; body?: string } = {}): Promise<unknown> {
|
|
44
|
+
const res = await fetch(url, {
|
|
45
|
+
method: init.method ?? 'GET',
|
|
46
|
+
body: init.body,
|
|
47
|
+
headers: { 'User-Agent': env.userAgent, Accept: 'application/json', ...(init.headers ?? {}) },
|
|
48
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) throw new Error(`maps provider answered ${res.status}`);
|
|
51
|
+
return res.json();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function getImage(url: string, env: ProviderEnv): Promise<StaticImage | null> {
|
|
55
|
+
const res = await fetch(url, { headers: { 'User-Agent': env.userAgent, Accept: 'image/*' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
56
|
+
if (!res.ok) throw new Error(`static map answered ${res.status}`);
|
|
57
|
+
const contentType = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
58
|
+
if (!/^image\/(png|jpeg|webp)$/.test(contentType)) throw new Error(`static map returned ${contentType || 'no content type'}`);
|
|
59
|
+
const body = await res.arrayBuffer();
|
|
60
|
+
if (!body.byteLength || body.byteLength > MAX_IMAGE_BYTES) throw new Error('static map image is empty or too large');
|
|
61
|
+
return { body, contentType };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const str = (v: unknown) => (typeof v === 'string' ? v.trim() : typeof v === 'number' ? String(v) : '');
|
|
65
|
+
export const s = str;
|
|
66
|
+
|
|
67
|
+
export function coord(v: unknown): number | null {
|
|
68
|
+
const n = typeof v === 'string' ? Number(v) : v;
|
|
69
|
+
return typeof n === 'number' && Number.isFinite(n) ? n : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// a result row; drops the name when it only repeats the start of the address
|
|
73
|
+
export function geo(name: string, address: string, lat: unknown, lng: unknown, url = ''): GeoResult {
|
|
74
|
+
const la = coord(lat);
|
|
75
|
+
const ln = coord(lng);
|
|
76
|
+
const ok = la !== null && ln !== null && la >= -90 && la <= 90 && ln >= -180 && ln <= 180;
|
|
77
|
+
let n = name.trim();
|
|
78
|
+
let a = address.trim();
|
|
79
|
+
if (n && a.startsWith(`${n},`)) a = a.slice(n.length + 1).trim();
|
|
80
|
+
if (n && n === a) a = '';
|
|
81
|
+
return { name: n, address: a, lat: ok ? la : null, lng: ok ? ln : null, url };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const joinParts = (...parts: unknown[]) => parts.map(str).filter(Boolean).join(', ');
|
|
85
|
+
|
|
86
|
+
export const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
|
|
87
|
+
export const obj = (v: unknown): Record<string, unknown> => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {});
|
|
88
|
+
|
|
89
|
+
export const qs = (params: Record<string, string | number | undefined>) =>
|
|
90
|
+
Object.entries(params)
|
|
91
|
+
.filter(([, v]) => v !== undefined && v !== '')
|
|
92
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
|
93
|
+
.join('&');
|
|
94
|
+
|
|
95
|
+
// rows with neither a name nor an address are useless as suggestions
|
|
96
|
+
export const usable = (rows: GeoResult[], limit: number) => rows.filter((r) => r.name || r.address).slice(0, limit);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The adapter registry. Each provider is a small module behind MapsProvider.
|
|
2
|
+
import type { MapsProviderId } from '../types';
|
|
3
|
+
import type { MapsProvider, ProviderEnv } from './http';
|
|
4
|
+
import { geoapify } from './geoapify';
|
|
5
|
+
import { google } from './google';
|
|
6
|
+
import { mapbox } from './mapbox';
|
|
7
|
+
import { locationiq } from './locationiq';
|
|
8
|
+
import { pelias, stadia } from './pelias';
|
|
9
|
+
import { nominatim, photon } from './osm';
|
|
10
|
+
|
|
11
|
+
export type { MapsProvider, ProviderEnv } from './http';
|
|
12
|
+
|
|
13
|
+
export const PROVIDERS: Record<MapsProviderId, { label: string; selfHosted: boolean; staticMaps: boolean; create: (env: ProviderEnv) => MapsProvider }> = {
|
|
14
|
+
geoapify: { label: 'Geoapify', selfHosted: false, staticMaps: true, create: geoapify },
|
|
15
|
+
google: { label: 'Google Maps', selfHosted: false, staticMaps: true, create: google },
|
|
16
|
+
mapbox: { label: 'Mapbox', selfHosted: false, staticMaps: true, create: mapbox },
|
|
17
|
+
locationiq: { label: 'LocationIQ', selfHosted: false, staticMaps: true, create: locationiq },
|
|
18
|
+
stadia: { label: 'Stadia Maps', selfHosted: false, staticMaps: true, create: stadia },
|
|
19
|
+
photon: { label: 'Photon (self-hosted)', selfHosted: true, staticMaps: false, create: photon },
|
|
20
|
+
nominatim: { label: 'Nominatim (self-hosted)', selfHosted: true, staticMaps: false, create: nominatim },
|
|
21
|
+
pelias: { label: 'Pelias (self-hosted)', selfHosted: true, staticMaps: false, create: pelias },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const PROVIDER_IDS = Object.keys(PROVIDERS) as MapsProviderId[];
|
|
25
|
+
|
|
26
|
+
// credits for images made by each provider (the id is stored in the image's
|
|
27
|
+
// file name, so the credit survives a later provider switch)
|
|
28
|
+
export const ATTRIBUTION: Record<MapsProviderId, string> = {
|
|
29
|
+
geoapify: 'Powered by Geoapify · © OpenMapTiles · © OpenStreetMap contributors',
|
|
30
|
+
google: 'Map data © Google',
|
|
31
|
+
mapbox: '© Mapbox · © OpenStreetMap contributors',
|
|
32
|
+
locationiq: '© LocationIQ · © OpenStreetMap contributors',
|
|
33
|
+
stadia: '© Stadia Maps · © OpenMapTiles · © OpenStreetMap contributors',
|
|
34
|
+
photon: '© OpenStreetMap contributors',
|
|
35
|
+
nominatim: '© OpenStreetMap contributors',
|
|
36
|
+
pelias: '© OpenStreetMap contributors',
|
|
37
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// LocationIQ (OpenStreetMap data): autocomplete, reverse and static maps.
|
|
2
|
+
import type { GeoResult } from '../types';
|
|
3
|
+
import { arr, geo, getImage, getJson, obj, qs, s, usable, type MapsProvider, type ProviderEnv } from './http';
|
|
4
|
+
|
|
5
|
+
export function locationiqResults(json: unknown): GeoResult[] {
|
|
6
|
+
const rows = Array.isArray(json) ? json : [json];
|
|
7
|
+
return rows.map((raw) => {
|
|
8
|
+
const r = obj(raw);
|
|
9
|
+
const place = s(r.display_place);
|
|
10
|
+
return place ? geo(place, s(r.display_address), r.lat, r.lon) : geo('', s(r.display_name), r.lat, r.lon);
|
|
11
|
+
}).filter((r) => r.lat !== null || r.address || r.name);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function locationiq(env: ProviderEnv): MapsProvider {
|
|
15
|
+
return {
|
|
16
|
+
id: 'locationiq',
|
|
17
|
+
label: 'LocationIQ',
|
|
18
|
+
attribution: '© LocationIQ · © OpenStreetMap contributors',
|
|
19
|
+
async search(q, opts = {}) {
|
|
20
|
+
const near = opts.near ? { viewbox: `${opts.near.lng - 0.5},${opts.near.lat - 0.5},${opts.near.lng + 0.5},${opts.near.lat + 0.5}` } : {};
|
|
21
|
+
const json = await getJson(`https://api.locationiq.com/v1/autocomplete?${qs({ key: env.key, q, limit: opts.limit ?? 5, dedupe: 1, ...near })}`, env);
|
|
22
|
+
return usable(locationiqResults(arr(json)), opts.limit ?? 5);
|
|
23
|
+
},
|
|
24
|
+
async reverse(lat, lng) {
|
|
25
|
+
const json = await getJson(`https://us1.locationiq.com/v1/reverse?${qs({ key: env.key, lat, lon: lng, format: 'json' })}`, env);
|
|
26
|
+
return locationiqResults(json)[0] ?? null;
|
|
27
|
+
},
|
|
28
|
+
// no dark style: the light image is used in dark mode too
|
|
29
|
+
async staticMap(lat, lng, { width, height, dark }) {
|
|
30
|
+
if (dark) return null;
|
|
31
|
+
const params = qs({ key: env.key, center: `${lat},${lng}`, zoom: 15, size: `${Math.round(width / 2)}x${Math.round(height / 2)}`, scale: 2, format: 'png', markers: `icon:large-red-cutout|${lat},${lng}` });
|
|
32
|
+
return getImage(`https://maps.locationiq.com/v3/staticmap?${params}`, env);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Mapbox: Geocoding v6 (forward with autocomplete, reverse) and the Static
|
|
2
|
+
// Images API. MAPS_API_KEY is a public access token (pk.…).
|
|
3
|
+
import type { GeoResult } from '../types';
|
|
4
|
+
import { arr, geo, getImage, getJson, obj, qs, s, usable, type MapsProvider, type ProviderEnv } from './http';
|
|
5
|
+
|
|
6
|
+
export function mapboxResults(json: unknown): GeoResult[] {
|
|
7
|
+
return arr(obj(json).features).map((raw) => {
|
|
8
|
+
const f = obj(raw);
|
|
9
|
+
const p = obj(f.properties);
|
|
10
|
+
const c = obj(p.coordinates);
|
|
11
|
+
const pt = arr(obj(f.geometry).coordinates);
|
|
12
|
+
const lat = c.latitude ?? pt[1];
|
|
13
|
+
const lng = c.longitude ?? pt[0];
|
|
14
|
+
return p.feature_type === 'address' ? geo('', s(p.full_address) || s(p.name), lat, lng) : geo(s(p.name), s(p.place_formatted) || s(p.full_address), lat, lng);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function mapbox(env: ProviderEnv): MapsProvider {
|
|
19
|
+
const base = 'https://api.mapbox.com/search/geocode/v6';
|
|
20
|
+
return {
|
|
21
|
+
id: 'mapbox',
|
|
22
|
+
label: 'Mapbox',
|
|
23
|
+
attribution: '© Mapbox · © OpenStreetMap contributors',
|
|
24
|
+
async search(q, opts = {}) {
|
|
25
|
+
const json = await getJson(`${base}/forward?${qs({ q, autocomplete: 'true', limit: opts.limit ?? 5, proximity: opts.near ? `${opts.near.lng},${opts.near.lat}` : undefined, access_token: env.key })}`, env);
|
|
26
|
+
return usable(mapboxResults(json), opts.limit ?? 5);
|
|
27
|
+
},
|
|
28
|
+
async reverse(lat, lng) {
|
|
29
|
+
const json = await getJson(`${base}/reverse?${qs({ latitude: lat, longitude: lng, limit: 1, access_token: env.key })}`, env);
|
|
30
|
+
return mapboxResults(json)[0] ?? null;
|
|
31
|
+
},
|
|
32
|
+
async staticMap(lat, lng, { width, height, dark }) {
|
|
33
|
+
const style = dark ? 'dark-v11' : 'streets-v12';
|
|
34
|
+
const size = `${Math.round(width / 2)}x${Math.round(height / 2)}@2x`;
|
|
35
|
+
return getImage(`https://api.mapbox.com/styles/v1/mapbox/${style}/static/pin-l+e03131(${lng},${lat})/${lng},${lat},15,0/${size}?${qs({ access_token: env.key })}`, env);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Self-hosted OpenStreetMap geocoders at MAPS_URL: Photon (built for
|
|
2
|
+
// search-as-you-type) and Nominatim. Search + reverse only — neither draws
|
|
3
|
+
// map images. Never point these at the public nominatim.openstreetmap.org:
|
|
4
|
+
// its usage policy forbids autocomplete and use by platforms.
|
|
5
|
+
import type { GeoResult } from '../types';
|
|
6
|
+
import { arr, geo, getJson, joinParts, obj, qs, s, usable, type MapsProvider, type ProviderEnv } from './http';
|
|
7
|
+
|
|
8
|
+
export function photonResults(json: unknown): GeoResult[] {
|
|
9
|
+
return arr(obj(json).features).map((raw) => {
|
|
10
|
+
const f = obj(raw);
|
|
11
|
+
const p = obj(f.properties);
|
|
12
|
+
const c = arr(obj(f.geometry).coordinates);
|
|
13
|
+
const street = [s(p.housenumber), s(p.street)].filter(Boolean).join(' ');
|
|
14
|
+
const region = [s(p.state), s(p.postcode)].filter(Boolean).join(' ');
|
|
15
|
+
const address = joinParts(street, s(p.city) || s(p.district) || s(p.county), region, s(p.country));
|
|
16
|
+
const isHouse = p.type === 'house' || (!p.name && street);
|
|
17
|
+
return geo(isHouse ? '' : s(p.name), address, c[1], c[0]);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function nominatimResults(json: unknown): GeoResult[] {
|
|
22
|
+
const rows = Array.isArray(json) ? json : [json];
|
|
23
|
+
return rows.map((raw) => {
|
|
24
|
+
const r = obj(raw);
|
|
25
|
+
return geo(s(r.name), s(r.display_name), r.lat, r.lon);
|
|
26
|
+
}).filter((r) => r.lat !== null || r.address);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function photon(env: ProviderEnv): MapsProvider {
|
|
30
|
+
return {
|
|
31
|
+
id: 'photon',
|
|
32
|
+
label: 'Photon (self-hosted)',
|
|
33
|
+
attribution: '© OpenStreetMap contributors',
|
|
34
|
+
async search(q, opts = {}) {
|
|
35
|
+
const json = await getJson(`${env.url}/api?${qs({ q, limit: opts.limit ?? 5, lat: opts.near?.lat, lon: opts.near?.lng })}`, env);
|
|
36
|
+
return usable(photonResults(json), opts.limit ?? 5);
|
|
37
|
+
},
|
|
38
|
+
async reverse(lat, lng) {
|
|
39
|
+
const json = await getJson(`${env.url}/reverse?${qs({ lat, lon: lng, limit: 1 })}`, env);
|
|
40
|
+
return photonResults(json)[0] ?? null;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function nominatim(env: ProviderEnv): MapsProvider {
|
|
46
|
+
return {
|
|
47
|
+
id: 'nominatim',
|
|
48
|
+
label: 'Nominatim (self-hosted)',
|
|
49
|
+
attribution: '© OpenStreetMap contributors',
|
|
50
|
+
async search(q, opts = {}) {
|
|
51
|
+
const json = await getJson(`${env.url}/search?${qs({ q, format: 'jsonv2', limit: opts.limit ?? 5 })}`, env);
|
|
52
|
+
return usable(nominatimResults(json), opts.limit ?? 5);
|
|
53
|
+
},
|
|
54
|
+
async reverse(lat, lng) {
|
|
55
|
+
const json = await getJson(`${env.url}/reverse?${qs({ lat, lon: lng, format: 'jsonv2' })}`, env);
|
|
56
|
+
return obj(json).error ? null : nominatimResults(json)[0] ?? null;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|