@protobase/ui 0.12.22 → 0.13.3

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 (63) 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-layout.js +19 -0
  7. package/dist/app/displays/card-photos.js +6 -3
  8. package/dist/app/displays/cards-display.js +16 -11
  9. package/dist/app/displays/list-cards.js +50 -0
  10. package/dist/app/displays/list-displays.js +4 -3
  11. package/dist/app/displays/map-display.js +83 -23
  12. package/dist/app/displays/map-groups.js +1 -8
  13. package/dist/app/displays/record-card.js +8 -6
  14. package/dist/app/displays/use-map-records.js +44 -0
  15. package/dist/app/displays/virtual-cards.js +138 -0
  16. package/dist/app/embedded-list/embedded-columns.js +52 -0
  17. package/dist/app/embedded-list/embedded-display.js +26 -0
  18. package/dist/app/embedded-list/embedded-list.js +157 -0
  19. package/dist/app/embedded-list/use-embedded-display.js +23 -0
  20. package/dist/app/embedded-list/use-embedded-rows.js +33 -0
  21. package/dist/app/file-field.js +16 -13
  22. package/dist/app/list-page.js +19 -6
  23. package/dist/app/list-scroll.js +25 -0
  24. package/dist/app/map/map-area.js +53 -0
  25. package/dist/app/map/records-map.js +91 -38
  26. package/dist/app/pages/blocks/table.js +21 -41
  27. package/dist/app/pages/field-value.js +22 -9
  28. package/dist/app/record-page.js +2 -1
  29. package/dist/app/related-section.js +30 -52
  30. package/dist/app/router.js +1 -0
  31. package/dist/app/use-record-editor.js +2 -1
  32. package/dist/data/api-provider.d.ts +2 -0
  33. package/dist/data/query-keys.js +9 -0
  34. package/dist/data/use-update-record.js +1 -0
  35. package/dist/data-grid/cells.js +8 -5
  36. package/dist/data-grid/data-grid.js +19 -39
  37. package/dist/data-grid/geometry.js +7 -9
  38. package/dist/data-grid/use-grid-scroll.js +8 -1
  39. package/dist/data-grid/use-virtual-rows.js +31 -0
  40. package/dist/live/columns-from-model.js +6 -4
  41. package/dist/live/file-image.js +35 -0
  42. package/dist/live/file-values.js +17 -1
  43. package/dist/live/grid-loader.js +26 -10
  44. package/dist/package.js +1 -1
  45. package/dist/record-view/section.js +24 -17
  46. package/dist/shell/assets/app-CfuMqqAY.js +66 -0
  47. package/dist/shell/assets/index-CYWfU_c1.css +2 -0
  48. package/dist/shell/assets/{index-B8MhIaLr.js → index-DCTyUhH-.js} +3 -3
  49. package/dist/shell/assets/maplibre-gl-DP9hPAtI.js +1 -0
  50. package/dist/shell/assets/record-map-Dgn4pFlE.js +814 -0
  51. package/dist/shell/assets/records-map-3O6u05bx.js +1 -0
  52. package/dist/shell/assets/shared-protobase-ui-C84DU2o6.js +1 -0
  53. package/dist/shell/index.html +5 -5
  54. package/dist/shell/protobase.shell.json +1 -1
  55. package/package.json +5 -5
  56. package/dist/app/pages/record-table.js +0 -114
  57. package/dist/app/pages/use-relation-labels.js +0 -30
  58. package/dist/shell/assets/app-BYw-8qpE.js +0 -66
  59. package/dist/shell/assets/index-CdSIk0BQ.css +0 -2
  60. package/dist/shell/assets/navigation-control-CGS0pamh.js +0 -814
  61. package/dist/shell/assets/record-map-9Y3IL_dg.js +0 -1
  62. package/dist/shell/assets/records-map-DXIWowJ3.js +0 -1
  63. package/dist/shell/assets/shared-protobase-ui-Bol6wL_S.js +0 -1
