@sevenfold/setto-client 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ import { type CSSProperties } from 'react';
2
+ export interface SettoMapProps {
3
+ /**
4
+ * i18n key prefix holding `lat`, `lng`, optional `zoom` (1–19, default 15)
5
+ * and optional `title`. A static string literal, like every other key.
6
+ */
7
+ k: string;
8
+ className?: string;
9
+ style?: CSSProperties;
10
+ /** The iframe's loading strategy. Default `lazy`. */
11
+ loading?: 'lazy' | 'eager';
12
+ }
13
+ /**
14
+ * A map whose location is content.
15
+ *
16
+ * Rendered as an OpenStreetMap embed with a marker on the point, because a
17
+ * hand-written embed with a guessed pin in the middle of a fjord is what the
18
+ * build agent produced without it, and nobody could move that pin. Here the
19
+ * point lives in the locale, so it publishes like text, and in edit mode the
20
+ * tools Setto serves (`map-tools`) sit over the map to move it.
21
+ *
22
+ * The wrapper carries the site's classes in both modes and the iframe fills
23
+ * it, so a map sized with `h-72 w-full` is the same box whether or not anybody
24
+ * is editing. Without a usable location the wrapper renders empty — no iframe
25
+ * pointing at 0,0 — and still takes the tools, so the owner can set one.
26
+ */
27
+ export declare function SettoMap({ k, className, style, loading }: SettoMapProps): import("react/jsx-runtime").JSX.Element;
package/dist/index.d.ts CHANGED
@@ -21,6 +21,9 @@ export { SETTO_MODULES } from './modules';
21
21
  export type { SettoModule } from './modules';
22
22
  export { SettoVideo } from './SettoVideo';
23
23
  export type { SettoVideoProps } from './SettoVideo';
24
+ export { SettoMap } from './SettoMap';
25
+ export type { SettoMapProps } from './SettoMap';
26
+ export type { MapLocation, MapToolsProps, MapToolsComponent } from './map-tools';
24
27
  export { SettoAnimation } from './SettoAnimation';
25
28
  export type { SettoAnimationProps } from './SettoAnimation';
26
29
  export { useSectionTheme } from './use-section-theme';
@@ -0,0 +1,80 @@
1
+ import type { MapLocation } from '../map-tools';
2
+ import type { I18nStore } from './i18n-store';
3
+ /**
4
+ * Where a `SettoMap` points, and how that becomes an OpenStreetMap embed.
5
+ *
6
+ * The location is content, stored under the map's key in every locale file:
7
+ * `{ lat, lng, zoom, title }`, all strings, because every other value in those
8
+ * files is. Pure on purpose — the component, the editor's tools and the tests
9
+ * all agree on one reading of what the build agent wrote.
10
+ */
11
+ /** What a locale file holds under a map's key. Every field may be missing. */
12
+ export interface RawMapContent {
13
+ lat?: unknown;
14
+ lng?: unknown;
15
+ zoom?: unknown;
16
+ }
17
+ export declare const MAP_MIN_ZOOM = 1;
18
+ export declare const MAP_MAX_ZOOM = 19;
19
+ export declare const MAP_DEFAULT_ZOOM = 15;
20
+ export declare const MAP_DEFAULT_TITLE = "Kart";
21
+ /** Web mercator stops here; a map centred further north cannot be drawn. */
22
+ export declare const MERCATOR_MAX_LAT = 85.0511287798066;
23
+ /**
24
+ * The frame the zoom is chosen for.
25
+ *
26
+ * The embed is told a bounding box, not a zoom, and fits that box into
27
+ * whatever size the iframe has. A box sized for a small frame keeps the zoom
28
+ * the owner chose on anything larger — a phone-width map or a wide `h-72` band
29
+ * both open at it — where a box sized for a desktop would zoom a phone out.
30
+ */
31
+ export declare const MAP_ASSUMED_WIDTH = 320;
32
+ export declare const MAP_ASSUMED_HEIGHT = 240;
33
+ /**
34
+ * A coordinate as the locale spells it.
35
+ *
36
+ * Strict about shape, because `Number('')` is 0 and a map of the Gulf of
37
+ * Guinea is the wrong way to say a value is missing. A decimal comma is
38
+ * accepted: the owner is Norwegian and so is their keyboard.
39
+ */
40
+ export declare function parseCoordinate(value: unknown): number | null;
41
+ /**
42
+ * The zoom, forgiving where the coordinates are not.
43
+ *
44
+ * A missing or unreadable zoom still gives a usable map, so it falls back to
45
+ * the default; a readable one out of range is clamped and rounded rather than
46
+ * thrown away.
47
+ */
48
+ export declare function parseZoom(value: unknown): number;
49
+ /** A location, or null when the coordinates are missing, unreadable or off the map. */
50
+ export declare function parseMapLocation(raw: RawMapContent | null | undefined): MapLocation | null;
51
+ /** Six decimals is about ten centimetres; more is noise in a content file. */
52
+ export declare function formatCoordinate(n: number): string;
53
+ /** The strings a location is stored as. */
54
+ export declare function serialiseMapLocation(location: MapLocation): {
55
+ lat: string;
56
+ lng: string;
57
+ zoom: string;
58
+ };
59
+ export interface MapBbox {
60
+ minLng: number;
61
+ minLat: number;
62
+ maxLng: number;
63
+ maxLat: number;
64
+ }
65
+ /**
66
+ * The box a frame of `width` × `height` pixels would show, centred on the
67
+ * location at its zoom. Longitude is linear in web mercator; latitude is not,
68
+ * so it goes through world pixels and back.
69
+ */
70
+ export declare function mapBbox(location: MapLocation, width?: number, height?: number): MapBbox;
71
+ /** The OpenStreetMap embed for a location, marker on the point itself. */
72
+ export declare function mapEmbedUrl(location: MapLocation): string;
73
+ /**
74
+ * Write a location to every language at once.
75
+ *
76
+ * A place is not translated, so the same numbers go into each locale, the way
77
+ * `SettoImage` writes an image path. The title is left alone: that one is
78
+ * words, and belongs to each language.
79
+ */
80
+ export declare function writeMapLocation(store: Pick<I18nStore, 'set' | 'batch'>, languages: readonly string[], key: string, location: MapLocation): void;
@@ -0,0 +1,24 @@
1
+ import type { ComponentType } from 'react';
2
+ /**
3
+ * The contract between a map on the customer's page and the tools Setto
4
+ * serves for moving it.
5
+ *
6
+ * Same split as the form field tools: the search, the draggable preview and
7
+ * the buttons live in setto-server's chrome and change with a deploy, while
8
+ * `SettoMap` — compiled into the customer's bundle — applies what they hand
9
+ * back. So this is the pinned part, and it is deliberately plain data.
10
+ */
11
+ /** A point on the map and how close to it the map opens. WGS84 degrees; zoom 1–19. */
12
+ export interface MapLocation {
13
+ lat: number;
14
+ lng: number;
15
+ zoom: number;
16
+ }
17
+ export interface MapToolsProps {
18
+ /** Where the map points now, or null when the content has no usable location. */
19
+ location: MapLocation | null;
20
+ /** Move the map. `SettoMap` writes it to every language as a draft. */
21
+ onChange: (next: MapLocation) => void;
22
+ }
23
+ /** What the chrome registers under `mountSlot('map-tools', …)`. */
24
+ export type MapToolsComponent = ComponentType<MapToolsProps>;
@@ -37755,6 +37755,142 @@ function toEmbedUrl(url) {
37755
37755
  return null;
37756
37756
  }
