@protobase/ui 0.13.0 → 0.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/app/create/create-page.js +1 -0
  2. package/dist/app/current-address.d.ts +3 -2
  3. package/dist/app/current-address.js +2 -1
  4. package/dist/app/delete/use-delete-flow.js +2 -1
  5. package/dist/app/displays/card-carousel.js +4 -1
  6. package/dist/app/displays/card-photos.js +6 -3
  7. package/dist/app/displays/map-display.js +83 -23
  8. package/dist/app/displays/map-groups.js +1 -8
  9. package/dist/app/displays/record-card.js +8 -6
  10. package/dist/app/displays/use-map-records.js +44 -0
  11. package/dist/app/embedded-list/embedded-list.js +3 -1
  12. package/dist/app/file-field.js +16 -13
  13. package/dist/app/list-page.js +6 -2
  14. package/dist/app/map/map-area.js +53 -0
  15. package/dist/app/map/records-map.js +91 -38
  16. package/dist/app/pages/field-value.js +22 -9
  17. package/dist/app/use-record-editor.js +2 -1
  18. package/dist/data/api-provider.d.ts +2 -0
  19. package/dist/data/query-keys.js +9 -0
  20. package/dist/data/use-update-record.js +1 -0
  21. package/dist/data-grid/cells.js +8 -5
  22. package/dist/live/file-image.js +35 -0
  23. package/dist/live/file-values.js +17 -1
  24. package/dist/package.js +1 -1
  25. package/dist/shell/assets/app-BBslM0jG.js +66 -0
  26. package/dist/shell/assets/index-CYWfU_c1.css +2 -0
  27. package/dist/shell/assets/{index-D7IcSr6n.js → index-rR2iasG9.js} +1 -1
  28. package/dist/shell/assets/{maplibre-gl-Bt-3iOM-.js → maplibre-gl-estTAzEH.js} +1 -1
  29. package/dist/shell/assets/{record-map-DL4b9dZO.js → record-map-B57A7PQ0.js} +2 -2
  30. package/dist/shell/assets/records-map-BwsWXD8A.js +1 -0
  31. package/dist/shell/assets/shared-protobase-ui-CE7mIalS.js +1 -0
  32. package/dist/shell/index.html +4 -4
  33. package/dist/shell/protobase.shell.json +1 -1
  34. package/package.json +5 -4
  35. package/dist/shell/assets/app-DLjaMVJD.js +0 -66
  36. package/dist/shell/assets/index-BYPphaEm.css +0 -2
  37. package/dist/shell/assets/records-map-D0EOO5kZ.js +0 -1
  38. package/dist/shell/assets/shared-protobase-ui-B8UXOIeW.js +0 -1
@@ -94,6 +94,7 @@ const LoadedCreate = ({ resource, values }) => {
94
94
  "page",
95
95
  "list",
96
96
  "first-page",
97
+ "map",
97
98
  "facets",
98
99
  "series"
99
100
  ]) queryClient.invalidateQueries({ queryKey: [prefix, resource] });
@@ -1,6 +1,6 @@
1
- import { AppAddress, FilterLayout, ResourceModel, ViewModel } from "@protobase/schema";
1
+ import { AppAddress, FilterLayout, MapView, ResourceModel, ViewModel } from "@protobase/schema";
2
2
  //#region src/app/current-address.d.ts
