@acms-subir/core 0.3.6 → 0.3.7
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 +17 -0
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +146 -2
- package/src/components/EventPage.astro +25 -5
- 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/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/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 +12 -7
- 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/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/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/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,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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Pelias-style geocoders: Stadia Maps (hosted, with static maps) and a
|
|
2
|
+
// self-hosted Pelias at MAPS_URL (search + reverse only).
|
|
3
|
+
import type { GeoResult } from '../types';
|
|
4
|
+
import { arr, geo, getImage, getJson, obj, qs, s, usable, type MapsProvider, type ProviderEnv, type SearchOptions } from './http';
|
|
5
|
+
|
|
6
|
+
export function peliasResults(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 = arr(obj(f.geometry).coordinates);
|
|
11
|
+
const label = s(p.label);
|
|
12
|
+
return p.layer === 'address' || p.layer === 'street' ? geo('', label || s(p.name), c[1], c[0]) : geo(s(p.name), label, c[1], c[0]);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function peliasCalls(base: string, env: ProviderEnv, auth: Record<string, string>) {
|
|
17
|
+
return {
|
|
18
|
+
async search(q: string, opts: SearchOptions = {}) {
|
|
19
|
+
const near = opts.near ? { 'focus.point.lat': opts.near.lat, 'focus.point.lon': opts.near.lng } : {};
|
|
20
|
+
const json = await getJson(`${base}/v1/autocomplete?${qs({ text: q, size: opts.limit ?? 5, ...near, ...auth })}`, env);
|
|
21
|
+
return usable(peliasResults(json), opts.limit ?? 5);
|
|
22
|
+
},
|
|
23
|
+
async reverse(lat: number, lng: number) {
|
|
24
|
+
const json = await getJson(`${base}/v1/reverse?${qs({ 'point.lat': lat, 'point.lon': lng, size: 1, ...auth })}`, env);
|
|
25
|
+
return peliasResults(json)[0] ?? null;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function stadia(env: ProviderEnv): MapsProvider {
|
|
31
|
+
return {
|
|
32
|
+
id: 'stadia',
|
|
33
|
+
label: 'Stadia Maps',
|
|
34
|
+
attribution: '© Stadia Maps · © OpenMapTiles · © OpenStreetMap contributors',
|
|
35
|
+
...peliasCalls('https://api.stadiamaps.com/geocoding', env, { api_key: env.key }),
|
|
36
|
+
async staticMap(lat, lng, { width, height, dark }) {
|
|
37
|
+
const style = dark ? 'alidade_smooth_dark' : 'alidade_smooth';
|
|
38
|
+
const params = qs({ center: `${lat},${lng}`, zoom: 15, size: `${Math.round(width / 2)}x${Math.round(height / 2)}@2x`, markers: `${lat},${lng}`, api_key: env.key });
|
|
39
|
+
return getImage(`https://tiles.stadiamaps.com/static/${style}?${params}`, env);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pelias(env: ProviderEnv): MapsProvider {
|
|
45
|
+
return {
|
|
46
|
+
id: 'pelias',
|
|
47
|
+
label: 'Pelias (self-hosted)',
|
|
48
|
+
attribution: '© OpenStreetMap contributors',
|
|
49
|
+
...peliasCalls(env.url, env, env.key ? { api_key: env.key } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|