37757
37757
  }
37758
+ const MAP_MIN_ZOOM = 1;
37759
+ const MAP_MAX_ZOOM = 19;
37760
+ const MAP_DEFAULT_ZOOM = 15;
37761
+ const MAP_DEFAULT_TITLE = "Kart";
37762
+ const MERCATOR_MAX_LAT = 85.0511287798066;
37763
+ const MAP_ASSUMED_WIDTH = 320;
37764
+ const MAP_ASSUMED_HEIGHT = 240;
37765
+ const TILE_SIZE = 256;
37766
+ function parseCoordinate(value) {
37767
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
37768
+ if (typeof value !== "string") return null;
37769
+ const text2 = value.trim().replace(",", ".");
37770
+ if (!/^[+-]?(\d+(\.\d*)?|\.\d+)$/.test(text2)) return null;
37771
+ const n = Number(text2);
37772
+ return Number.isFinite(n) ? n : null;
37773
+ }
37774
+ function parseZoom(value) {
37775
+ const n = parseCoordinate(value);
37776
+ if (n === null) return MAP_DEFAULT_ZOOM;
37777
+ return Math.min(MAP_MAX_ZOOM, Math.max(MAP_MIN_ZOOM, Math.round(n)));
37778
+ }
37779
+ function parseMapLocation(raw) {
37780
+ if (!raw) return null;
37781
+ const lat = parseCoordinate(raw.lat);
37782
+ const lng = parseCoordinate(raw.lng);
37783
+ if (lat === null || lng === null) return null;
37784
+ if (Math.abs(lat) > MERCATOR_MAX_LAT || Math.abs(lng) > 180) return null;
37785
+ return { lat, lng, zoom: parseZoom(raw.zoom) };
37786
+ }
37787
+ function formatCoordinate(n) {
37788
+ const fixed = n.toFixed(6);
37789
+ const trimmed = fixed.includes(".") ? fixed.replace(/0+$/, "").replace(/\.$/, "") : fixed;
37790
+ return trimmed === "-0" ? "0" : trimmed;
37791
+ }
37792
+ function serialiseMapLocation(location2) {
37793
+ return {
37794
+ lat: formatCoordinate(location2.lat),
37795
+ lng: formatCoordinate(location2.lng),
37796
+ zoom: String(parseZoom(location2.zoom))
37797
+ };
37798
+ }
37799
+ function worldSize(zoom) {
37800
+ return TILE_SIZE * 2 ** zoom;
37801
+ }
37802
+ function latToWorldY(lat, zoom) {
37803
+ const clamped = Math.max(-MERCATOR_MAX_LAT, Math.min(MERCATOR_MAX_LAT, lat));
37804
+ const sin = Math.sin(clamped * Math.PI / 180);
37805
+ return (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * worldSize(zoom);
37806
+ }
37807
+ function worldYToLat(y, zoom) {
37808
+ const n = Math.PI - 2 * Math.PI * y / worldSize(zoom);
37809
+ return 180 / Math.PI * Math.atan(Math.sinh(n));
37810
+ }
37811
+ function mapBbox(location2, width = MAP_ASSUMED_WIDTH, height = MAP_ASSUMED_HEIGHT) {
37812
+ const degreesPerPixel = 360 / worldSize(location2.zoom);
37813
+ const halfLng = width / 2 * degreesPerPixel;
37814
+ const y = latToWorldY(location2.lat, location2.zoom);
37815
+ const max = worldSize(location2.zoom);
37816
+ return {
37817
+ minLng: Math.max(-180, location2.lng - halfLng),
37818
+ maxLng: Math.min(180, location2.lng + halfLng),
37819
+ minLat: worldYToLat(Math.min(max, y + height / 2), location2.zoom),
37820
+ maxLat: worldYToLat(Math.max(0, y - height / 2), location2.zoom)
37821
+ };
37822
+ }
37823
+ function mapEmbedUrl(location2) {
37824
+ const box = mapBbox(location2);
37825
+ const bbox = [box.minLng, box.minLat, box.maxLng, box.maxLat].map(formatCoordinate).join(",");
37826
+ const marker = `${formatCoordinate(location2.lat)},${formatCoordinate(location2.lng)}`;
37827
+ return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${marker}`;
37828
+ }
37829
+ function writeMapLocation(store, languages, key, location2) {
37830
+ const values = serialiseMapLocation(location2);
37831
+ store.batch(() => {
37832
+ for (const lng of languages) {
37833
+ store.set(`${key}.lat`, lng, values.lat);
37834
+ store.set(`${key}.lng`, lng, values.lng);
37835
+ store.set(`${key}.zoom`, lng, values.zoom);
37836
+ }
37837
+ });
37838
+ }
37839
+ function SettoMap({ k, className, style, loading = "lazy" }) {
37840
+ const { t, i18n } = useTranslation();
37841
+ const { editMode, store } = useSetto();
37842
+ const guestEdit = useGuestEdit();
37843
+ const editable = editMode || guestEdit.active;
37844
+ const MapTools = useSettoChromeSlot("map-tools");
37845
+ const [, force] = useState(0);
37846
+ useEffect(() => {
37847
+ if (!store) return;
37848
+ return store.subscribe(() => force((x) => x + 1));
37849
+ }, [store]);
37850
+ const lng = i18n.language;
37851
+ const read = (field) => {
37852
+ const key = `${k}.${field}`;
37853
+ return store?.get(key, lng) || t(key, { defaultValue: "" });
37854
+ };
37855
+ const location2 = parseMapLocation({ lat: read("lat"), lng: read("lng"), zoom: read("zoom") });
37856
+ const title = read("title") || MAP_DEFAULT_TITLE;
37857
+ const tools = editable && store && MapTools ? /* @__PURE__ */ jsx(
37858
+ MapTools,
37859
+ {
37860
+ location: location2,
37861
+ onChange: (next) => writeMapLocation(store, store.languages(lng), k, next)
37862
+ }
37863
+ ) : null;
37864
+ if (!location2) {
37865
+ return /* @__PURE__ */ jsx("div", { className, style, "data-setto-map": k, "data-setto-map-empty": "1", children: tools ? /* @__PURE__ */ jsx("div", { style: fillStyle, children: tools }) : null });
37866
+ }
37867
+ return /* @__PURE__ */ jsx("div", { className, style, "data-setto-map": k, children: /* @__PURE__ */ jsxs("div", { style: fillStyle, children: [
37868
+ /* @__PURE__ */ jsx(
37869
+ "iframe",
37870
+ {
37871
+ src: mapEmbedUrl(location2),
37872
+ title,
37873
+ loading,
37874
+ referrerPolicy: "strict-origin-when-cross-origin",
37875
+ style: iframeStyle
37876
+ }
37877
+ ),
37878
+ tools
37879
+ ] }) });
37880
+ }
37881
+ const fillStyle = {
37882
+ position: "relative",
37883
+ width: "100%",
37884
+ height: "100%",
37885
+ borderRadius: "inherit",
37886
+ overflow: "hidden"
37887
+ };
37888
+ const iframeStyle = {
37889
+ display: "block",
37890
+ width: "100%",
37891
+ height: "100%",
37892
+ border: 0
37893
+ };
37758
37894
  const DEFAULT_DURATION = {
37759
37895
  fadeIn: 600,
37760
37896
  slideUp: 700,
@@ -38739,6 +38875,7 @@ export {
38739
38875
  SettoIcon,
38740
38876
  SettoImage,
38741
38877
  SettoLanguages,
38878
+ SettoMap,
38742
38879
  SettoMark,
38743
38880
  SettoProvider,
38744
38881
  SettoRepeater,