@@ -0,0 +1,52 @@
1
+ import { humanize } from "../../live/naming.js";
2
+ import { displayRow } from "../displays/list-displays.js";
3
+ import { columnsFromModel } from "../../live/columns-from-model.js";
4
+ import { withFieldTypeCells } from "../field-types/field-type-cells.js";
5
+ import { scopeOf } from "../pages/use-layout-data.js";
6
+ import { FieldValue } from "../pages/field-value.js";
7
+ import { usePathValue } from "../pages/use-path-value.js";
8
+ import { jsx } from "react/jsx-runtime";
9
+ import { resolveFieldPath } from "@protobase/layout";
10
+ //#region src/app/embedded-list/embedded-columns.tsx
11
+ const isPath = (column) => column.includes(".");
12
+ /** A value through relations (`unit_id.property_id`), fetched like the record page fetches it and cached. */
13
+ const PathCell = ({ model, row, column }) => {
14
+ const value = usePathValue(scopeOf(model, displayRow(model, row).record), column);
15
+ if (value.state === "loading") return /* @__PURE__ */ jsx("span", {
16
+ className: "text-muted-foreground",
17
+ children: "…"
18
+ });
19
+ if (value.state === "missing") return /* @__PURE__ */ jsx("span", {
20
+ className: "text-muted-foreground",
21
+ children: "—"
22
+ });
23
+ return /* @__PURE__ */ jsx(FieldValue, {
24
+ model: value.model,
25
+ field: value.field,
26
+ value: value.value
27
+ });
28
+ };
29
+ /** A column through relations, headed by the label of the field it ends at. */
30
+ const pathColumn = (model, column, { resources, views }) => {
31
+ const resolved = resolveFieldPath(resources, model.name, column);
32
+ const last = resolved.ok ? resolved.steps.at(-1) : void 0;
33
+ const name = last?.field.name ?? column;
34
+ return {
35
+ id: column,
36
+ header: views[last?.model.name ?? model.name]?.fields[name]?.label ?? humanize(name),
37
+ kind: "text",
38
+ width: 200,
39
+ render: (row) => /* @__PURE__ */ jsx(PathCell, {
40
+ model,
41
+ row,
42
+ column
43
+ })
44
+ };
45
+ };
46
+ /** The list page's columns for `names`: fields of `model` as its table draws them, and paths through relations. */
47
+ const embeddedColumns = (model, view, names, meta) => {
48
+ const fields = new Map(withFieldTypeCells(columnsFromModel(model, view, [], names.filter((name) => !isPath(name))), model, view, meta.fieldTypes).map((spec) => [spec.id, spec]));
49
+ return names.flatMap((name) => isPath(name) ? [pathColumn(model, name, meta)] : fields.get(name) ?? []);
50
+ };
51
+ //#endregion
52
+ export { embeddedColumns };
@@ -0,0 +1,26 @@
1
+ import { guarded } from "../../lib/guarded.js";
2
+ import { defaultDisplay, displayChoices } from "../displays/list-displays.js";
3
+ import { changedList, listDefaults } from "../current-address.js";
4
+ //#region src/app/embedded-list/embedded-display.ts
5
+ const prefix = "protobase:embedded-display:";
6
+ /** Where a viewer's pick of display is kept for one embedded list, named by the place it is shown. */
7
+ const embeddedDisplayKey = (place) => `${prefix}${place}`;
8
+ const readPickedDisplay = (storage, key) => guarded(() => storage.getItem(key) ?? void 0, void 0);
9
+ const writePickedDisplay = (storage, key, display) => guarded(() => storage.setItem(key, display), void 0);
10
+ /**
11
+ * The display an embedded list shows: the viewer's pick while the view still has it, else what the list opens in for
12
+ * the screen, with the display the page names in place of the view's default (below `sm`, a cards display when the
13
+ * view has one).
14
+ */
15
+ const embeddedDisplay = (view, xs, { picked, preferred }) => {
16
+ const names = displayChoices(view).map((choice) => choice.name);
17
+ if (picked !== void 0 && names.includes(picked)) return picked;
18
+ return defaultDisplay(view, xs, preferred !== void 0 && names.includes(preferred) ? preferred : void 0);
19
+ };
20
+ /** The resource's list with the embedded list's filter, order and display, leaving out what the list does by default. */
21
+ const viewAllAddress = (model, view, xs, list) => changedList({
22
+ kind: "list",
23
+ resource: model.name
24
+ }, list, listDefaults(model, view, xs));
25
+ //#endregion
26
+ export { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, viewAllAddress, writePickedDisplay };
@@ -0,0 +1,157 @@
1
+ import { useIsDesktop, useIsXs } from "../../lib/use-media-query.js";
2
+ import { Skeleton } from "../../primitives/skeleton.js";
3
+ import { recordKey } from "../../live/model-helpers.js";
4
+ import { humanize } from "../../live/naming.js";
5
+ import { useProjectUi } from "../pages/project-ui.js";
6
+ import { displayRow, loadedFields, shownDisplay } from "../displays/list-displays.js";
7
+ import { defaultOrderBy, parseSort, sortToOrderBy } from "../list-order.js";
8
+ import { useAdminMeta } from "../meta-gate.js";
9
+ import { AppLink, useAppAddress } from "../use-app-address.js";
10
+ import { ErrorBanner } from "../error-banner.js";
11
+ import { DataGrid } from "../../data-grid/data-grid.js";
12
+ import { listColumnNames } from "../../live/columns-from-model.js";
13
+ import { MapDisplay } from "../displays/map-display.js";
14
+ import { CustomDisplay } from "../displays/custom-display.js";
15
+ import { DisplaySwitch } from "../displays/display-controls.js";
16
+ import { CardsDisplay } from "../displays/cards-display.js";
17
+ import { embeddedColumns } from "./embedded-columns.js";
18
+ import { viewAllAddress } from "./embedded-display.js";
19
+ import { useEmbeddedRows } from "./use-embedded-rows.js";
20
+ import { useMemo, useState } from "react";
21
+ import { ArrowRight } from "lucide-react";
22
+ import { jsx, jsxs } from "react/jsx-runtime";
23
+ //#region src/app/embedded-list/embedded-list.tsx
24
+ /** The switch between the table and the view's displays, for an embedded list's heading; nothing with only a table. */
25
+ const EmbeddedDisplaySwitch = ({ view, value, onChange }) => /* @__PURE__ */ jsx(DisplaySwitch, {
26
+ view,
27
+ value,
28
+ onChange,
29
+ menu: !useIsDesktop()
30
+ });
31
+ const Loading = () => /* @__PURE__ */ jsx("div", {
32
+ className: "flex flex-col gap-2",
33
+ children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
34
+ });
35
+ /**
36
+ * The first `limit` records of a list, drawn by the list page's own table (sized to its rows) or display, and a link
37
+ * to the whole list with the same filter, order and display. On a phone a cards display is one row that scrolls sideways.
38
+ */
39
+ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty }) => {
40
+ const { resources, views } = useAdminMeta();
41
+ const { fields: fieldTypes } = useProjectUi();
42
+ const { go } = useAppAddress();
43
+ const xs = useIsXs();
44
+ const [orderBy, setOrderBy] = useState(sort || defaultOrderBy(model, view));
45
+ const names = columns ?? listColumnNames(model, view);
46
+ const specs = useMemo(() => embeddedColumns(model, view, names, {
47
+ resources,
48
+ views,
49
+ fieldTypes
50
+ }), [
51
+ model,
52
+ view,
53
+ names.join(","),
54
+ resources,
55
+ views,
56
+ fieldTypes
57
+ ]);
58
+ const fields = useMemo(() => loadedFields(model, view, specs.map((spec) => spec.id)), [
59
+ model,
60
+ view,
61
+ specs
62
+ ]);
63
+ const rows = useEmbeddedRows({
64
+ model,
65
+ filter,
66
+ orderBy,
67
+ limit,
68
+ fields
69
+ });
70
+ const loaded = rows.data;
71
+ const source = useMemo(() => loaded && {
72
+ total: loaded.length,
73
+ rowAt: (index) => loaded[index]
74
+ }, [loaded]);
75
+ const shown = shownDisplay(view, display);
76
+ if (rows.isPending) return /* @__PURE__ */ jsx(Loading, {});
77
+ if (rows.error) return /* @__PURE__ */ jsx(ErrorBanner, {
78
+ title: "Could not load",
79
+ error: rows.error
80
+ });
81
+ if (!source || source.total === 0) return /* @__PURE__ */ jsx("p", {
82
+ className: "text-[13px] text-muted-foreground",
83
+ children: empty ?? "Nothing here yet."
84
+ });
85
+ const list = {
86
+ resource: model.name,
87
+ rows: rows.data.map((row) => displayRow(model, row)),
88
+ total: rows.data.length,
89
+ loading: false,
90
+ error: null,
91
+ more: false,
92
+ loadMore: () => void 0
93
+ };
94
+ const openRecord = (row) => go({
95
+ kind: "record",
96
+ resource: model.name,
97
+ key: recordKey(model, row)
98
+ });
99
+ const singular = view?.names?.singular ?? humanize(model.name);
100
+ const plural = view?.names?.plural ?? humanize(model.name);
101
+ return /* @__PURE__ */ jsxs("div", {
102
+ className: "flex flex-col gap-3",
103
+ children: [!shown ? /* @__PURE__ */ jsx(DataGrid, {
104
+ fit: true,
105
+ columns: specs,
106
+ source,
107
+ latencyMs: 0,
108
+ sort: parseSort(orderBy),
109
+ onSortChange: (next) => setOrderBy(sortToOrderBy(next)),
110
+ onOpenRow: openRecord,
111
+ rowActions: [{
112
+ label: `Open ${singular.toLowerCase()}`,
113
+ onSelect: openRecord
114
+ }],
115
+ rowNumbers: false,
116
+ selectable: false
117
+ }) : shown.kind === "cards" ? /* @__PURE__ */ jsx(CardsDisplay, {
118
+ model,
119
+ view,
120
+ display: shown,
121
+ list,
122
+ row: xs,
123
+ footer: false
124
+ }) : shown.kind === "map" ? /* @__PURE__ */ jsx(MapDisplay, {
125
+ model,
126
+ view,
127
+ display: shown,
128
+ filter,
129
+ orderBy,
130
+ fields,
131
+ footer: false
132
+ }) : /* @__PURE__ */ jsx(CustomDisplay, {
133
+ display: shown,
134
+ list
135
+ }), /* @__PURE__ */ jsxs(AppLink, {
136
+ to: viewAllAddress(model, view, xs, {
137
+ filter,
138
+ orderBy,
139
+ display
140
+ }),
141
+ className: "flex items-center gap-1 self-end text-[13px] font-medium text-primary-text hover:underline",
142
+ children: [
143
+ "View all ",
144
+ /* @__PURE__ */ jsx("span", {
145
+ className: "sr-only",
146
+ children: plural.toLowerCase()
147
+ }),
148
+ /* @__PURE__ */ jsx(ArrowRight, {
149
+ className: "size-3.5",
150
+ "aria-hidden": true
151
+ })
152
+ ]
153
+ })]
154
+ });
155
+ };
156
+ //#endregion
157
+ export { EmbeddedDisplaySwitch, EmbeddedList };
@@ -0,0 +1,23 @@
1
+ import { useIsXs } from "../../lib/use-media-query.js";
2
+ import { embeddedDisplay, embeddedDisplayKey, readPickedDisplay, writePickedDisplay } from "./embedded-display.js";
3
+ import { useCallback, useState } from "react";
4
+ //#region src/app/embedded-list/use-embedded-display.ts
5
+ /**
6
+ * The display an embedded list shows and the way to pick another, which is kept in local storage for the viewer under
7
+ * `place`, the list's place in the app.
8
+ */
9
+ const useEmbeddedDisplay = (view, place, preferred) => {
10
+ const xs = useIsXs();
11
+ const key = embeddedDisplayKey(place);
12
+ const [picked, setPicked] = useState(() => readPickedDisplay(localStorage, key));
13
+ const pick = useCallback((display) => {
14
+ setPicked(display);
15
+ writePickedDisplay(localStorage, key, display);
16
+ }, [key]);
17
+ return [embeddedDisplay(view, xs, {
18
+ picked,
19
+ preferred
20
+ }), pick];
21
+ };
22
+ //#endregion
23
+ export { useEmbeddedDisplay };
@@ -0,0 +1,33 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { recordId } from "../../live/model-helpers.js";
4
+ import { withLabels } from "../../live/relation-labels.js";
5
+ import { useAdminMeta } from "../meta-gate.js";
6
+ import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
7
+ //#region src/app/embedded-list/use-embedded-rows.ts
8
+ /**
9
+ * The first `limit` records in `orderBy` order, as the list page's table and displays get them: relations among
10
+ * `fields` as `{ id, name }`. A filter through a join resource may hold hundreds of keys, so it goes in a JSON body.
11
+ */
12
+ const useEmbeddedRows = ({ model, filter, orderBy, limit, fields }) => {
13
+ const client = useClient();
14
+ const queryClient = useQueryClient();
15
+ const { resources, views } = useAdminMeta();
16
+ return useQuery({
17
+ queryKey: [...keys.list(model.name, filter, orderBy, limit, "embedded"), fields.join(",")],
18
+ placeholderData: keepPreviousData,
19
+ queryFn: async () => {
20
+ const page = await client.search(model.name, {
21
+ filter: filter || void 0,
22
+ orderBy: orderBy || void 0,
23
+ pageSize: limit
24
+ });
25
+ return (await withLabels(queryClient, client, resources, views, model, fields, page.items)).map((row, index) => ({
26
+ ...row,
27
+ id: recordId(model, page.items[index])
28
+ }));
29
+ }
30
+ });
31
+ };
32
+ //#endregion
33
+ export { useEmbeddedRows };
@@ -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,
@@ -26,11 +26,12 @@ import { useLiveRows } from "../live/use-live-rows.js";
26
26
  import { Sheet } from "../primitives/sheet.js";