3
- /** A list as the app shows it: every value there, the view's default where the address names none. */
3
+ /** A list as the app shows it: every value there, the view's default where the address names none; `map` has none. */
4
4
  export type ListAddress = {
5
5
  kind: 'list';
6
6
  resource: string;
@@ -8,6 +8,7 @@ export type ListAddress = {
8
8
  filter: string;
9
9
  orderBy: string;
10
10
  filterLayout: FilterLayout;
11
+ map?: MapView;
11
12
  };
12
13
  /** The address the app shows, as logical values. A record's key is in the text form the record API takes. */
13
14
  export type CurrentAddress = ListAddress | {
@@ -35,7 +35,8 @@ const changedList = (read, change, defaults) => {
35
35
  ...next.filter && { filter: next.filter },
36
36
  ...differs("display") && { display: next.display },
37
37
  ...differs("orderBy") && { orderBy: next.orderBy },
38
- ...differs("filterLayout") && { filterLayout: next.filterLayout }
38
+ ...differs("filterLayout") && { filterLayout: next.filterLayout },
39
+ ...next.map && { map: next.map }
39
40
  };
40
41
  };
41
42
  //#endregion
@@ -25,7 +25,8 @@ const useDeleteFlow = ({ model, view, onDeleted, onReview, restore }) => {
25
25
  for (const prefix of [
26
26
  "page",
27
27
  "list",
28
- "first-page"
28
+ "first-page",
29
+ "map"
29
30
  ]) queryClient.invalidateQueries({ queryKey: [prefix, model.name] });
30
31
  for (const prefix of [
31
32
  "facets",
@@ -1,6 +1,7 @@
1
1
  import { cn } from "../../lib/cn.js";
2
2
  import { keys } from "../../data/query-keys.js";
3
3
  import { useClient } from "../../data/api-provider.js";
4
+ import { useAdminMeta } from "../meta-gate.js";
4
5
  import { cardPhotoList, cardPhotosRequest, shownPhoto } from "./card-photos.js";
5
6
  import { useState } from "react";
6
7
  import { ChevronLeft, ChevronRight } from "lucide-react";
@@ -17,7 +18,9 @@ const CardCarousel = ({ photos, recordKey, cover, children }) => {
17
18
  const client = useClient();
18
19
  const [opened, setOpened] = useState(false);
19
20
  const [step, setStep] = useState(0);
20
- const request = cardPhotosRequest(photos, recordKey);
21
+ const target = useAdminMeta().resources[photos.resource];
22
+ const placeholder = target?.fields[photos.image]?.file?.placeholder;
23
+ const request = cardPhotosRequest(photos, recordKey, placeholder !== void 0 && target?.fields[placeholder] ? placeholder : void 0);
21
24
  const loaded = useQuery({
22
25
  queryKey: keys.list(photos.resource, request.filter, request.orderBy ?? "", request.pageSize, `card-photos:${photos.image}`),
23
26
  enabled: opened,
@@ -2,11 +2,14 @@ import { isStoredFile } from "../../live/file-values.js";
2
2
  import { createWhere, printFilter } from "@protobase/schema";
3
3
  //#region src/app/displays/card-photos.ts
4
4
  const where = createWhere();
5
- /** The list request for a card's photos: the records pointing at the card's, in the display's order, with only their image. */
6
- const cardPhotosRequest = (photos, recordKey) => ({
5
+ /**
6
+ * The list request for a card's photos: the records pointing at the card's, in the display's order, with only their
7
+ * image and its placeholder field, when it has one.
8
+ */
9
+ const cardPhotosRequest = (photos, recordKey, placeholder) => ({
7
10
  filter: printFilter(where.eq(photos.field, recordKey)),
8
11
  ...photos.sort !== void 0 && { orderBy: photos.sort },
9
- fields: [photos.image],
12
+ fields: placeholder === void 0 ? [photos.image] : [photos.image, placeholder],
10
13
  pageSize: 100
11
14
  });
12
15
  /** The files a card steps through: its own image first, then the loaded photos less that same file; empty values are left out. */
@@ -1,48 +1,77 @@
1
- import { Skeleton } from "../../primitives/skeleton.js";
1
+ import { Button } from "../../primitives/button.js";
2
2
  import { humanize } from "../../live/naming.js";
3
3
  import { mapCards } from "./list-displays.js";
4
4
  import { useAdminMeta } from "../meta-gate.js";
5
+ import { ErrorBanner } from "../error-banner.js";
5
6
  import { Problem } from "../pages/problem.js";
6
7
  import { RecordCard } from "./record-card.js";
7
- import { DisplayFooter, DisplayState } from "./display-frame.js";
8
+ import { useDebounced } from "../../lib/use-debounced.js";
9
+ import { boundsAround, worldArea } from "../map/map-area.js";
8
10
  import { keptSelection, mapPoints, stepSelection } from "./map-groups.js";
11
+ import { useMapRecords } from "./use-map-records.js";
9
12
  import { Suspense, lazy, useLayoutEffect, useRef, useState } from "react";
10
13
  import { ChevronLeft, ChevronRight, X } from "lucide-react";
11
14
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
12
15
  //#region src/app/displays/map-display.tsx
13
16
  const RecordsMap = lazy(() => import("../map/records-map.js").then((module) => ({ default: module.RecordsMap })));
17
+ const settleMs = 250;
14
18
  const arrow = "flex size-8 items-center justify-center rounded-full border border-border bg-background hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30";
15
19
  /**
16
- * The list's loaded records on a map, drawn with the map style `/meta` names. Choosing a marker shows the card of its
17
- * record at the map's bottom, with arrows through the records the marker stands for; closing it or choosing the map
18
- * elsewhere clears it. Below the map, how many of the matching records are on it and a button for more.
20
+ * The matching records in the map's view, drawn with the map style `/meta` names: the records themselves when few are
21
+ * in view or the map is close, else a count for each part of it, which zooms in when chosen. The map asks again as it
22
+ * pans and zooms. Without a view of its own it first fits every matching record. Choosing a marker shows the card of
23
+ * its record at the map's bottom, with arrows through the records the marker stands for; closing it or choosing the map
24
+ * elsewhere clears it.
19
25
  */
20
- const MapDisplay = ({ model, view, display, list, footer = true }) => {
26
+ const MapDisplay = ({ model, view, display, filter, orderBy, fields, at, onMove, footer = true }) => {
21
27
  const style = useAdminMeta().maps?.style;
22
28
  const [chosen, setChosen] = useState();
29
+ const [area, setArea] = useState();
30
+ const settled = useDebounced(area, settleMs);
31
+ const [fitting, setFitting] = useState(at === void 0);
32
+ const records = useMapRecords({
33
+ model,
34
+ display,
35
+ fields,
36
+ filter,
37
+ orderBy,
38
+ area: fitting ? worldArea : settled
39
+ });
40
+ const loaded = records.isPlaceholderData ? void 0 : records.data;
41
+ const rows = records.data?.rows ?? [];
42
+ const buckets = records.data?.buckets ?? [];
43
+ const points = mapPoints(rows, display);
44
+ const fit = fitting && loaded ? boundsAround(mapPoints(loaded.rows, display), loaded.buckets) : void 0;
23
45
  const cards = mapCards(view, display);
24
46
  const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
25
- const selection = keptSelection(chosen, list.rows);
26
- const row = selection && list.rows.find((candidate) => candidate.key === selection.keys[selection.index]);
27
47
  const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
48
+ const selection = keptSelection(chosen, rows);
49
+ const row = selection && rows.find((candidate) => candidate.key === selection.keys[selection.index]);
28
50
  const markerLabel = (keys) => keys.length === 1 ? `Show the ${singular}` : `Show ${keys.length} ${plural}`;
51
+ const bucketLabel = (count) => `Zoom in on ${count.toLocaleString("en-IE")} ${count === 1 ? singular : plural}`;
29
52
  const overlay = useRef(null);
30
53
  const [covered, setCovered] = useState(0);
31
54
  useLayoutEffect(() => setCovered(overlay.current?.offsetHeight ?? 0), [row?.key]);
32
55
  if (!style) return /* @__PURE__ */ jsx(Problem, { children: "The map display needs a map style: set PROTOBASE_MAP_STYLE_URL." });
33
- return /* @__PURE__ */ jsx(DisplayState, {
34
- list,
35
- empty: `No ${plural} match.`,
36
- loading: /* @__PURE__ */ jsx(Skeleton, { className: "h-96" }),
37
- children: /* @__PURE__ */ jsxs("div", {
38
- className: "flex h-full min-h-[36rem] flex-col sm:min-h-[28rem]",
39
- children: [/* @__PURE__ */ jsxs("div", {
40
- className: "relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-muted",
41
- children: [/* @__PURE__ */ jsx(Suspense, {
56
+ const empty = loaded?.total === 0;
57
+ return /* @__PURE__ */ jsxs("div", {
58
+ className: "flex h-full min-h-[36rem] flex-1 flex-col sm:min-h-[28rem]",
59
+ children: [/* @__PURE__ */ jsxs("div", {
60
+ className: "relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-muted",
61
+ children: [
62
+ /* @__PURE__ */ jsx(Suspense, {
42
63
  fallback: null,
43
64
  children: /* @__PURE__ */ jsx(RecordsMap, {
44
65
  style,
45
- points: mapPoints(list.rows, display),
66
+ initial: at,
67
+ points,
68
+ buckets,
69
+ fit,
70
+ onFitted: () => setFitting(false),
71
+ onMove: (next, center) => {
72
+ setArea(next);
73
+ onMove?.(center);
74
+ },
46
75
  selected: selection?.keys,
47
76
  covered: selection ? covered : 0,
48
77
  onSelect: (keys) => setChosen({
@@ -50,9 +79,30 @@ const MapDisplay = ({ model, view, display, list, footer = true }) => {
50
79
  index: 0
51
80
  }),
52
81
  onClear: () => setChosen(void 0),
53
- markerLabel
82
+ markerLabel,
83
+ bucketLabel
84
+ })
85
+ }),
86
+ records.error && /* @__PURE__ */ jsx("div", {
87
+ className: "absolute inset-x-3 top-3 z-10",
88
+ children: /* @__PURE__ */ jsx(ErrorBanner, {
89
+ title: "Could not load rows",
90
+ error: records.error
54
91
  })
55
- }), selection && row && /* @__PURE__ */ jsx("div", {
92
+ }),
93
+ empty && /* @__PURE__ */ jsx("div", {
94
+ className: "pointer-events-none absolute inset-x-3 top-3 z-10 flex justify-center",
95
+ children: /* @__PURE__ */ jsxs("p", {
96
+ className: "pointer-events-auto flex items-center gap-3 rounded-full border border-border bg-background px-4 py-1.5 text-[13px] text-muted-foreground shadow",
97
+ role: "status",
98
+ children: [fitting ? `No ${plural} match.` : `No ${plural} in this part of the map.`, !fitting && /* @__PURE__ */ jsx(Button, {
99
+ size: "sm",
100
+ onClick: () => setFitting(true),
101
+ children: "Show all"
102
+ })]
103
+ })
104
+ }),
105
+ selection && row && /* @__PURE__ */ jsx("div", {
56
106
  ref: overlay,
57
107
  className: "pointer-events-none absolute inset-x-3 bottom-3 z-10 flex justify-center",
58
108
  children: /* @__PURE__ */ jsxs("div", {
@@ -111,10 +161,20 @@ const MapDisplay = ({ model, view, display, list, footer = true }) => {
111
161
  })
112
162
  })]
113
163
  })
114
- })]
115
- }), footer && /* @__PURE__ */ jsx(DisplayFooter, { list })]
116
- })
164
+ })
165
+ ]
166
+ }), footer && /* @__PURE__ */ jsx("p", {
167
+ className: "py-3 text-xs text-muted-foreground",
168
+ children: inView(records.data, plural)
169
+ })]
117
170
  });
118
171
  };
172
+ /** How many of the matching records are in view, and how many the map shows when it cannot show them all. */
173
+ const inView = (data, plural) => {
174
+ if (!data) return "\xA0";
175
+ const total = data.total.toLocaleString("en-IE");
176
+ if (data.buckets.length === 0 && data.rows.length < data.total) return `${data.rows.length.toLocaleString("en-IE")} of ${total} ${plural} in view`;
177
+ return `${total} ${plural} in view`;
178
+ };
119
179
  //#endregion
120
180
  export { MapDisplay };
@@ -8,13 +8,6 @@ const mapPoints = (rows, fields) => rows.flatMap((row) => {
8
8
  ...position
9
9
  }] : [];
10
10
  });
11
- /** The south-west and north-east corners around the points, as `[longitude, latitude]`, or none without points. */
12
- const pointBounds = (points) => {
13
- if (points.length === 0) return void 0;
14
- const longitudes = points.map((point) => point.longitude);
15
- const latitudes = points.map((point) => point.latitude);
16
- return [[Math.min(...longitudes), Math.min(...latitudes)], [Math.max(...longitudes), Math.max(...latitudes)]];
17
- };
18
11
  /**
19
12
  * The markers for the points as the map draws them: in order, each point joins the first marker within `radius` pixels
20
13
  * of it, else starts its own. A marker is at its first point, so markers do not move as others join them.
@@ -52,4 +45,4 @@ const stepSelection = (selection, step) => ({
52
45
  index: ((selection.index + step) % selection.keys.length + selection.keys.length) % selection.keys.length
53
46
  });
54
47
  //#endregion
55
- export { groupPoints, keptSelection, mapPoints, pointBounds, stepSelection };
48
+ export { groupPoints, keptSelection, mapPoints, stepSelection };
@@ -1,10 +1,11 @@
1
1
  import { Badge } from "../../primitives/badge.js";
2
2
  import { humanize } from "../../live/naming.js";
3
3
  import { useProjectUi } from "../pages/project-ui.js";
4
- import { fileShown, isPreviewable } from "../../live/file-values.js";
4
+ import { shownImage } from "../../live/file-values.js";
5
5
  import { displayValue } from "../../live/display-value.js";
6
6
  import { amountParts, cardFields, filledAmount, filledMeta } from "./list-displays.js";
7
7
  import { AppLink } from "../use-app-address.js";
8
+ import { FileImage } from "../../live/file-image.js";
8
9
  import { shownWidget } from "../field-types/shown-widget.js";
9
10
  import { CustomBoundary } from "../pages/custom-boundary.js";
10
11
  import { WidgetPart } from "../field-types/widget-part.js";
@@ -14,13 +15,14 @@ import { CardCarousel } from "./card-carousel.js";
14
15
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
15
16
  //#region src/app/displays/record-card.tsx
16
17
  const CardImage = ({ value }) => {
17
- const file = fileShown(value);
18
- const url = file && "url" in file && typeof file.url === "string" && isPreviewable(file.type) ? file.url : void 0;
18
+ const image = shownImage(value, {
19
+ width: 320,
20
+ height: 240
21
+ });
19
22
  return /* @__PURE__ */ jsx("span", {
20
23
  className: "-mx-3 -mt-3 mb-1.5 block aspect-[4/3] overflow-hidden rounded-t-lg border-b border-border bg-muted",
21
- children: url && /* @__PURE__ */ jsx("img", {
22
- src: url,
23
- alt: "",
24
+ children: image && /* @__PURE__ */ jsx(FileImage, {
25
+ image,
24
26
  loading: "lazy",
25
27
  className: "size-full object-cover"
26
28
  })
@@ -0,0 +1,44 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { withLabels } from "../../live/relation-labels.js";
4
+ import { displayRow } from "./list-displays.js";
5
+ import { useAdminMeta } from "../meta-gate.js";
6
+ import { areaFilter, requestZoom } from "../map/map-area.js";
7
+ import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
8
+ //#region src/app/displays/use-map-records.ts
9
+ /**
10
+ * The records in the map's area through the list API's `:map`, labelled for the card, or buckets of them when it holds
11
+ * many. A new area or filter keeps the previous answer until its own arrives, and an answer for an area the map has
12
+ * left is never shown.
13
+ */
14
+ const useMapRecords = ({ model, display, fields, filter, orderBy, area }) => {
15
+ const client = useClient();
16
+ const queryClient = useQueryClient();
17
+ const { resources, views } = useAdminMeta();
18
+ const position = {
19
+ latitude: display.latitude,
20
+ longitude: display.longitude
21
+ };
22
+ const inside = area ? areaFilter(filter, position, area.bounds) : "";
23
+ const zoom = area ? requestZoom(area.zoom) : 0;
24
+ return useQuery({
25
+ queryKey: keys.map(model.name, `${position.latitude},${position.longitude}`, zoom, inside, orderBy),
26
+ enabled: area !== void 0,
27
+ placeholderData: keepPreviousData,
28
+ queryFn: async () => {
29
+ const result = await client.map(model.name, {
30
+ ...position,
31
+ zoom,
32
+ filter: inside,
33
+ orderBy
34
+ });
35
+ return {
36
+ rows: (await withLabels(queryClient, client, resources, views, model, fields, result.items)).map((record) => displayRow(model, record)),
37
+ buckets: result.buckets,
38
+ total: result.totalSize
39
+ };
40
+ }
41
+ });
42
+ };
43
+ //#endregion
44
+ export { useMapRecords };
@@ -125,7 +125,9 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
125
125
  model,
126
126
  view,
127
127
  display: shown,
128
- list,
128
+ filter,
129
+ orderBy,
130
+ fields,
129
131
  footer: false
130
132
  }) : /* @__PURE__ */ jsx(CustomDisplay, {
131
133
  display: shown,
@@ -2,21 +2,27 @@ import { cn } from "../lib/cn.js";
2
2
  import { Button } from "../primitives/button.js";
3
3
  import { useClient } from "../data/api-provider.js";
4
4
  import { humanize } from "../live/naming.js";
5
- import { acceptOf, fileShown, formatSize, isPreviewable } from "../live/file-values.js";
5
+ import { acceptOf, fileShown, formatSize, isPreviewable, shownImage } from "../live/file-values.js";
6
6
  import { ErrorBanner } from "./error-banner.js";
7
+ import { FileImage } from "../live/file-image.js";
7
8
  import { useRef, useState } from "react";
8
9
  import { FileText, Paperclip, Upload } from "lucide-react";
9
10
  import { jsx, jsxs } from "react/jsx-runtime";
10
11
  import { useMutation } from "@tanstack/react-query";
11
12
  //#region src/app/file-field.tsx
12
- const Thumbnail = ({ url, type }) => url && isPreviewable(type) ? /* @__PURE__ */ jsx("img", {
13
- src: url,
14
- alt: "",
15
- className: "size-10 shrink-0 rounded border border-border object-cover"
16
- }) : /* @__PURE__ */ jsx("span", {
17
- className: "flex size-10 shrink-0 items-center justify-center rounded border border-border bg-muted text-muted-foreground",
18
- children: /* @__PURE__ */ jsx(FileText, { className: "size-4" })
19
- });
13
+ const Thumbnail = ({ value }) => {
14
+ const image = shownImage(value, {
15
+ width: 40,
16
+ height: 40
17
+ });
18
+ return image ? /* @__PURE__ */ jsx(FileImage, {
19
+ image,
20
+ className: "size-10 shrink-0 rounded border border-border object-cover"
21
+ }) : /* @__PURE__ */ jsx("span", {
22
+ className: "flex size-10 shrink-0 items-center justify-center rounded border border-border bg-muted text-muted-foreground",
23
+ children: /* @__PURE__ */ jsx(FileText, { className: "size-4" })
24
+ });
25
+ };
20
26
  /** The file as it is: thumbnail or icon, name (a link while it has a URL), type and size, and what the server corrected. */
21
27
  const FileSummary = ({ value }) => {
22
28
  const shown = fileShown(value);
@@ -32,10 +38,7 @@ const FileSummary = ({ value }) => {
32
38
  const corrected = "corrected" in shown ? shown.corrected : void 0;
33
39
  return /* @__PURE__ */ jsxs("div", {
34
40
  className: "flex min-w-0 items-center gap-3 py-1",
35
- children: [/* @__PURE__ */ jsx(Thumbnail, {
36
- url,
37
- type: shown.type
38
- }), /* @__PURE__ */ jsxs("div", {
41
+ children: [/* @__PURE__ */ jsx(Thumbnail, { value }), /* @__PURE__ */ jsxs("div", {
39
42
  className: "min-w-0 text-[13px]",
40
43
  children: [url && !url.startsWith("blob:") ? /* @__PURE__ */ jsx("a", {
41
44
  href: url,
@@ -99,7 +99,7 @@ const LoadedList = ({ model, address }) => {
99
99
  orderBy
100
100
  });
101
101
  const display = shownDisplay(view, address.display);
102
- const displayRows = useDisplayRows(model, rows.page, rows.key, display !== void 0 && display.kind !== "cards");
102
+ const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
103
103
  const scroll = useListScroll();
104
104
  const titleField = recordTitleField(model, view);
105
105
  const target = (row) => ({
@@ -273,7 +273,11 @@ const LoadedList = ({ model, address }) => {
273
273
  model,
274
274
  view,
275
275
  display,
276
- list: displayRows
276
+ filter: parsed.ok ? filterText : "",
277
+ orderBy,
278
+ fields: loaded,
279
+ at: address.map,
280
+ onMove: (map) => set({ map })
277
281
  });
278
282
  return /* @__PURE__ */ jsx(CustomDisplay, {
279
283
  display,
@@ -0,0 +1,53 @@
1
+ import { createWhere, printFilter } from "@protobase/schema";
2
+ //#region src/app/map/map-area.ts
3
+ const where = createWhere();
4
+ /** The whole world from as far out as a map goes: what a map asks for before it knows where its records are. */
5
+ const worldArea = {
6
+ bounds: {
7
+ south: -90,
8
+ west: -180,
9
+ north: 90,
10
+ east: 180
11
+ },
12
+ zoom: 0
13
+ };
14
+ const maxZoom = 22;
15
+ const down = (degrees) => Math.floor(degrees * 1e6) / 1e6;
16
+ const up = (degrees) => Math.ceil(degrees * 1e6) / 1e6;
17
+ const wrapped = (longitude) => ((longitude + 180) % 360 + 360) % 360 - 180;
18
+ /**
19
+ * The AIP-160 filter for the records inside `bounds`: between its latitudes, and between its longitudes on the globe.
20
+ * A view across the antimeridian takes the longitudes on both sides of it, and one around the whole world takes them all.
21
+ */
22
+ const boundsFilter = (fields, bounds) => {
23
+ const latitude = [where.gte(fields.latitude, down(Math.max(bounds.south, -90))), where.lte(fields.latitude, up(Math.min(bounds.north, 90)))];
24
+ if (bounds.east - bounds.west >= 360) return where.and(...latitude);
25
+ const west = where.gte(fields.longitude, down(wrapped(bounds.west)));
26
+ const east = where.lte(fields.longitude, up(wrapped(bounds.east)));
27
+ return wrapped(bounds.west) <= wrapped(bounds.east) ? where.and(...latitude, west, east) : where.and(...latitude, where.or(west, east));
28
+ };
29
+ /** The list's filter, its search included, narrowed to the records inside the map's bounds. */
30
+ const areaFilter = (listFilter, fields, bounds) => {
31
+ const inside = printFilter(boundsFilter(fields, bounds));
32
+ return listFilter.trim() === "" ? inside : `(${listFilter}) AND ${inside}`;
33
+ };
34
+ /** The zoom the records are asked for at: the whole zoom level the map is at. */
35
+ const requestZoom = (zoom) => Math.min(Math.max(Math.floor(zoom), 0), maxZoom);
36
+ /** The bounds around the records and the buckets, or none without either. */
37
+ const boundsAround = (points, buckets) => {
38
+ const corners = [...points.map((point) => ({
39
+ south: point.latitude,
40
+ west: point.longitude,
41
+ north: point.latitude,
42
+ east: point.longitude
43
+ })), ...buckets.map((bucket) => bucket.bounds)];
44
+ if (corners.length === 0) return void 0;
45
+ return {
46
+ south: Math.min(...corners.map((corner) => corner.south)),
47
+ west: Math.min(...corners.map((corner) => corner.west)),
48
+ north: Math.max(...corners.map((corner) => corner.north)),
49
+ east: Math.max(...corners.map((corner) => corner.east))
50
+ };
51
+ };
52
+ //#endregion
53
+ export { areaFilter, boundsAround, boundsFilter, requestZoom, worldArea };