@escape-game-over/atlas 0.1.50 → 0.1.53
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/package.json +6 -1
- package/src/astro/AutoDialog.astro +40 -0
- package/src/astro/Document.astro +13 -2
- package/src/astro/GoogleMap.astro +76 -0
- package/src/astro/RememberLocale.astro +23 -0
- package/src/astro/auto-dialog.ts +72 -0
- package/src/astro/consent-element.ts +2 -1
- package/src/astro/consent.ts +3 -0
- package/src/astro/google-map.ts +135 -0
- package/src/astro/remember-locale.ts +35 -0
- package/src/index.ts +1 -0
- package/src/map.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escape-game-over/atlas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.53",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
|
|
6
6
|
"private": false,
|
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
"./astro": "./src/astro/index.ts",
|
|
17
17
|
"./astro/images": "./src/astro/images.ts",
|
|
18
18
|
"./client": "./src/astro/client.ts",
|
|
19
|
+
"./astro/auto-dialog": "./src/astro/AutoDialog.astro",
|
|
19
20
|
"./astro/consent-banner": "./src/astro/ConsentBanner.astro",
|
|
20
21
|
"./astro/document": "./src/astro/Document.astro",
|
|
21
22
|
"./astro/element": "./src/astro/AtlasElement.astro",
|
|
23
|
+
"./astro/google-map": "./src/astro/GoogleMap.astro",
|
|
22
24
|
"./astro/image": "./src/astro/Image.astro",
|
|
23
25
|
"./astro/rich-text": "./src/astro/RichText.astro"
|
|
24
26
|
},
|
|
@@ -45,6 +47,9 @@
|
|
|
45
47
|
"fmt": "biome format --write .",
|
|
46
48
|
"check-fmt": "biome check . --error-on-warnings"
|
|
47
49
|
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@types/google.maps": "3.66.4"
|
|
52
|
+
},
|
|
48
53
|
"peerDependencies": {
|
|
49
54
|
"@types/node": ">=22",
|
|
50
55
|
"astro": ">=7",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A native `<dialog>` that opens by itself, around the site's own markup. The
|
|
4
|
+
* browser handles focus, Escape, the backdrop and a click outside
|
|
5
|
+
* (`closedby="any"`). Close it with `<form method="dialog"><button>…</button></form>`.
|
|
6
|
+
*
|
|
7
|
+
* ```astro
|
|
8
|
+
* <AutoDialog id="promo" aria-label="…" after={2000} once="session">…</AutoDialog>
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* A dialog opened only by a button or a script needs none of this: write
|
|
12
|
+
* `<dialog closedby="any">`.
|
|
13
|
+
*/
|
|
14
|
+
import type { HTMLAttributes } from "astro/types";
|
|
15
|
+
import AtlasElement from "./AtlasElement.astro";
|
|
16
|
+
import { type AutoOpen, autoDialog } from "./auto-dialog.ts";
|
|
17
|
+
|
|
18
|
+
type Props = AutoOpen &
|
|
19
|
+
Omit<HTMLAttributes<"dialog">, "id" | "open"> & {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const { after, once, waitForConsent, modal, ...attrs } = Astro.props;
|
|
24
|
+
const auto: AutoOpen = { after, once, waitForConsent, modal };
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<script src="./auto-dialog.ts" />
|
|
28
|
+
|
|
29
|
+
<AtlasElement
|
|
30
|
+
of={autoDialog}
|
|
31
|
+
data-auto={JSON.stringify(auto)}
|
|
32
|
+
style="display:contents"
|
|
33
|
+
>
|
|
34
|
+
<dialog
|
|
35
|
+
closedby="any"
|
|
36
|
+
{...attrs}
|
|
37
|
+
>
|
|
38
|
+
<slot />
|
|
39
|
+
</dialog>
|
|
40
|
+
</AtlasElement>
|
package/src/astro/Document.astro
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* A site with a lead form passes `rememberCampaign`, so the `utm_*` tags a
|
|
14
14
|
* visit landed with survive to the page the form is on; see `attribution.ts`.
|
|
15
15
|
*
|
|
16
|
+
* It sends a reader arriving from elsewhere to the page in the language they
|
|
17
|
+
* last chose; see `RememberLocale.astro`. `rememberLocale={false}` turns it off.
|
|
18
|
+
*
|
|
16
19
|
* `fonts={site.fonts}` writes the `@font-face` rules and preloads for the
|
|
17
20
|
* faces `siteFonts(site)` registered in the Astro config.
|
|
18
21
|
*
|
|
@@ -29,6 +32,7 @@ import type { Fonts } from "../fonts.ts";
|
|
|
29
32
|
import type { PageMeta } from "../site/index.ts";
|
|
30
33
|
import MetaTags from "./MetaTags.astro";
|
|
31
34
|
import RememberCampaign from "./RememberCampaign.astro";
|
|
35
|
+
import RememberLocale from "./RememberLocale.astro";
|
|
32
36
|
import ScrollRestore from "./ScrollRestore.astro";
|
|
33
37
|
|
|
34
38
|
interface Props {
|
|
@@ -41,6 +45,8 @@ interface Props {
|
|
|
41
45
|
readonly restoreScroll?: boolean;
|
|
42
46
|
/** Off unless stated: a site with no lead form has nothing to keep them for. */
|
|
43
47
|
readonly rememberCampaign?: boolean;
|
|
48
|
+
/** Defaults to `true`. */
|
|
49
|
+
readonly rememberLocale?: boolean;
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
const {
|
|
@@ -50,6 +56,7 @@ const {
|
|
|
50
56
|
body,
|
|
51
57
|
restoreScroll = true,
|
|
52
58
|
rememberCampaign = false,
|
|
59
|
+
rememberLocale = true,
|
|
53
60
|
} = Astro.props;
|
|
54
61
|
---
|
|
55
62
|
|
|
@@ -62,9 +69,13 @@ const {
|
|
|
62
69
|
<head>
|
|
63
70
|
<slot name="head-start" />
|
|
64
71
|
<MetaTags tags={meta.tags} />
|
|
72
|
+
{rememberLocale && <RememberLocale />}
|
|
65
73
|
{Object.entries(fonts).map(([key, font]) => (
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
<Font
|
|
75
|
+
cssVariable={`--${key}`}
|
|
76
|
+
preload={font.preload === true}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
68
79
|
<slot name="head" />
|
|
69
80
|
</head>
|
|
70
81
|
<body {...body}>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A Google map of `places`, loaded only when asked for. Until then the element
|
|
4
|
+
* shows its children: a picture and a button, say. With `load="click"` (the
|
|
5
|
+
* default) a click anywhere on them loads it; with `"visible"`, scrolling near.
|
|
6
|
+
*
|
|
7
|
+
* `mapId` is required: markers are `AdvancedMarkerElement`, which needs one,
|
|
8
|
+
* and its colours are set on the Map ID in the Cloud console. `colorScheme`
|
|
9
|
+
* picks that style's light or dark variant; the site's own, so a dark site
|
|
10
|
+
* never gets Google's default light map.
|
|
11
|
+
*
|
|
12
|
+
* Give the element its size; the map fills it.
|
|
13
|
+
*
|
|
14
|
+
* ```astro
|
|
15
|
+
* <GoogleMap apiKey={key} mapId={id} colorScheme="DARK" places={[{ at, label, href }]} class="block h-96">
|
|
16
|
+
* <button type="button">Show map</button>
|
|
17
|
+
* </GoogleMap>
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
import type { HTMLAttributes } from "astro/types";
|
|
21
|
+
import { type MapPlace, mapFrame } from "../map.ts";
|
|
22
|
+
import type { NonEmpty } from "../types.ts";
|
|
23
|
+
import AtlasElement from "./AtlasElement.astro";
|
|
24
|
+
import { type GoogleMapConfig, googleMap } from "./google-map.ts";
|
|
25
|
+
|
|
26
|
+
type Props = HTMLAttributes<"div"> & {
|
|
27
|
+
readonly apiKey: string;
|
|
28
|
+
readonly mapId: string;
|
|
29
|
+
readonly places: NonEmpty<MapPlace>;
|
|
30
|
+
readonly load?: GoogleMapConfig["load"];
|
|
31
|
+
readonly colorScheme: GoogleMapConfig["colorScheme"];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
apiKey,
|
|
36
|
+
mapId,
|
|
37
|
+
places,
|
|
38
|
+
load = "click",
|
|
39
|
+
colorScheme,
|
|
40
|
+
...attrs
|
|
41
|
+
} = Astro.props;
|
|
42
|
+
|
|
43
|
+
const config: GoogleMapConfig = {
|
|
44
|
+
apiKey,
|
|
45
|
+
mapId,
|
|
46
|
+
load,
|
|
47
|
+
colorScheme,
|
|
48
|
+
frame: mapFrame(places),
|
|
49
|
+
places: places.map(({ at, label, href, pin }) => ({
|
|
50
|
+
lat: at.latitude,
|
|
51
|
+
lng: at.longitude,
|
|
52
|
+
label,
|
|
53
|
+
href,
|
|
54
|
+
pin:
|
|
55
|
+
pin === undefined
|
|
56
|
+
? undefined
|
|
57
|
+
: {
|
|
58
|
+
src: pin.image.src,
|
|
59
|
+
width: pin.width,
|
|
60
|
+
height: Math.round(
|
|
61
|
+
(pin.width * pin.image.height) / pin.image.width
|
|
62
|
+
),
|
|
63
|
+
},
|
|
64
|
+
})),
|
|
65
|
+
};
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
<script src="./google-map.ts" />
|
|
69
|
+
|
|
70
|
+
<AtlasElement
|
|
71
|
+
of={googleMap}
|
|
72
|
+
{...attrs}
|
|
73
|
+
data-map={JSON.stringify(config)}
|
|
74
|
+
>
|
|
75
|
+
<slot />
|
|
76
|
+
</AtlasElement>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Opens a page in the language its reader chose, however they reached it.
|
|
4
|
+
*
|
|
5
|
+
* The choice is the locale of the last page reached from inside the site, so
|
|
6
|
+
* using the language switcher makes it and a shared link does not. Arriving
|
|
7
|
+
* from anywhere else, a reader is sent to this page in their chosen locale when
|
|
8
|
+
* the head lists an alternate for it. Crawlers keep no storage, so they are
|
|
9
|
+
* never sent anywhere.
|
|
10
|
+
*
|
|
11
|
+
* Rendered by `Document` after the head tags, whose alternates it reads.
|
|
12
|
+
* Inline and blocking, so the wrong language is never painted.
|
|
13
|
+
*/
|
|
14
|
+
import { inlineScript } from "./inline-script.ts";
|
|
15
|
+
import source from "./remember-locale.ts?raw";
|
|
16
|
+
|
|
17
|
+
const code = await inlineScript(source);
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<script
|
|
21
|
+
is:inline
|
|
22
|
+
set:html={code}
|
|
23
|
+
/>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { CONSENT_EVENT, CONSENT_TAG, readConsent } from "./consent.ts";
|
|
2
|
+
import { element } from "./element.ts";
|
|
3
|
+
import { data, ref } from "./ref.ts";
|
|
4
|
+
|
|
5
|
+
/** When an `<AutoDialog>` opens by itself. */
|
|
6
|
+
export interface AutoOpen {
|
|
7
|
+
/** Milliseconds after load, or after the consent answer with `waitForConsent`. */
|
|
8
|
+
readonly after: number;
|
|
9
|
+
/**
|
|
10
|
+
* Once closed, stays closed for the tab (`session`) or for good (`visitor`).
|
|
11
|
+
* Omitted, it opens on every page.
|
|
12
|
+
*/
|
|
13
|
+
readonly once?: "session" | "visitor";
|
|
14
|
+
/** Holds the timer until the consent banner is answered, where it asks. */
|
|
15
|
+
readonly waitForConsent?: boolean;
|
|
16
|
+
/** Defaults to `true`; `false` leaves the page usable behind it. */
|
|
17
|
+
readonly modal?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const storage = (once: "session" | "visitor"): Storage =>
|
|
21
|
+
once === "session" ? sessionStorage : localStorage;
|
|
22
|
+
|
|
23
|
+
/** Opens `dialog` as `options` say, until `signal` aborts. */
|
|
24
|
+
export function autoOpen(
|
|
25
|
+
dialog: HTMLDialogElement,
|
|
26
|
+
{ after, once, waitForConsent = false, modal = true }: AutoOpen,
|
|
27
|
+
signal: AbortSignal
|
|
28
|
+
): void {
|
|
29
|
+
const key = `atlas-dialog:${dialog.id}`;
|
|
30
|
+
// Storage is blocked in some private windows: then it opens every time.
|
|
31
|
+
try {
|
|
32
|
+
if (once !== undefined && storage(once).getItem(key) !== null) return;
|
|
33
|
+
} catch {}
|
|
34
|
+
|
|
35
|
+
if (once !== undefined) {
|
|
36
|
+
dialog.addEventListener(
|
|
37
|
+
"close",
|
|
38
|
+
() => {
|
|
39
|
+
try {
|
|
40
|
+
storage(once).setItem(key, "closed");
|
|
41
|
+
} catch {}
|
|
42
|
+
},
|
|
43
|
+
{ signal }
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const start = () => {
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
if (dialog.open) return;
|
|
50
|
+
if (modal) dialog.showModal();
|
|
51
|
+
else dialog.show();
|
|
52
|
+
}, after);
|
|
53
|
+
signal.addEventListener("abort", () => clearTimeout(timer));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const asking =
|
|
57
|
+
document.querySelector(CONSENT_TAG) !== null &&
|
|
58
|
+
readConsent() === undefined;
|
|
59
|
+
if (waitForConsent && asking) {
|
|
60
|
+
addEventListener(CONSENT_EVENT, start, { once: true, signal });
|
|
61
|
+
} else {
|
|
62
|
+
start();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const autoDialog = element("atlas-dialog", ({ root, signal }) => {
|
|
67
|
+
autoOpen(
|
|
68
|
+
ref<HTMLDialogElement>(root, "dialog"),
|
|
69
|
+
JSON.parse(data(root, "auto")),
|
|
70
|
+
signal
|
|
71
|
+
);
|
|
72
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CONSENT_CATEGORIES } from "../analytics/consent-storage.ts";
|
|
2
2
|
import {
|
|
3
|
+
CONSENT_TAG,
|
|
3
4
|
type ConsentCategory,
|
|
4
5
|
type ConsentChoices,
|
|
5
6
|
everyCategory,
|
|
@@ -36,7 +37,7 @@ import { data, ref, refs } from "./ref.ts";
|
|
|
36
37
|
* `data-consent-reopen hidden`: it stays hidden until there is an answer to
|
|
37
38
|
* withdraw.
|
|
38
39
|
*/
|
|
39
|
-
export const consentBanner = element(
|
|
40
|
+
export const consentBanner = element(CONSENT_TAG, ({ root, signal }) => {
|
|
40
41
|
const boxes = refs<HTMLInputElement>(root, "[data-consent-category]").map(
|
|
41
42
|
(box) => {
|
|
42
43
|
const category = data(box, "consent-category");
|
package/src/astro/consent.ts
CHANGED
|
@@ -86,6 +86,9 @@ export function readConsent(): ConsentRecord | undefined {
|
|
|
86
86
|
*/
|
|
87
87
|
export const CONSENT_EVENT = "atlas:consent";
|
|
88
88
|
|
|
89
|
+
/** The banner's tag: on the page only where this deployment asks for consent. */
|
|
90
|
+
export const CONSENT_TAG = "atlas-consent";
|
|
91
|
+
|
|
89
92
|
/**
|
|
90
93
|
* Remembers an answer, dated now, tells Google about it, and announces it — one
|
|
91
94
|
* call, so the three cannot come apart.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/// <reference types="google.maps" />
|
|
2
|
+
import type { MapFrame } from "../map.ts";
|
|
3
|
+
import { reportDevError } from "./dev-log.ts";
|
|
4
|
+
import { element } from "./element.ts";
|
|
5
|
+
import { data } from "./ref.ts";
|
|
6
|
+
|
|
7
|
+
/** What `GoogleMap.astro` hands the element, serialised. */
|
|
8
|
+
export interface GoogleMapConfig {
|
|
9
|
+
readonly apiKey: string;
|
|
10
|
+
readonly mapId: string;
|
|
11
|
+
readonly load: "click" | "visible";
|
|
12
|
+
readonly colorScheme: google.maps.ColorSchemeString;
|
|
13
|
+
readonly frame: MapFrame;
|
|
14
|
+
readonly places: readonly {
|
|
15
|
+
readonly lat: number;
|
|
16
|
+
readonly lng: number;
|
|
17
|
+
readonly label: string;
|
|
18
|
+
readonly href?: string;
|
|
19
|
+
readonly pin?: {
|
|
20
|
+
readonly src: string;
|
|
21
|
+
readonly width: number;
|
|
22
|
+
readonly height: number;
|
|
23
|
+
};
|
|
24
|
+
}[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CALLBACK = "__atlasGoogleMaps";
|
|
28
|
+
|
|
29
|
+
let loading: Promise<typeof google.maps> | undefined;
|
|
30
|
+
|
|
31
|
+
/** The Maps JavaScript API, fetched once per page however many maps ask. */
|
|
32
|
+
export function loadGoogleMaps(apiKey: string): Promise<typeof google.maps> {
|
|
33
|
+
loading ??= new Promise((resolve, reject) => {
|
|
34
|
+
(window as unknown as Record<string, () => void>)[CALLBACK] = () =>
|
|
35
|
+
resolve(google.maps);
|
|
36
|
+
const script = document.createElement("script");
|
|
37
|
+
script.src = `https://maps.googleapis.com/maps/api/js?${new URLSearchParams(
|
|
38
|
+
{ key: apiKey, v: "weekly", loading: "async", callback: CALLBACK }
|
|
39
|
+
)}`;
|
|
40
|
+
script.async = true;
|
|
41
|
+
script.onerror = () => {
|
|
42
|
+
// So the next click tries again.
|
|
43
|
+
loading = undefined;
|
|
44
|
+
reject(new Error("the Maps JavaScript API failed to load"));
|
|
45
|
+
};
|
|
46
|
+
document.head.append(script);
|
|
47
|
+
});
|
|
48
|
+
return loading;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function draw(root: HTMLElement, config: GoogleMapConfig) {
|
|
52
|
+
const maps = await loadGoogleMaps(config.apiKey);
|
|
53
|
+
const [{ Map: GoogleMapClass, InfoWindow }, { AdvancedMarkerElement }] =
|
|
54
|
+
await Promise.all([
|
|
55
|
+
maps.importLibrary("maps"),
|
|
56
|
+
maps.importLibrary("marker"),
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const container = document.createElement("div");
|
|
60
|
+
container.style.width = "100%";
|
|
61
|
+
container.style.height = "100%";
|
|
62
|
+
root.replaceChildren(container);
|
|
63
|
+
|
|
64
|
+
const { frame } = config;
|
|
65
|
+
const map = new GoogleMapClass(container, {
|
|
66
|
+
mapId: config.mapId,
|
|
67
|
+
colorScheme: config.colorScheme,
|
|
68
|
+
...(frame.kind === "point"
|
|
69
|
+
? { center: frame.center, zoom: frame.zoom }
|
|
70
|
+
: {
|
|
71
|
+
center: {
|
|
72
|
+
lat: (frame.south + frame.north) / 2,
|
|
73
|
+
lng: (frame.west + frame.east) / 2,
|
|
74
|
+
},
|
|
75
|
+
zoom: 1,
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
if (frame.kind === "bounds") {
|
|
79
|
+
const { south, west, north, east } = frame;
|
|
80
|
+
map.fitBounds({ south, west, north, east }, 50);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const info = new InfoWindow();
|
|
84
|
+
for (const place of config.places) {
|
|
85
|
+
let content: HTMLImageElement | undefined;
|
|
86
|
+
if (place.pin !== undefined) {
|
|
87
|
+
content = document.createElement("img");
|
|
88
|
+
content.src = place.pin.src;
|
|
89
|
+
content.alt = "";
|
|
90
|
+
content.width = place.pin.width;
|
|
91
|
+
content.height = place.pin.height;
|
|
92
|
+
}
|
|
93
|
+
const marker = new AdvancedMarkerElement({
|
|
94
|
+
map,
|
|
95
|
+
position: { lat: place.lat, lng: place.lng },
|
|
96
|
+
title: place.label,
|
|
97
|
+
content,
|
|
98
|
+
gmpClickable: place.href !== undefined,
|
|
99
|
+
});
|
|
100
|
+
const { href } = place;
|
|
101
|
+
if (href === undefined) continue;
|
|
102
|
+
marker.addEventListener("gmp-click", () => {
|
|
103
|
+
const link = document.createElement("a");
|
|
104
|
+
link.href = href;
|
|
105
|
+
link.target = "_blank";
|
|
106
|
+
link.rel = "noopener";
|
|
107
|
+
link.textContent = place.label;
|
|
108
|
+
info.setContent(link);
|
|
109
|
+
info.open({ anchor: marker, map });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Internal to `GoogleMap.astro`. */
|
|
115
|
+
export const googleMap = element("atlas-google-map", ({ root, signal }) => {
|
|
116
|
+
const config: GoogleMapConfig = JSON.parse(data(root, "map"));
|
|
117
|
+
const show = () => {
|
|
118
|
+
draw(root, config).catch((error) => reportDevError("GoogleMap", error));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (config.load === "click") {
|
|
122
|
+
root.addEventListener("click", show, { once: true, signal });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const observer = new IntersectionObserver(
|
|
126
|
+
(entries) => {
|
|
127
|
+
if (!entries.some((entry) => entry.isIntersecting)) return;
|
|
128
|
+
observer.disconnect();
|
|
129
|
+
show();
|
|
130
|
+
},
|
|
131
|
+
{ rootMargin: "200px" }
|
|
132
|
+
);
|
|
133
|
+
observer.observe(root);
|
|
134
|
+
return () => observer.disconnect();
|
|
135
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The body of `RememberLocale.astro`'s inline script. A classic script: no
|
|
2
|
+
// imports or exports, and the block keeps its names out of the global scope.
|
|
3
|
+
{
|
|
4
|
+
const KEY = "atlas-locale";
|
|
5
|
+
const current = document.documentElement.lang;
|
|
6
|
+
|
|
7
|
+
const remember = () => {
|
|
8
|
+
// Came from this site: the visitor is browsing, or just switched
|
|
9
|
+
// language. Either way, this locale is their choice.
|
|
10
|
+
if (document.referrer.startsWith(`${location.origin}/`)) {
|
|
11
|
+
localStorage.setItem(KEY, current);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const chosen = localStorage.getItem(KEY);
|
|
16
|
+
if (chosen === null || chosen === current) return;
|
|
17
|
+
|
|
18
|
+
// No alternate means this page is not built in that locale.
|
|
19
|
+
const alternate = document.querySelector<HTMLLinkElement>(
|
|
20
|
+
`link[rel="alternate"][hreflang="${CSS.escape(chosen)}"]`
|
|
21
|
+
);
|
|
22
|
+
if (alternate === null) return;
|
|
23
|
+
|
|
24
|
+
// Path only: alternates carry the production origin, not a preview's.
|
|
25
|
+
location.replace(
|
|
26
|
+
new URL(alternate.href).pathname + location.search + location.hash
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
remember();
|
|
32
|
+
} catch {
|
|
33
|
+
// Storage is blocked in some private windows.
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
CHANGED
package/src/map.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { assertCoordinates, type Coordinates } from "./contact.ts";
|
|
2
|
+
import type { ImageAsset } from "./image.ts";
|
|
3
|
+
import type { NonEmpty } from "./types.ts";
|
|
4
|
+
import type { HttpsUrl } from "./url.ts";
|
|
5
|
+
|
|
6
|
+
/** A marker on a map. */
|
|
7
|
+
export interface MapPlace {
|
|
8
|
+
readonly at: Coordinates;
|
|
9
|
+
/** The marker's title, and the info window's text. */
|
|
10
|
+
readonly label: string;
|
|
11
|
+
/** Opened from the info window, usually the place on Google Maps. None: no info window. */
|
|
12
|
+
readonly href?: HttpsUrl;
|
|
13
|
+
/** A custom pin, drawn `width` pixels wide. */
|
|
14
|
+
readonly pin?: { readonly image: ImageAsset; readonly width: number };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** What the map shows first: one place close up, or every place in view. */
|
|
18
|
+
export type MapFrame =
|
|
19
|
+
| {
|
|
20
|
+
readonly kind: "point";
|
|
21
|
+
readonly center: { readonly lat: number; readonly lng: number };
|
|
22
|
+
readonly zoom: number;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
readonly kind: "bounds";
|
|
26
|
+
readonly south: number;
|
|
27
|
+
readonly west: number;
|
|
28
|
+
readonly north: number;
|
|
29
|
+
readonly east: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Street level: close enough to find the door. */
|
|
33
|
+
const POINT_ZOOM = 15;
|
|
34
|
+
|
|
35
|
+
export function mapFrame(places: NonEmpty<MapPlace>): MapFrame {
|
|
36
|
+
for (const place of places) assertCoordinates(place.at, place.label);
|
|
37
|
+
|
|
38
|
+
const latitudes = places.map((place) => place.at.latitude);
|
|
39
|
+
const longitudes = places.map((place) => place.at.longitude);
|
|
40
|
+
const south = Math.min(...latitudes);
|
|
41
|
+
const north = Math.max(...latitudes);
|
|
42
|
+
const west = Math.min(...longitudes);
|
|
43
|
+
const east = Math.max(...longitudes);
|
|
44
|
+
|
|
45
|
+
// Bounds around one point would zoom in as far as the map goes.
|
|
46
|
+
if (south === north && west === east) {
|
|
47
|
+
return {
|
|
48
|
+
kind: "point",
|
|
49
|
+
center: { lat: south, lng: west },
|
|
50
|
+
zoom: POINT_ZOOM,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { kind: "bounds", south, west, north, east };
|
|
54
|
+
}
|