27
27
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
28
28
  import { useRestore } from "./delete/use-restore.js";
29
- import { CardsDisplay } from "./displays/cards-display.js";
29
+ import { ListCards } from "./displays/list-cards.js";
30
30
  import { MapDisplay } from "./displays/map-display.js";
31
31
  import { CustomDisplay } from "./displays/custom-display.js";
32
32
  import { DisplaySwitch, SortMenu } from "./displays/display-controls.js";
33
33
  import { useDisplayRows } from "./displays/use-display-rows.js";
34
+ import { useListScroll } from "./list-scroll.js";
34
35
  import { ListChart } from "./list-chart.js";
35
36
  import { ListSearch } from "./list-search.js";
36
37
  import { withFieldTypeCells } from "./field-types/field-type-cells.js";
@@ -98,7 +99,8 @@ const LoadedList = ({ model, address }) => {
98
99
  orderBy
99
100
  });
100
101
  const display = shownDisplay(view, address.display);
101
- const displayRows = useDisplayRows(model, rows.page, rows.key, display !== void 0);
102
+ const displayRows = useDisplayRows(model, rows.page, rows.key, display?.kind === "custom");
103
+ const scroll = useListScroll();
102
104
  const titleField = recordTitleField(model, view);
103
105
  const target = (row) => ({
104
106
  key: row.id,
@@ -234,6 +236,8 @@ const LoadedList = ({ model, address }) => {
234
236
  sort: parseSort(orderBy),
235
237
  onSortChange: (sort) => set({ orderBy: sortToOrderBy(sort) }),
236
238
  onOpenRow: openRecord,
239
+ initialScrollIndex: scroll.initialIndex,
240
+ onScrollIndex: scroll.remember,
237
241
  rowActions: [{
238
242
  label: `Open ${singular.toLowerCase()}`,
239
243
  onSelect: openRecord
@@ -252,19 +256,28 @@ const LoadedList = ({ model, address }) => {
252
256
  pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
253
257
  className: "min-h-0 flex-1"
254
258
  }) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
259
+ const cardsList = {
260
+ ...rows,
261
+ initialIndex: scroll.initialIndex,
262
+ onScrollIndex: scroll.remember
263
+ };
255
264
  const drawDisplay = () => {
256
265
  if (!display) return table;
257
- if (display.kind === "cards") return /* @__PURE__ */ jsx(CardsDisplay, {
266
+ if (display.kind === "cards") return /* @__PURE__ */ jsx(ListCards, {
258
267
  model,
259
268
  view,
260
269
  display,
261
- list: displayRows
262
- });
270
+ list: cardsList
271
+ }, rows.key.join(" "));
263
272
  if (display.kind === "map") return /* @__PURE__ */ jsx(MapDisplay, {
264
273
  model,
265
274
  view,
266
275
  display,
267
- list: displayRows
276
+ filter: parsed.ok ? filterText : "",
277
+ orderBy,
278
+ fields: loaded,
279
+ at: address.map,
280
+ onMove: (map) => set({ map })
268
281
  });
269
282
  return /* @__PURE__ */ jsx(CustomDisplay, {
270
283
  display,
@@ -0,0 +1,25 @@
1
+ import { useRouter } from "./router.js";
2
+ import { useCallback } from "react";
3
+ //#region src/app/list-scroll.ts
4
+ /** The first record in view of the list each history entry last showed, for this tab; a page load starts empty. */
5
+ const remembered = /* @__PURE__ */ new Map();
6
+ /**
7
+ * Where a list starts and how it remembers where it is, so going back to it shows the same records. Only the list the
8
+ * history entry showed last comes back: after a change of sort or filter, which replaces the entry, the list starts at
9
+ * the top again.
10
+ */
11
+ const useListScroll = () => {
12
+ const { entry, path, params } = useRouter();
13
+ const url = `${path}?${params.toString()}`;
14
+ const last = remembered.get(entry);
15
+ const remember = useCallback((index) => void remembered.set(entry, {
16
+ url,
17
+ index
18
+ }), [entry, url]);
19
+ return {
20
+ initialIndex: last?.url === url ? last.index : 0,
21
+ remember
22
+ };
23
+ };
24
+ //#endregion
25
+ export { useListScroll };
@@ -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 };
@@ -1,5 +1,5 @@
1
1
  import { cn } from "../../lib/cn.js";
2
- import { groupPoints, pointBounds } from "../displays/map-groups.js";
2
+ import { groupPoints } from "../displays/map-groups.js";
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import { MapPin } from "lucide-react";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -12,18 +12,29 @@ const fitOptions = {
12
12
  maxZoom: 15,
13
13
  duration: 0
14
14
  };
15
+ const bucketOptions = {
16
+ padding: 48,
17
+ maxZoom: 16,
18
+ duration: 300
19
+ };
15
20
  const markerHeight = 32;
21
+ const compact = new Intl.NumberFormat("en-IE", {
22
+ notation: "compact",
23
+ maximumFractionDigits: 1
24
+ });
25
+ const corners = (bounds) => [[bounds.west, bounds.south], [bounds.east, bounds.north]];
16
26
  /**
17
27
  * A map of `style` with a marker for each group of records that lie close together at the zoom, showing how many it
18
- * stands for. It fits the records when they change. Choosing a marker selects its records; choosing the map elsewhere
19
- * clears the selection. A chosen marker the covered bottom would hide moves up into the open part of the map.
28
+ * stands for, and a circle with the count of each bucket, which zooms in on its records when chosen. Choosing a marker
29
+ * selects its records; choosing the map elsewhere clears the selection. A chosen marker the covered bottom would hide
30
+ * moves up into the open part of the map.
20
31
  */
21
- const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, markerLabel }) => {
32
+ const RecordsMap = ({ style, initial, points, buckets, fit, onFitted, onMove, selected, covered, onSelect, onClear, markerLabel, bucketLabel }) => {
22
33
  const ref = useRef(null);
23
34
  const [groups, setGroups] = useState([]);
24
35
  const byKey = new globalThis.Map(points.map((point) => [point.key, point]));
25
36
  const pointKeys = points.map((point) => point.key).join(",");
26
- const bounds = pointBounds(points);
37
+ const fitKey = fit && JSON.stringify(fit);
27
38
  const regroup = () => {
28
39
  const map = ref.current?.getMap();
29
40
  if (!map) return;
@@ -32,12 +43,34 @@ const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, marke
32
43
  ...map.project([point.longitude, point.latitude])
33
44
  })), groupRadius));
34
45
  };
35
- useEffect(() => {
46
+ const report = () => {
36
47
  const map = ref.current?.getMap();
37
48
  if (!map) return;
38
- if (bounds) map.fitBounds(bounds, fitOptions);
39
- regroup();
40
- }, [pointKeys]);
49
+ const bounds = map.getBounds();
50
+ const center = map.getCenter();
51
+ onMove({
52
+ bounds: {
53
+ south: bounds.getSouth(),
54
+ west: bounds.getWest(),
55
+ north: bounds.getNorth(),
56
+ east: bounds.getEast()
57
+ },
58
+ zoom: map.getZoom()
59
+ }, {
60
+ latitude: center.lat,
61
+ longitude: center.lng,
62
+ zoom: map.getZoom()
63
+ });
64
+ };
65
+ const fitNow = () => {
66
+ const map = ref.current?.getMap();
67
+ if (!map || !fit) return;
68
+ map.fitBounds(corners(fit), fitOptions);
69
+ onFitted();
70
+ report();
71
+ };
72
+ useEffect(regroup, [pointKeys]);
73
+ useEffect(fitNow, [fitKey]);
41
74
  const chosenAt = groups.find((group) => selected !== void 0 && group.keys.some((key) => selected.includes(key)))?.at;
42
75
  useEffect(() => {
43
76
  const map = ref.current?.getMap();
@@ -56,10 +89,7 @@ const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, marke
56
89
  ref,
57
90
  mapLib: maplibre,
58
91
  mapStyle: style,
59
- initialViewState: bounds ? {
60
- bounds,
61
- fitBoundsOptions: fitOptions
62
- } : {
92
+ initialViewState: initial ?? {
63
93
  latitude: 0,
64
94
  longitude: 0,
65
95
  zoom: 1
@@ -68,38 +98,61 @@ const RecordsMap = ({ style, points, selected, covered, onSelect, onClear, marke
68
98
  width: "100%",
69
99
  height: "100%"
70
100
  },
71
- onLoad: regroup,
101
+ onLoad: () => {
102
+ regroup();
103
+ if (fit) fitNow();
104
+ else report();
105
+ },
72
106
  onZoomEnd: regroup,
107
+ onMoveEnd: report,
73
108
  onClick: onClear,
74
- children: [/* @__PURE__ */ jsx(NavigationControl, { showCompass: false }), groups.map((group) => {
75
- const at = byKey.get(group.at);
76
- if (!at) return null;
77
- const chosen = selected !== void 0 && group.keys.some((key) => selected.includes(key));
78
- return /* @__PURE__ */ jsx(Marker, {
79
- latitude: at.latitude,
80
- longitude: at.longitude,
81
- anchor: "bottom",
82
- style: { zIndex: chosen ? 1 : 0 },
83
- children: /* @__PURE__ */ jsxs("button", {
109
+ children: [
110
+ /* @__PURE__ */ jsx(NavigationControl, { showCompass: false }),
111
+ buckets.map((bucket) => /* @__PURE__ */ jsx(Marker, {
112
+ latitude: bucket.latitude,
113
+ longitude: bucket.longitude,
114
+ anchor: "center",
115
+ children: /* @__PURE__ */ jsx("button", {
84
116
  type: "button",
85
- "aria-label": markerLabel(group.keys),
86
- "aria-pressed": chosen,
87
- className: "relative block cursor-pointer",
117
+ "aria-label": bucketLabel(bucket.count),
118
+ className: "flex size-10 cursor-pointer items-center justify-center rounded-full border-2 border-background bg-primary text-xs font-semibold text-primary-foreground tabular-nums shadow",
88
119
  onClick: (event) => {
89
120
  event.stopPropagation();
90
- onSelect(group.keys);
121
+ ref.current?.getMap().fitBounds(corners(bucket.bounds), bucketOptions);
91
122
  },
92
- children: [/* @__PURE__ */ jsx(MapPin, {
93
- "aria-hidden": true,
94
- className: cn("size-8 drop-shadow", chosen ? "fill-foreground text-background" : "fill-primary text-primary-foreground"),
95
- strokeWidth: 1.5
96
- }), group.keys.length > 1 && /* @__PURE__ */ jsx("span", {
97
- className: "absolute -right-2 -top-1 min-w-5 rounded-full border border-background bg-foreground px-1 text-center text-[11px] font-medium leading-4 text-background tabular-nums",
98
- children: group.keys.length
99
- })]
123
+ children: compact.format(bucket.count)
100
124
  })
101
- }, group.at);
102
- })]
125
+ }, `${bucket.latitude},${bucket.longitude}`)),
126
+ groups.map((group) => {
127
+ const at = byKey.get(group.at);
128
+ if (!at) return null;
129
+ const chosen = selected !== void 0 && group.keys.some((key) => selected.includes(key));
130
+ return /* @__PURE__ */ jsx(Marker, {
131
+ latitude: at.latitude,
132
+ longitude: at.longitude,
133
+ anchor: "bottom",
134
+ style: { zIndex: chosen ? 1 : 0 },
135
+ children: /* @__PURE__ */ jsxs("button", {
136
+ type: "button",
137
+ "aria-label": markerLabel(group.keys),
138
+ "aria-pressed": chosen,
139
+ className: "relative block cursor-pointer",
140
+ onClick: (event) => {
141
+ event.stopPropagation();
142
+ onSelect(group.keys);
143
+ },
144
+ children: [/* @__PURE__ */ jsx(MapPin, {
145
+ "aria-hidden": true,
146
+ className: cn("size-8 drop-shadow", chosen ? "fill-foreground text-background" : "fill-primary text-primary-foreground"),
147
+ strokeWidth: 1.5
148
+ }), group.keys.length > 1 && /* @__PURE__ */ jsx("span", {
149
+ className: "absolute -right-2 -top-1 min-w-5 rounded-full border border-background bg-foreground px-1 text-center text-[11px] font-medium leading-4 text-background tabular-nums",
150
+ children: group.keys.length
151
+ })]
152
+ })
153
+ }, group.at);
154
+ })
155
+ ]
103
156
  });
104
157
  };
105
158
  //#endregion