feat(bookmarks): save bookmarks in the project (#3006)

* feat(bookmarks): save bookmarks in the project

Bookmarks lived only in the browser's localStorage, so they did not
travel with a .geolibre.json and the Python package and MCP server
could not author them.

- Project format: optional `bookmarks` and `bookmarkGroups` (folders),
  normalized on load (camera range checks, duplicate ids, bearing wrap,
  dangling folder ids dropped) and omitted when empty.
- Store: `bookmarks`, `bookmarkGroups` and `setBookmarks`; saved,
  autosaved and shared in collaboration like other project sections.
- Bookmarks panel: no longer persists to localStorage. It opens with
  the project's bookmarks, writes every edit (including host-dialog
  imports) back to the project, and rebuilds when a project load or a
  collaborator replaces them. Bookmarks saved by older versions move
  into the first project opened that has none, once.
- Python `Map.add_bookmark` / `remove_bookmark` and MCP tools
  `add_bookmark` / `remove_bookmark` (camera from the saved view by
  default, folder by id or name, layers to show), plus bookmarks in
  `describe_project`. The Python round-trip fixture now carries
  bookmarks through the app's parseProject.
- Docs: project format, Python, MCP, user guide, and the agent skill.

Part of #2869

* Address review feedback

- Trim a bookmark's groupId before matching it to the (trimmed) folder
  ids, so a hand-edited id with spaces is not silently un-filed
  (Claude review).
- Legacy migration no longer deletes the old localStorage bookmarks:
  a marker key records the one-time copy, so bookmarks copied into the
  wrong project stay recoverable from browser storage (Claude review).
- Python add_bookmark refuses a 501st bookmark before changing the
  project, matching the app's MAX_PROJECT_BOOKMARKS (500), with a drift
  test that pins the two together (CodeRabbit).

* Address review feedback

- Write the legacy-bookmark migration marker only once the project that
  received them is saved; a project discarded or replaced unsaved leaves
  it unset so the next empty project gets the copy (CodeRabbit).
- Correct the user guide: the old browser copy is left in place but no
  longer updated, not kept as a backup (Claude).
- Move the bookmarkGroups normalization above the print-layout comment so
  the comment stays attached to its code (Claude).
This commit is contained in:
Qiusheng Wu authored and GitHub committed 2026-10-06 18:20:03 -04:00
1 parent 56e5834576
commit 49f083b334
23 files changed
+1337 -11

No files matched your search

@@ -1083,6 +1083,8 @@ export function useProjectFileActions(mapControllerRef: MapControllerRef) {
models: state.models,
processingHistory: state.processingHistory,
widgets: state.widgets,
bookmarks: state.bookmarks,
bookmarkGroups: state.bookmarkGroups,
dashboardColumns: state.dashboardColumns,
mapLayout: state.mapLayout,
secondaryMapViews: state.secondaryMapViews,
@@ -55,6 +55,8 @@ export function buildProjectSnapshot(
models: state.models,
processingHistory: state.processingHistory,
widgets: state.widgets,
bookmarks: state.bookmarks,
bookmarkGroups: state.bookmarkGroups,
dashboardColumns: state.dashboardColumns,
mapLayout: state.mapLayout,
secondaryMapViews: state.secondaryMapViews,
@@ -16,7 +16,7 @@ type AppState = ReturnType<typeof useAppStore.getState>;
* (viewport rectangles + opt-in follow-host) instead.
*
* The compared fields must stay aligned with `buildProjectSnapshot` (minus
* camera): models, processing history, dashboard widgets, map grid, and the
* camera): models, processing history, dashboard widgets, bookmarks, map grid, and the
* project style library are part of the snapshot payload and must trigger a
* broadcast when they change on their own. Plugin activation/settings are
* participant-local: plugin-created layer records trigger their own broadcast,
@@ -42,6 +42,8 @@ export function projectChanged(a: AppState, b: AppState): boolean {
a.models !== b.models ||
a.processingHistory !== b.processingHistory ||
a.widgets !== b.widgets ||
a.bookmarks !== b.bookmarks ||
a.bookmarkGroups !== b.bookmarkGroups ||
a.dashboardColumns !== b.dashboardColumns ||
a.mapLayout !== b.mapLayout ||
a.secondaryMapViews !== b.secondaryMapViews ||
+2 -3
View File
@@ -154,14 +154,13 @@ what `describe_project` showed it without tracking UUIDs.
| `set_story_map` | Set a story map's title block, theme, markers, inset, and start/end slides. |
| `add_story_chapter` | Add a chapter with its camera, text, image, alignment, animation, and layer fades. A camera value left out comes from the project's saved view. |
| `move_story_chapter` / `remove_story_chapter` | Reorder or drop a chapter by id, title, or index. |
| `add_bookmark` | Add a saved view to the Bookmarks panel, optionally in a folder (a new folder name creates it) and with the layers to show when it is opened. A camera value left out comes from the project's saved view. |
| `remove_bookmark` | Drop a bookmark by id, name, or index. |
The shapes match what the app reads (`parseProject` in `@geolibre/core`); a
round-trip test loads a project these tools wrote through it and checks nothing
is dropped or rewritten.
Bookmarks have no tool: the app keeps them in browser storage, not in the
project file, so there is nothing for a project tool to write. Story chapters
are the saved, shareable equivalent.
### Live desktop map
+34
View File
@@ -40,6 +40,8 @@ file contents do not change.
| `storymap` | object | Optional scroll-driven story map (chapters and presentation settings); omitted when there are no chapters |
| `widgets` | array | Optional Dashboard panel chart widgets (see below); omitted when there are none |
| `dashboardColumns`| number | Optional Dashboard widget-grid column count (1-6, default 2); omitted when default |
| `bookmarks` | array | Optional saved map views from the Bookmarks panel (see below); omitted when there are none |
| `bookmarkGroups` | array | Optional folders the bookmarks are organized into; omitted when there are none |
| `styleLibrary` | array | Optional project-scoped Style Manager entries (name, tags, kind, `LayerStyle` subset); omitted when empty |
| `primaryRenderer` | string | Optional engine for the primary map area: `"maplibre"` (2D, the default), `"mapbox"` (Mapbox GL JS), `"arcgis"` (ArcGIS Maps SDK for JavaScript) or `"cesium"` (3D globe); omitted when default |
| `interaction` | object | Optional startup Identify target and control visibility (see below); omitted by default |
@@ -207,6 +209,38 @@ widget-grid column count (1-6, default 2), at the top level of the project.
Charts read from GeoJSON-backed vector layers and DuckDB query layers; widgets
bound to a missing or non-attribute layer are shown as empty.
## Bookmarks
```json
{
"bookmarkGroups": [{ "id": "hoods", "name": "Neighborhoods", "collapsed": false }],
"bookmarks": [
{
"id": "downtown",
"name": "Downtown",
"lng": -83.92,
"lat": 35.96,
"zoom": 14,
"pitch": 30,
"bearing": 340,
"createdAt": 1700000000000,
"groupId": "hoods",
"extra": { "visibleLayerIds": ["cities"] }
}
]
}
```
Saved views from the Bookmarks panel (Controls → Bookmark), in panel order.
`lng`/`lat` (-180..180 / -90..90), `zoom` (0-24), `pitch` (0-85) and `bearing`
(wrapped into 0-360) are the camera; `createdAt` is milliseconds since the
epoch. `groupId` files a bookmark in one of `bookmarkGroups` (folders do not
nest); a `groupId` that names no folder is dropped on load. `extra` is state
captured with the view: `visibleLayerIds` lists the layers to show when the
bookmark is opened, and the others are hidden. Bookmarks with a missing or
out-of-range camera, or a duplicate `id`, are dropped on load. Before these
fields existed, bookmarks lived only in the browser's local storage.
## Interaction
```json
+2
View File
@@ -337,6 +337,8 @@ m.on_layer_change(lambda e: print("layers", e["layerIds"]))
| `set_story_map(**settings)` | Set the [story map](project-format.md#story-map)'s `title`, `subtitle`, `byline`, `footer`, `theme`, `show_markers`, `marker_color`, `inset`, `inset_position`, `hide_chapter_nav`, `start_slide`, `end_slide`. |
| `add_story_chapter(title, description=, center=, zoom=, pitch=, bearing=, image=, alignment=, hidden=, map_animation=, rotate_animation=, on_enter=, on_exit=, index=)` | Add a story chapter; camera values left out come from the saved view. `on_enter`/`on_exit` fade layers: `[{"layer": "Cities", "opacity": 1, "duration": 800}]`. |
| `move_story_chapter(chapter, index)` / `remove_story_chapter(chapter)` | Reorder or drop a chapter by id, title, or 0-based index. |
| `add_bookmark(name, center=, zoom=, pitch=, bearing=, folder=, visible_layers=)` | Save a view to the Bookmarks panel; camera values left out come from the saved view. `folder` is a folder id or name (a new name creates it); `visible_layers` lists the layers (ids, names, or handles) to show when it is opened. |
| `remove_bookmark(bookmark)` | Drop a bookmark by id, name, or 0-based index. |
| `set_identify(layer="all")` | Arm the Identify tool so a click opens the popup: on one layer (id, name, or handle), on a list of layers, on every visible layer (`"all"`), or off (`None`). |
| `show_control(name, visible=True)` / `hide_control(name)` | Show or hide a toolbar panel (`bookmark`, `search`, `measure`, `minimap`, `print`) or a built-in map control (`navigation`, `fullscreen`, `compass`, `geolocate`, `globe`, `scale`, `attribution`, `logo`). |
| `set_projection(projection)` / `projection` | Draw the map as a `"globe"` (the default) or flat `"mercator"` map; saved in the project. |
+1 -1
View File
@@ -32,7 +32,7 @@ These are interactive panels provided by the MapLibre components plugin:
| **Legend** | Show a legend describing the layers on the map. |
| **HTML** | Display custom HTML content in an on-map panel. |
| **Measure** | Measure distances and areas interactively, with heading and terrain-aware 3D readouts. See [Measuring distance, area, and heading](#measuring-distance-area-and-heading). |
| **Bookmark** | Save named map views and jump back to them. |
| **Bookmark** | Save named map views and jump back to them. Bookmarks and their folders are saved in the project, so they travel with the file (bookmarks saved by older versions in this browser are copied into the first project you open that has none and saved there; the old browser copy is left in place but no longer updated). |
| **Minimap** | Show an overview map of the current extent. |
| **View State** | Read and edit the exact center, zoom, bearing, and pitch. |
+111
View File
@@ -16,6 +16,8 @@ import {
MIN_DASHBOARD_COLUMNS,
PROJECT_VERSION,
type DashboardWidget,
type ProjectBookmark,
type ProjectBookmarkGroup,
type DashboardWidgetAggregation,
type DashboardWidgetType,
type GeoLibreLayer,
@@ -456,6 +458,7 @@ export function parseProject(json: string): GeoLibreProject {
models: normalizeModels(data.models) ?? undefined,
processingHistory: normalizeProcessingHistory(data.processingHistory) ?? undefined,
widgets: normalizeWidgets(data.widgets) ?? undefined,
...bookmarkFields(data.bookmarks, data.bookmarkGroups),
...(data.dashboardColumns === undefined
? {}
: { dashboardColumns: normalizeDashboardColumns(data.dashboardColumns) }),
@@ -1215,6 +1218,106 @@ const INDICATOR_AGGREGATIONS: readonly IndicatorAggregation[] = [
"median",
];
/** Bookmarks kept per project, as the panel's own default limit allows. */
export const MAX_PROJECT_BOOKMARKS = 500;
/**
* Coerce an untrusted `bookmarkGroups` array into valid folders: drops entries
* without a string id and de-duplicates by id. A missing name becomes
* "Folder"; `collapsed` defaults to false.
*
* @param value Raw `bookmarkGroups` value from the project JSON.
* @returns The folders (possibly empty).
*/
export function normalizeBookmarkGroups(value: unknown): ProjectBookmarkGroup[] {
if (!Array.isArray(value)) return [];
const groups: ProjectBookmarkGroup[] = [];
const seen = new Set<string>();
for (const raw of value) {
if (!raw || typeof raw !== "object") continue;
const entry = raw as Record<string, unknown>;
const id = typeof entry.id === "string" ? entry.id.trim() : "";
if (!id || seen.has(id)) continue;
seen.add(id);
groups.push({
id,
name: typeof entry.name === "string" && entry.name.trim() ? entry.name : "Folder",
collapsed: entry.collapsed === true,
});
}
return groups;
}
/**
* Coerce an untrusted `bookmarks` array into valid saved views: drops entries
* without a string id or a finite, in-range camera, de-duplicates by id, wraps
* the bearing into 0-360, and drops a `groupId` that names no folder in
* `groups`. Keeps at most {@link MAX_PROJECT_BOOKMARKS}.
*
* @param value Raw `bookmarks` value from the project JSON.
* @param groups The project's (normalized) folders.
* @returns The bookmarks (possibly empty).
*/
export function normalizeBookmarks(
value: unknown,
groups: readonly ProjectBookmarkGroup[] = [],
): ProjectBookmark[] {
if (!Array.isArray(value)) return [];
const groupIds = new Set(groups.map((group) => group.id));
const bookmarks: ProjectBookmark[] = [];
const seen = new Set<string>();
const finite = (input: unknown): number | null =>
typeof input === "number" && Number.isFinite(input) ? input : null;
for (const raw of value) {
if (bookmarks.length >= MAX_PROJECT_BOOKMARKS) break;
if (!raw || typeof raw !== "object") continue;
const entry = raw as Record<string, unknown>;
const id = typeof entry.id === "string" ? entry.id.trim() : "";
const lng = finite(entry.lng);
const lat = finite(entry.lat);
const zoom = finite(entry.zoom);
if (!id || seen.has(id) || lng === null || lat === null || zoom === null) continue;
if (lng < -180 || lng > 180 || lat < -90 || lat > 90 || zoom < 0 || zoom > 24) continue;
seen.add(id);
const pitch = Math.min(85, Math.max(0, finite(entry.pitch) ?? 0));
const bearing = (((finite(entry.bearing) ?? 0) % 360) + 360) % 360;
const bookmark: ProjectBookmark = {
id,
name: typeof entry.name === "string" ? entry.name : "",
lng,
lat,
zoom,
pitch,
bearing,
createdAt: finite(entry.createdAt) ?? 0,
};
// Folder ids are trimmed, so compare a trimmed groupId with them.
const groupId = typeof entry.groupId === "string" ? entry.groupId.trim() : "";
if (groupId && groupIds.has(groupId)) bookmark.groupId = groupId;
if (entry.extra && typeof entry.extra === "object" && !Array.isArray(entry.extra)) {
bookmark.extra = entry.extra as Record<string, unknown>;
}
bookmarks.push(bookmark);
}
return bookmarks;
}
/**
* The `bookmarks`/`bookmarkGroups` project fields for normalized input, each
* omitted when empty so a bookmark-less project stays free of the keys.
*/
function bookmarkFields(
rawBookmarks: unknown,
rawGroups: unknown,
): { bookmarks?: ProjectBookmark[]; bookmarkGroups?: ProjectBookmarkGroup[] } {
const bookmarkGroups = normalizeBookmarkGroups(rawGroups);
const bookmarks = normalizeBookmarks(rawBookmarks, bookmarkGroups);
return {
...(bookmarks.length > 0 ? { bookmarks } : {}),
...(bookmarkGroups.length > 0 ? { bookmarkGroups } : {}),
};
}
/**
* Coerce an untrusted (possibly hand-edited) `widgets` array into valid
* {@link DashboardWidget} records. Drops widgets without a usable id, layer id,
@@ -1850,6 +1953,8 @@ export function projectFromStore(state: {
models?: ProcessingModel[] | null;
processingHistory?: ProcessingRun[] | null;
widgets?: DashboardWidget[] | null;
bookmarks?: ProjectBookmark[] | null;
bookmarkGroups?: ProjectBookmarkGroup[] | null;
dashboardColumns?: number;
mapLayout?: MapGridLayout;
secondaryMapViews?: SecondaryMapView[];
@@ -1924,6 +2029,7 @@ export function projectFromStore(state: {
...(models ? { models } : {}),
...(processingHistory ? { processingHistory } : {}),
...(widgets ? { widgets } : {}),
...bookmarkFields(state.bookmarks, state.bookmarkGroups),
...(dashboardColumns !== DEFAULT_DASHBOARD_COLUMNS ? { dashboardColumns } : {}),
...(persistGrid
? {
@@ -2239,6 +2345,8 @@ export function applyProjectToStore(project: GeoLibreProject): {
models: ProcessingModel[];
processingHistory: ProcessingRun[];
widgets: DashboardWidget[];
bookmarks: ProjectBookmark[];
bookmarkGroups: ProjectBookmarkGroup[];
dashboardColumns: number;
mapLayout: MapGridLayout;
secondaryMapViews: SecondaryMapView[];
@@ -2311,6 +2419,7 @@ export function applyProjectToStore(project: GeoLibreProject): {
orphanIds.size > 0 ? scrubCommentsForRemovedLayers(comments, orphanIds) : comments;
const scrubbedLegend =
orphanIds.size > 0 ? scrubLegendForRemovedLayers(legend, orphanIds) : legend;
const bookmarkGroups = normalizeBookmarkGroups(project.bookmarkGroups);
// The composer's data/atlas blocks name a layer directly rather than through
// `allReferencedIds`, so they are scrubbed against the surviving layer set.
const printLayout = scrubPrintLayoutForLayers(
@@ -2335,6 +2444,8 @@ export function applyProjectToStore(project: GeoLibreProject): {
models: normalizeModels(project.models) ?? [],
processingHistory: normalizeProcessingHistory(project.processingHistory) ?? [],
widgets: scrubbedWidgets,
bookmarkGroups,
bookmarks: normalizeBookmarks(project.bookmarks, bookmarkGroups),
dashboardColumns: normalizeDashboardColumns(project.dashboardColumns),
mapLayout,
secondaryMapViews,
@@ -10,6 +10,8 @@ import {
MIN_DASHBOARD_COLUMNS,
type CommentReply,
type DashboardWidget,
type ProjectBookmark,
type ProjectBookmarkGroup,
type ProjectComment,
type ProjectInteraction,
type StoryChapter,
@@ -23,6 +25,10 @@ export interface ProjectContentSlice {
widgets: DashboardWidget[];
/** Number of columns in the Dashboard widget grid. */
dashboardColumns: number;
/** Saved map views from the Bookmarks panel, in panel order (#2869). */
bookmarks: ProjectBookmark[];
/** Folders the bookmarks are organized into. */
bookmarkGroups: ProjectBookmarkGroup[];
/** Anchored review comments on map points or features (issue #1518). */
comments: ProjectComment[];
/**
@@ -60,15 +66,24 @@ export interface ProjectContentSlice {
toggleResolveComment: (commentId: string, resolved?: boolean) => void;
deleteComment: (commentId: string) => void;
setComments: (comments: ProjectComment[]) => void;
/**
* Replace every bookmark and folder (the Bookmarks panel writes its whole
* state back after each edit). Marks the project dirty.
*/
setBookmarks: (bookmarks: ProjectBookmark[], bookmarkGroups: ProjectBookmarkGroup[]) => void;
}
export const createProjectContentSlice: SliceCreator<ProjectContentSlice> = (set) => ({
storymap: null,
widgets: [],
dashboardColumns: DEFAULT_DASHBOARD_COLUMNS,
bookmarks: [],
bookmarkGroups: [],
comments: [],
projectInteraction: null,
setBookmarks: (bookmarks, bookmarkGroups) => set({ bookmarks, bookmarkGroups, isDirty: true }),
addComment: (comment) =>
set((s) => {
// Ignore a duplicate id: the WebSocket relay echoes our own
+43
View File
@@ -2243,6 +2243,42 @@ export type DashboardWidgetAggregation = "count" | "sum" | "mean";
* keys are simply ignored, so the record stays flat and easy to hand-edit.
* Saved in the project file so a dashboard reopens with its widgets intact.
*/
/**
* A saved map view in the Bookmarks panel. Mirrors `MapBookmark` from
* maplibre-gl-components so the panel can load it unchanged.
*/
export interface ProjectBookmark {
/** Unique within the project. */
id: string;
name: string;
/** Camera center longitude, -180..180. */
lng: number;
/** Camera center latitude, -90..90. */
lat: number;
zoom: number;
/** Camera tilt in degrees, 0..85. */
pitch: number;
/** Camera rotation in degrees. */
bearing: number;
/** Creation time, milliseconds since the epoch. */
createdAt: number;
/** The {@link ProjectBookmarkGroup} it belongs to; absent when ungrouped. */
groupId?: string;
/**
* Host state captured with the view. GeoLibre stores `visibleLayerIds`
* (the layers to show when the bookmark is opened).
*/
extra?: Record<string, unknown>;
}
/** A folder in the Bookmarks panel. Folders do not nest. */
export interface ProjectBookmarkGroup {
id: string;
name: string;
/** Whether the folder is collapsed in the panel. */
collapsed: boolean;
}
export interface DashboardWidget {
/** Stable id, unique within the project (React key and store key). */
id: string;
@@ -2429,6 +2465,13 @@ export interface GeoLibreProject {
processingHistory?: ProcessingRun[];
/** Saved Dashboard panel chart widgets (issue #401). */
widgets?: DashboardWidget[];
/**
* Saved map views (the Bookmarks panel), in panel order. Omitted when there
* are none. Before #2869 these lived only in the browser's localStorage.
*/
bookmarks?: ProjectBookmark[];
/** Folders the {@link bookmarks} are organized into. Omitted when none. */
bookmarkGroups?: ProjectBookmarkGroup[];
/** Number of columns in the Dashboard widget grid; omitted when default. */
dashboardColumns?: number;
/**
@@ -1,12 +1,20 @@
// The standalone Bookmark panel.
// Split out of maplibre-components.ts (opengeos/GeoLibre#2633).
import { useAppStore } from "@geolibre/core";
import {
normalizeBookmarkGroups,
normalizeBookmarks,
useAppStore,
type ProjectBookmark,
type ProjectBookmarkGroup,
} from "@geolibre/core";
import type {
BookmarkControl,
BookmarkControlOptions,
BookmarkEvent,
BookmarkExportMode,
MapBookmark,
MapBookmarkGroup,
} from "maplibre-gl-components";
import type { GeoLibreAppAPI, GeoLibreMapControlPosition } from "../../types";
import { type BookmarkControlConstructor, getComponentsConstructors } from "./constructors";
@@ -90,7 +98,10 @@ const BOOKMARK_OPTIONS = {
maxHeight: 520,
panelWidth: 280,
position: bookmarkControlPosition,
storageKey: "geolibre-bookmarks",
// Bookmarks are saved in the project (#2869), not in this browser's
// localStorage: an empty key turns the control's own persistence off, and
// createBookmarkControl seeds it from the store instead.
storageKey: "",
// Resizable panel and drag reordering are on by default upstream; enable
// per-bookmark export selection and visible-layer capture here. Labels and
// the capture tooltip are applied per-instance in createBookmarkControl so
@@ -109,7 +120,38 @@ const BOOKMARK_OPTIONS = {
restoreState: restoreVisibleLayers,
} satisfies BookmarkControlOptions;
/**
* Where bookmarks lived before they moved into the project. Read once, to
* migrate them into the first project that has none of its own.
*/
export const LEGACY_BOOKMARK_STORAGE_KEY = "geolibre-bookmarks";
/**
* Set once the legacy bookmarks were copied into a project and it was saved.
* The legacy data
* itself is left in place, so bookmarks moved into the wrong project can still
* be recovered from this browser's storage.
*/
export const LEGACY_BOOKMARKS_MIGRATED_KEY = "geolibre-bookmarks-migrated";
/** Control events after which its bookmarks or folders may have changed. */
const BOOKMARK_CHANGE_EVENTS: readonly BookmarkEvent[] = [
"add",
"remove",
"rename",
"clear",
"import",
"reorder",
"group-add",
"group-remove",
"group-rename",
"group-move",
];
let bookmarkControl: BookmarkControl | null = null;
/** The store arrays the open control last matched, to tell its own writes apart. */
let syncedBookmarks: ProjectBookmark[] | null = null;
let syncedGroups: ProjectBookmarkGroup[] | null = null;
let unsubscribeStore: (() => void) | null = null;
let bookmarkControlMounted = false;
let bookmarkPanelVisible = false;
const bookmarkPanelListeners = new Set<() => void>();
@@ -135,6 +177,7 @@ export function subscribeBookmarkPanel(listener: () => void): () => void {
async function openStandaloneBookmarkControl(app: GeoLibreAppAPI): Promise<boolean> {
const { BookmarkControl: BookmarkControlClass } = await getComponentsConstructors();
migrateLegacyBookmarks();
bookmarkControl ??= createBookmarkControl(BookmarkControlClass, app);
if (!bookmarkControlMounted) {
@@ -159,8 +202,12 @@ function createBookmarkControl(
BookmarkControlClass: BookmarkControlConstructor,
app: GeoLibreAppAPI,
): BookmarkControl {
const { bookmarks, bookmarkGroups } = useAppStore.getState();
const control = new BookmarkControlClass({
...BOOKMARK_OPTIONS,
// The control mutates these arrays in place, so give it copies.
bookmarks: copyBookmarks(bookmarks) as MapBookmark[],
groups: copyGroups(bookmarkGroups),
captureStateLabel: bookmarkLabels.captureStateLabel,
captureStateTooltip: bookmarkLabels.captureStateTooltip,
exportLabel: bookmarkLabels.exportLabel,
@@ -170,9 +217,114 @@ function createBookmarkControl(
defaultFolderName: bookmarkLabels.defaultFolderName,
});
routeBookmarkFileIoThroughHost(control, app);
syncedBookmarks = bookmarks;
syncedGroups = bookmarkGroups;
// Write the panel's state back to the project after every edit.
for (const event of BOOKMARK_CHANGE_EVENTS) control.on(event, () => syncControlToStore(control));
// Bookmarks replaced from elsewhere (a project load, a collaborator, a
// script) rebuild the panel from the store, keeping it open.
unsubscribeStore?.();
unsubscribeStore = useAppStore.subscribe((state) => {
if (control !== bookmarkControl) return;
if (state.bookmarks === syncedBookmarks && state.bookmarkGroups === syncedGroups) return;
const reopen = bookmarkPanelVisible;
teardownBookmarkControl(app);
if (reopen) void openStandaloneBookmarkControl(app);
});
return control;
}
/**
* Write the open control's bookmarks and folders to the project.
*
* @param control - The control whose state to save; ignored when stale.
*/
function syncControlToStore(control: BookmarkControl): void {
if (control !== bookmarkControl) return;
const next = copyBookmarks(control.getBookmarks());
const nextGroups = copyGroups(control.getGroups());
syncedBookmarks = next;
syncedGroups = nextGroups;
useAppStore.getState().setBookmarks(next, nextGroups);
}
/** Plain copies, detached from the control's internal state. */
function copyBookmarks(bookmarks: readonly (ProjectBookmark | MapBookmark)[]): ProjectBookmark[] {
return bookmarks.map((bookmark) => ({
...bookmark,
...(bookmark.extra ? { extra: { ...bookmark.extra } } : {}),
}));
}
function copyGroups(
groups: readonly (ProjectBookmarkGroup | MapBookmarkGroup)[],
): ProjectBookmarkGroup[] {
return groups.map((group) => ({ id: group.id, name: group.name, collapsed: group.collapsed }));
}
/**
* Copy bookmarks saved by older versions (in this browser's localStorage) into
* the open project, once, when the project has none of its own. A marker key,
* written when that project is saved, stops them being copied into every
* project opened afterwards; the legacy data stays, so nothing is lost if they landed in the
* wrong project.
*/
function migrateLegacyBookmarks(): void {
let raw: string | null = null;
try {
if (localStorage.getItem(LEGACY_BOOKMARKS_MIGRATED_KEY)) return;
raw = localStorage.getItem(LEGACY_BOOKMARK_STORAGE_KEY);
} catch {
return;
}
if (!raw) return;
const state = useAppStore.getState();
if (state.bookmarks.length > 0 || state.bookmarkGroups.length > 0) return;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
// The control wrote a bare array, or `{ bookmarks, groups }` with folders.
const envelope = Array.isArray(parsed)
? { bookmarks: parsed, groups: [] }
: (parsed as { bookmarks?: unknown; groups?: unknown } | null);
const groups = normalizeBookmarkGroups(envelope?.groups);
const bookmarks = normalizeBookmarks(envelope?.bookmarks, groups);
if (bookmarks.length === 0 && groups.length === 0) return;
state.setBookmarks(bookmarks, groups);
markMigratedOnSave(useAppStore.getState().projectGeneration);
}
let unsubscribeMigrationWatch: (() => void) | null = null;
/**
* Write the migration marker once the project that received the legacy
* bookmarks is saved. A project discarded or replaced before saving leaves the
* marker unset, so the next empty project gets the copy instead.
*
* @param generation - The `projectGeneration` of the project that received them.
*/
function markMigratedOnSave(generation: number): void {
unsubscribeMigrationWatch?.();
unsubscribeMigrationWatch = useAppStore.subscribe((state) => {
if (state.projectGeneration !== generation) {
unsubscribeMigrationWatch?.();
unsubscribeMigrationWatch = null;
return;
}
if (state.isDirty) return;
unsubscribeMigrationWatch?.();
unsubscribeMigrationWatch = null;
try {
localStorage.setItem(LEGACY_BOOKMARKS_MIGRATED_KEY, new Date().toISOString());
} catch {
// Without the marker the copy may be offered again; nothing is lost.
}
});
}
/**
* The BookmarkControl's built-in Import/Export use a Blob `<a download>` and a
* hidden `<input type="file">`, which do not work inside the Tauri WebView.
@@ -255,6 +407,8 @@ function routeBookmarkFileIoThroughHost(control: BookmarkControl, app: GeoLibreA
return;
}
io.importBookmarks(valid);
// The public import emits no event, so save the result explicitly.
syncControlToStore(control);
})
.catch((error) => {
console.warn("BookmarkControl: import failed", error);
@@ -266,6 +420,8 @@ export function teardownBookmarkControl(app: GeoLibreAppAPI): void {
if (bookmarkControl && bookmarkControlMounted) {
app.removeMapControl(bookmarkControl);
}
unsubscribeStore?.();
unsubscribeStore = null;
bookmarkControl = null;
bookmarkControlMounted = false;
setBookmarkPanelVisible(false);
+202
View File
@@ -317,6 +317,7 @@ def describe_project(project: dict[str, Any]) -> dict[str, Any]:
"layerCount": len(layers_of(project)),
"layers": [layer_summary(layer) for layer in layers_of(project) if isinstance(layer, dict)],
"mapControls": controls,
**({"bookmarks": bookmark_summary(project)} if project.get("bookmarks") else {}),
}
@@ -1986,3 +1987,204 @@ def move_story_chapter(project: dict[str, Any], ref: str | int, index: int) -> d
destination = max(0, min(len(story["chapters"]), int(index)))
story["chapters"].insert(destination, chapter)
return _story_summary(story)
# -- bookmarks ------------------------------------------------------------------
def _bookmark_lists(project: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Return the project's bookmark and folder lists, creating them if absent.
Args:
project: The project dict (mutated in place when a list is created).
Returns:
The live ``bookmarks`` and ``bookmarkGroups`` lists.
"""
lists = []
for key in ("bookmarks", "bookmarkGroups"):
value = project.get(key)
if not isinstance(value, list):
value = []
project[key] = value
lists.append(value)
return lists[0], lists[1]
def _drop_empty_bookmark_lists(project: dict[str, Any]) -> None:
"""Remove empty bookmark lists, as the app omits them when saving."""
for key in ("bookmarks", "bookmarkGroups"):
if project.get(key) == []:
del project[key]
def bookmark_summary(project: dict[str, Any]) -> list[dict[str, Any]]:
"""List the project's bookmarks with the name of the folder each is in.
Args:
project: The project dict.
Returns:
``{"id", "name", "folder"}`` entries in panel order.
"""
folders = {
folder.get("id"): folder.get("name")
for folder in project.get("bookmarkGroups") or []
if isinstance(folder, dict)
}
return [
{
"id": entry.get("id"),
"name": entry.get("name"),
"folder": folders.get(entry.get("groupId")),
}
for entry in project.get("bookmarks") or []
if isinstance(entry, dict)
]
def find_bookmark(project: dict[str, Any], ref: str | int) -> int:
"""Resolve a bookmark by id, name, or 0-based position.
Args:
project: The project dict.
ref: A bookmark id, a bookmark name (exact, then case-insensitive), or
an integer index.
Returns:
The bookmark's index in ``bookmarks``.
Raises:
ValueError: If nothing matches, or a name matches several bookmarks.
"""
entries = [entry for entry in project.get("bookmarks") or [] if isinstance(entry, dict)]
if isinstance(ref, int) and not isinstance(ref, bool):
if 0 <= ref < len(entries):
return ref
raise ValueError(f"bookmark index {ref} is out of range (0-{len(entries) - 1})")
text = str(ref)
for index, entry in enumerate(entries):
if entry.get("id") == text:
return index
for matcher in (lambda name: name == text, lambda name: name.lower() == text.lower()):
matches = [i for i, entry in enumerate(entries) if matcher(str(entry.get("name", "")))]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise ValueError(f"several bookmarks are named {text!r}; use the id")
raise ValueError(f"no bookmark matches {text!r}")
def add_bookmark(
project: dict[str, Any],
name: str,
*,
center: Iterable[float] | None = None,
zoom: float | None = None,
pitch: float | None = None,
bearing: float | None = None,
folder: str | None = None,
visible_layers: Iterable[str] | None = None,
bookmark_id: str | None = None,
created_at: int | None = None,
) -> dict[str, Any]:
"""Add a saved map view to the project's Bookmarks panel.
A camera value left out is taken from the project's saved view, the way
the panel's **Add** button captures the current map.
Args:
project: The project dict (mutated in place).
name: Bookmark name.
center: Camera target ``[lng, lat]``.
zoom: Camera zoom, 0-24.
pitch: Camera tilt in degrees, 0-85.
bearing: Camera rotation in degrees.
folder: A folder id or name to file it under; a new name creates the
folder. Ungrouped when omitted.
visible_layers: Layers (ids or names) to show when the bookmark is
opened; the others are hidden. Omit to leave visibility alone.
bookmark_id: Explicit bookmark id; a UUID by default.
created_at: Creation time in milliseconds since the epoch; now by
default.
Returns:
The bookmark that was added.
Raises:
ValueError: If a value is invalid, a layer does not resolve,
``bookmark_id`` is already used, or the project already holds
:data:`geolibre.project.MAX_BOOKMARKS` bookmarks.
"""
existing = [b for b in project.get("bookmarks") or [] if isinstance(b, dict)]
if len(existing) >= _project.MAX_BOOKMARKS:
raise ValueError(
f"the project already has {len(existing)} bookmarks, the most the app keeps "
f"({_project.MAX_BOOKMARKS}); remove one first"
)
view = project.get("mapView") if isinstance(project.get("mapView"), dict) else {}
existing_folders = [f for f in project.get("bookmarkGroups") or [] if isinstance(f, dict)]
new_folder: dict[str, Any] | None = None
group_id: str | None = None
if folder is not None:
match = next((f for f in existing_folders if folder in (f.get("id"), f.get("name"))), None)
if match is None:
new_folder = match = _project.bookmark_folder(str(folder))
group_id = str(match["id"])
# Validate everything before touching the project, so a refused bookmark
# leaves it unchanged.
entry = _project.bookmark(
name,
center=list(center) if center is not None else list(view.get("center") or [0, 0]),
zoom=zoom if zoom is not None else view.get("zoom", 2),
pitch=pitch if pitch is not None else view.get("pitch", 0),
bearing=bearing if bearing is not None else view.get("bearing", 0),
group_id=group_id,
visible_layer_ids=(
resolve_layer_ids(project, visible_layers) if visible_layers is not None else None
),
bookmark_id=bookmark_id,
created_at=created_at,
)
if any(
isinstance(b, dict) and b.get("id") == entry["id"] for b in project.get("bookmarks") or []
):
raise ValueError(f"a bookmark with id {entry['id']!r} already exists")
bookmarks, folders = _bookmark_lists(project)
if new_folder is not None:
folders.append(new_folder)
# The panel keeps a folder's bookmarks together: file a new member right
# after the folder's last one.
position = len(bookmarks)
if group_id is not None:
members = [
i
for i, b in enumerate(bookmarks)
if isinstance(b, dict) and b.get("groupId") == group_id
]
if members:
position = members[-1] + 1
bookmarks.insert(position, entry)
_drop_empty_bookmark_lists(project)
return copy.deepcopy(entry)
def remove_bookmark(project: dict[str, Any], ref: str | int) -> list[dict[str, Any]]:
"""Remove a bookmark by id, name, or index.
Args:
project: The project dict (mutated in place).
ref: A bookmark id, name, or 0-based index.
Returns:
The remaining bookmarks, as :func:`bookmark_summary` lists them.
Raises:
ValueError: If the reference does not resolve to exactly one bookmark.
"""
index = find_bookmark(project, ref)
bookmarks, _ = _bookmark_lists(project)
entries = [i for i, entry in enumerate(bookmarks) if isinstance(entry, dict)]
bookmarks.pop(entries[index])
_drop_empty_bookmark_lists(project)
return bookmark_summary(project)
+63
View File
@@ -1542,6 +1542,69 @@ class Map(anywidget.AnyWidget):
"""Move a story chapter (by id, title, or index) to a new position."""
self._update_project(lambda p: _authoring.move_story_chapter(p, chapter, index))
def add_bookmark(
self,
name: str,
*,
center: tuple[float, float] | None = None,
zoom: float | None = None,
pitch: float | None = None,
bearing: float | None = None,
folder: str | None = None,
visible_layers: list[str | Layer] | None = None,
) -> dict[str, Any]:
"""Add a saved map view to the Bookmarks panel (Controls -> Bookmarks).
Bookmarks are saved in the project, so they travel with the file. A
camera value left out is taken from the map's current saved view.
Args:
name: Bookmark name.
center: Camera target ``(lng, lat)``.
zoom: Camera zoom, 0-24.
pitch: Camera tilt in degrees, 0-85.
bearing: Camera rotation in degrees.
folder: A folder id or name to file it under; a new name creates
the folder.
visible_layers: Layers (ids, names, or handles) to show when the
bookmark is opened; the others are hidden. Omit to leave layer
visibility alone.
Returns:
The bookmark that was added, including its ``id``.
Example:
>>> m.add_bookmark("Downtown", center=(-83.92, 35.96), zoom=14,
... folder="Neighborhoods")
"""
layer_refs = (
[layer.id if isinstance(layer, Layer) else layer for layer in visible_layers]
if visible_layers is not None
else None
)
result: dict[str, Any] = {}
def _apply(project: dict[str, Any]) -> None:
result.update(
_authoring.add_bookmark(
project,
name,
center=center,
zoom=zoom,
pitch=pitch,
bearing=bearing,
folder=folder,
visible_layers=layer_refs,
)
)
self._update_project(_apply)
return result
def remove_bookmark(self, bookmark: str | int) -> None:
"""Remove a bookmark by id, name, or 0-based index."""
self._update_project(lambda p: _authoring.remove_bookmark(p, bookmark))
def rename_layer(self, layer: str | Layer, name: str) -> None:
"""Rename a layer addressed by id, name, or handle.
+58
View File
@@ -1828,6 +1828,64 @@ def build_server(workspace: Workspace) -> MCPServer:
story = authoring.move_story_chapter(project, chapter, index)
return _summarize(file, project, storymap=story)
@tool()
def add_bookmark(
path: str,
name: str,
center: list[float] | None = None,
zoom: float | None = None,
pitch: float | None = None,
bearing: float | None = None,
folder: str | None = None,
visible_layers: list[str] | None = None,
) -> dict[str, Any]:
"""Add a saved map view to the project's Bookmarks panel.
A camera value you omit is taken from the project's saved view.
Args:
path: Path to the `.geolibre.json` file.
name: Bookmark name.
center: Camera target `[lng, lat]`.
zoom: Camera zoom (0-24).
pitch: Camera tilt in degrees (0-85).
bearing: Camera rotation in degrees.
folder: A folder id or name to file it under; a new name creates
the folder.
visible_layers: Layer ids or names to show when the bookmark is
opened; the others are hidden. Omit to leave visibility alone.
Returns:
The bookmark that was added, including its id.
"""
with edit(path) as (file, project):
bookmark = authoring.add_bookmark(
project,
name,
center=center,
zoom=zoom,
pitch=pitch,
bearing=bearing,
folder=folder,
visible_layers=visible_layers,
)
return _summarize(file, project, bookmark=bookmark)
@tool()
def remove_bookmark(path: str, bookmark: str | int) -> dict[str, Any]:
"""Remove a bookmark.
Args:
path: Path to the `.geolibre.json` file.
bookmark: The bookmark's id, name, or 0-based index.
Returns:
The remaining bookmarks.
"""
with edit(path) as (file, project):
bookmarks = authoring.remove_bookmark(project, bookmark)
return _summarize(file, project, bookmarks=bookmarks)
# -- export ---------------------------------------------------------------
@tool()
+116
View File
@@ -15,6 +15,7 @@ import json
import math
import re
import socket
import time
import uuid
import warnings
import zlib
@@ -3811,3 +3812,118 @@ def story_chapter(
}
)
return chapter
#: Bookmarks the app keeps per project (``MAX_PROJECT_BOOKMARKS`` in project.ts);
#: any past this are dropped when the project is opened.
MAX_BOOKMARKS = 500
def bookmark_folder(
name: str, *, collapsed: bool = False, folder_id: str | None = None
) -> dict[str, Any]:
"""Build one Bookmarks panel folder (``ProjectBookmarkGroup`` in types.ts).
Args:
name: Folder name shown in the panel.
collapsed: Start with the folder collapsed.
folder_id: Explicit folder id; a UUID by default.
Returns:
A folder dict.
Raises:
ValueError: If ``name`` is blank or ``collapsed`` is not a bool.
"""
if not isinstance(name, str) or not name.strip():
raise ValueError(f"folder name must be a non-empty string, got {name!r}")
if not isinstance(collapsed, bool):
raise ValueError("collapsed must be true or false")
folder_id = str(folder_id).strip() if folder_id is not None else str(uuid.uuid4())
if not folder_id:
raise ValueError("folder_id must not be empty")
return {"id": folder_id, "name": name.strip(), "collapsed": collapsed}
def bookmark(
name: str,
*,
center: tuple[float, float] | list[float],
zoom: float,
pitch: float = 0,
bearing: float = 0,
group_id: str | None = None,
visible_layer_ids: list[str] | None = None,
bookmark_id: str | None = None,
created_at: int | None = None,
) -> dict[str, Any]:
"""Build one saved map view for the Bookmarks panel (``ProjectBookmark``).
Values are stored the way ``normalizeBookmarks`` in project.ts leaves them,
so the app loads the bookmark unchanged: the bearing is wrapped into 0-360,
and out-of-range coordinates, zooms, and pitches are refused rather than
silently clamped.
Args:
name: Bookmark name shown in the panel.
center: Camera target as ``(lng, lat)``.
zoom: Camera zoom, 0-24.
pitch: Camera tilt in degrees, 0-85.
bearing: Camera rotation in degrees.
group_id: Id of the folder it belongs to; ungrouped when omitted.
visible_layer_ids: Layer ids to show when the bookmark is opened (all
others are hidden). Omit to leave layer visibility alone.
bookmark_id: Explicit bookmark id; a UUID by default.
created_at: Creation time in milliseconds since the epoch; now by default.
Returns:
A bookmark dict.
Raises:
ValueError: If any value is out of range or of the wrong type.
"""
if not isinstance(name, str) or not name.strip():
raise ValueError(f"bookmark name must be a non-empty string, got {name!r}")
if not isinstance(center, (list, tuple)) or len(center) != 2:
raise ValueError(f"center must be (lng, lat), got {center!r}")
try:
lng, lat, zoom, pitch, bearing = (float(value) for value in (*center, zoom, pitch, bearing))
except (TypeError, ValueError) as exc:
raise ValueError(f"center, zoom, pitch and bearing must be numbers: {exc}") from exc
if not (math.isfinite(lng) and -180 <= lng <= 180):
raise ValueError(f"longitude must be between -180 and 180, got {center[0]!r}")
if not (math.isfinite(lat) and -90 <= lat <= 90):
raise ValueError(f"latitude must be between -90 and 90, got {center[1]!r}")
if not (math.isfinite(zoom) and 0 <= zoom <= 24):
raise ValueError(f"zoom must be between 0 and 24, got {zoom!r}")
if not (math.isfinite(pitch) and 0 <= pitch <= 85):
raise ValueError(f"pitch must be between 0 and 85, got {pitch!r}")
if not math.isfinite(bearing):
raise ValueError(f"bearing must be finite, got {bearing!r}")
bookmark_id = str(bookmark_id).strip() if bookmark_id is not None else str(uuid.uuid4())
if not bookmark_id:
raise ValueError("bookmark_id must not be empty")
if created_at is None:
created_at = int(time.time() * 1000)
elif not isinstance(created_at, int) or isinstance(created_at, bool) or created_at < 0:
raise ValueError(f"created_at must be a non-negative integer, got {created_at!r}")
entry: dict[str, Any] = {
"id": bookmark_id,
"name": name.strip(),
"lng": lng,
"lat": lat,
"zoom": zoom,
"pitch": pitch,
"bearing": bearing % 360,
"createdAt": created_at,
}
if group_id is not None:
entry["groupId"] = str(group_id)
if visible_layer_ids is not None:
if not isinstance(visible_layer_ids, (list, tuple)) or not all(
isinstance(layer_id, str) for layer_id in visible_layer_ids
):
raise ValueError("visible_layer_ids must be a list of layer id strings")
entry["extra"] = {"visibleLayerIds": list(visible_layer_ids)}
return entry
+117
View File
@@ -689,6 +689,20 @@ def build_round_trip_project() -> dict:
chapter_id="close-up",
on_exit=[{"id": "fade-out", "layer": "cities", "opacity": 0.25}],
)
p["bookmarkGroups"] = [project.bookmark_folder("Neighborhoods", folder_id="hoods")]
authoring.add_bookmark(
p,
"Downtown",
center=[-83.92, 35.96],
zoom=14,
pitch=30,
bearing=-20,
folder="hoods",
visible_layers=["Cities"],
bookmark_id="downtown",
created_at=1_700_000_000_000,
)
authoring.add_bookmark(p, "Region", zoom=6, bookmark_id="region", created_at=1_700_000_000_001)
return p
@@ -708,3 +722,106 @@ def test_round_trip_fixture_is_current():
FIXTURE.write_text(text, encoding="utf-8")
assert FIXTURE.is_file(), "run with GEOLIBRE_REGEN_FIXTURES=1 to create the fixture"
assert json.loads(FIXTURE.read_text(encoding="utf-8")) == built
# -- bookmarks ------------------------------------------------------------------
def test_add_bookmark_defaults_to_the_saved_view(proj):
proj["mapView"] = {"center": [-83.9, 35.9], "zoom": 9, "pitch": 10, "bearing": 350}
bookmark = authoring.add_bookmark(proj, "Home")
assert (bookmark["lng"], bookmark["lat"], bookmark["zoom"]) == (-83.9, 35.9, 9)
assert (bookmark["pitch"], bookmark["bearing"]) == (10, 350)
assert "groupId" not in bookmark and "extra" not in bookmark
assert proj["bookmarks"] == [bookmark]
# No folder was asked for, so the project carries no empty folder list.
assert "bookmarkGroups" not in proj
def test_add_bookmark_files_it_in_a_folder_next_to_its_members(proj):
first = authoring.add_bookmark(proj, "A", zoom=1, folder="Parks")
authoring.add_bookmark(proj, "Loose", zoom=1)
third = authoring.add_bookmark(proj, "B", zoom=1, folder="parks-missing-name-match")
fourth = authoring.add_bookmark(proj, "C", zoom=1, folder="Parks")
folders = {f["name"]: f["id"] for f in proj["bookmarkGroups"]}
assert first["groupId"] == fourth["groupId"] == folders["Parks"]
assert third["groupId"] == folders["parks-missing-name-match"]
# "C" lands right after "A", keeping the Parks folder contiguous.
assert [b["name"] for b in proj["bookmarks"]] == ["A", "C", "Loose", "B"]
def test_add_bookmark_resolves_visible_layers(proj):
bookmark = authoring.add_bookmark(proj, "Cities only", zoom=3, visible_layers=["cities"])
layer_id = authoring.find_layer(proj, "Cities")["id"]
assert bookmark["extra"] == {"visibleLayerIds": [layer_id]}
with pytest.raises(ValueError):
authoring.add_bookmark(proj, "Bad", zoom=3, visible_layers=["no such layer"])
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"center": [200, 0]}, "longitude"),
({"center": [0, 95]}, "latitude"),
({"zoom": 30}, "zoom"),
({"pitch": 90}, "pitch"),
],
)
def test_add_bookmark_rejects_bad_values(proj, kwargs, message):
with pytest.raises(ValueError, match=message):
authoring.add_bookmark(proj, "Bad", **{"center": [0, 0], "zoom": 1, **kwargs})
# A refused bookmark leaves no empty lists behind.
assert "bookmarks" not in proj
def test_remove_bookmark_by_name_id_or_index(proj):
a = authoring.add_bookmark(proj, "Alpha", zoom=1)
authoring.add_bookmark(proj, "Beta", zoom=1)
authoring.add_bookmark(proj, "Gamma", zoom=1)
assert [b["name"] for b in authoring.remove_bookmark(proj, "beta")] == ["Alpha", "Gamma"]
authoring.remove_bookmark(proj, a["id"])
authoring.remove_bookmark(proj, 0)
assert "bookmarks" not in proj
with pytest.raises(ValueError, match="no bookmark"):
authoring.remove_bookmark(proj, "Alpha")
def test_describe_project_lists_bookmarks(proj):
assert "bookmarks" not in authoring.describe_project(proj)
authoring.add_bookmark(proj, "Home", zoom=2, folder="Mine")
assert authoring.describe_project(proj)["bookmarks"] == [
{"id": proj["bookmarks"][0]["id"], "name": "Home", "folder": "Mine"}
]
def test_map_add_bookmark_accepts_layer_handles(monkeypatch):
# No server: the bundled app is not needed to author a project.
monkeypatch.setattr(gmod, "serve_app", lambda *_a, **_k: "http://127.0.0.1:0/")
monkeypatch.setattr(gmod, "app_port", lambda: 0)
m = Map()
layer = m.get_layer(m.add_geojson(POINT_FC, name="Cities"))
bookmark = m.add_bookmark("Here", center=(1, 2), zoom=5, visible_layers=[layer])
assert bookmark["extra"] == {"visibleLayerIds": [layer.id]}
assert m.project["bookmarks"] == [bookmark]
m.remove_bookmark("Here")
assert "bookmarks" not in m.project
def test_add_bookmark_refuses_past_the_app_limit(proj):
proj["bookmarks"] = [
project.bookmark(f"B{i}", center=[0, 0], zoom=1, bookmark_id=f"b{i}", created_at=0)
for i in range(project.MAX_BOOKMARKS)
]
with pytest.raises(ValueError, match="most the app keeps"):
authoring.add_bookmark(proj, "One too many", zoom=1, folder="New")
# Refused before anything changed: no folder was created.
assert len(proj["bookmarks"]) == project.MAX_BOOKMARKS
assert "bookmarkGroups" not in proj
@pytest.mark.skipif(not (REPO / "packages").is_dir(), reason="needs the repo sources")
def test_bookmark_limit_matches_the_app():
source = (REPO / "packages" / "core" / "src" / "project.ts").read_text(encoding="utf-8")
match = re.search(r"MAX_PROJECT_BOOKMARKS = (\d+);", source)
assert match, "MAX_PROJECT_BOOKMARKS not found in project.ts"
assert int(match.group(1)) == project.MAX_BOOKMARKS
+17
View File
@@ -234,6 +234,23 @@ move_story_chapter(path, chapter, index)
`on_exit` fade layers: `[{"layer": "Cities", "opacity": 1, "duration": 800}]`.
`chapter` is a chapter id, title, or 0-based index.
### Bookmarks
```text
add_bookmark(path, name, center=None, zoom=None, pitch=None, bearing=None,
folder=None, visible_layers=None)
remove_bookmark(path, bookmark)
```
- Bookmarks are saved map views in the Bookmarks panel (Controls → Bookmarks),
stored in the project so they travel with the file. `add_bookmark` appends
one; a camera value you omit comes from the project's saved view, so
`set_view` then `add_bookmark` captures that view. `folder` is a folder id or
name, and a new name creates the folder. `visible_layers` lists the layers
(ids or names) to show when the bookmark is opened; the others are hidden.
`bookmark` is a bookmark id, name, or 0-based index. `describe_project` lists
them.
### Framing and decoration
```text
@@ -81,6 +81,7 @@ reprojected.
| `printLayout` | object | Print composer settings; omitted when default |
| `storymap` | object | Scroll-driven chapters; omitted when there are none |
| `widgets` / `dashboardColumns` | array / number | Dashboard charts |
| `bookmarks` / `bookmarkGroups` | array / array | Saved views and their folders (Bookmarks panel) |
| `styleLibrary` | array | Project-scoped Style Manager entries |
| `metadata` | object | Free-form |
+7 -3
View File
@@ -287,9 +287,13 @@ m.move_story_chapter("Downtown", 0); m.remove_story_chapter("Overview")
`set_plugin_state` accepts the built-in ids in
`geolibre.project.PLUGIN_STATE_IDS` (`allow_unknown=True` for an external
plugin); the blob's shape is the plugin's own. Bookmarks are not part of a
project (the app keeps them in browser storage), so there is no API for them;
story chapters are the saved equivalent.
plugin); the blob's shape is the plugin's own.
`m.add_bookmark(name, center=, zoom=, pitch=, bearing=, folder=,
visible_layers=)` saves a view to the Bookmarks panel (camera values left out
come from the saved view; `folder` is an id or name, a new name creates it;
`visible_layers` takes ids, names, or handles). `m.remove_bookmark(ref)` drops
one by id, name, or 0-based index. Bookmarks are saved in the project.
## Live interaction (notebook only)
+226
View File
@@ -0,0 +1,226 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import { useAppStore, type ProjectBookmark } from "@geolibre/core";
import {
__setComponentsModuleLoaderForTests,
closeBookmarkPanel,
openBookmarkPanel,
type ComponentsModules,
} from "../packages/plugins/src/plugins/maplibre-components.ts";
import type { GeoLibreAppAPI } from "../packages/plugins/src/types";
const view = (id: string, over: Partial<ProjectBookmark> = {}): ProjectBookmark => ({
id,
name: `View ${id}`,
lng: 10,
lat: 20,
zoom: 5,
pitch: 0,
bearing: 0,
createdAt: 1,
...over,
});
/** A stand-in for maplibre-gl-components' BookmarkControl. */
class FakeBookmarkControl {
static instances: FakeBookmarkControl[] = [];
readonly options: Record<string, unknown>;
bookmarks: ProjectBookmark[];
groups: { id: string; name: string; collapsed: boolean }[];
private readonly handlers = new Map<string, (() => void)[]>();
constructor(options: Record<string, unknown>) {
this.options = options;
this.bookmarks = [...((options.bookmarks as ProjectBookmark[]) ?? [])];
this.groups = [...((options.groups as FakeBookmarkControl["groups"]) ?? [])];
FakeBookmarkControl.instances.push(this);
}
on(event: string, handler: () => void): this {
this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]);
return this;
}
emit(event: string): void {
for (const handler of this.handlers.get(event) ?? []) handler();
}
getBookmarks(): ProjectBookmark[] {
return this.bookmarks;
}
getGroups(): FakeBookmarkControl["groups"] {
return this.groups;
}
importBookmarks(bookmarks: ProjectBookmark[]): this {
this.bookmarks.push(...bookmarks);
return this;
}
exportBookmarks(): string {
return JSON.stringify(this.bookmarks);
}
// Private file-I/O hooks the host overrides.
_exportToFile(): void {}
_importFromFile(): void {}
show(): this {
return this;
}
expand(): this {
return this;
}
}
function fakeApp(importText: string | null = null): GeoLibreAppAPI {
return {
addMapControl: () => true,
removeMapControl: () => true,
importTextFile: () => Promise.resolve(importText),
} as unknown as GeoLibreAppAPI;
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
// Opening loads the control module, then shows the panel on a later task.
const settle = async () => {
await tick();
await tick();
};
function installLocalStorage(initial: Record<string, string> = {}): Map<string, string> {
const store = new Map(Object.entries(initial));
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
};
return store;
}
describe("Bookmarks panel and the project", () => {
let app: GeoLibreAppAPI;
beforeEach(() => {
FakeBookmarkControl.instances = [];
__setComponentsModuleLoaderForTests(
(): Promise<ComponentsModules> =>
Promise.resolve([
{ BookmarkControl: FakeBookmarkControl } as unknown as NonNullable<ComponentsModules[0]>,
null,
]),
);
installLocalStorage();
useAppStore.getState().newProject();
app = fakeApp();
});
afterEach(() => {
closeBookmarkPanel(app);
__setComponentsModuleLoaderForTests(null);
delete (globalThis as { localStorage?: unknown }).localStorage;
});
it("opens with the project's bookmarks and no localStorage persistence", async () => {
useAppStore.getState().setBookmarks([view("a")], [{ id: "g", name: "F", collapsed: false }]);
openBookmarkPanel(app);
await tick();
const control = FakeBookmarkControl.instances[0];
assert.equal(control.options.storageKey, "");
assert.deepEqual(
control.bookmarks.map((bookmark) => bookmark.id),
["a"],
);
// The control gets copies, so its in-place edits cannot reach the store.
assert.notEqual(control.options.bookmarks, useAppStore.getState().bookmarks);
});
it("writes the panel's edits back to the project", async () => {
openBookmarkPanel(app);
await tick();
const control = FakeBookmarkControl.instances[0];
control.bookmarks.push(view("new"));
control.emit("add");
assert.deepEqual(
useAppStore.getState().bookmarks.map((bookmark) => bookmark.id),
["new"],
);
assert.equal(useAppStore.getState().isDirty, true);
// Its own write does not rebuild the panel.
assert.equal(FakeBookmarkControl.instances.length, 1);
});
it("saves bookmarks imported through the host's file dialog", async () => {
app = fakeApp(JSON.stringify([view("imported")]));
openBookmarkPanel(app);
await tick();
const control = FakeBookmarkControl.instances[0];
control._importFromFile();
await tick();
assert.deepEqual(
useAppStore.getState().bookmarks.map((bookmark) => bookmark.id),
["imported"],
);
});
it("rebuilds the open panel when the project's bookmarks are replaced", async () => {
openBookmarkPanel(app);
await settle();
useAppStore.getState().setBookmarks([view("loaded")], []);
await settle();
assert.equal(FakeBookmarkControl.instances.length, 2);
assert.deepEqual(
FakeBookmarkControl.instances[1].bookmarks.map((bookmark) => bookmark.id),
["loaded"],
);
});
it("moves bookmarks saved by older versions into a project without any, once", async () => {
const storage = installLocalStorage({
"geolibre-bookmarks": JSON.stringify({
bookmarks: [view("old", { groupId: "g" })],
groups: [{ id: "g", name: "Legacy", collapsed: false }],
}),
});
openBookmarkPanel(app);
await tick();
assert.deepEqual(
useAppStore.getState().bookmarks.map((bookmark) => bookmark.id),
["old"],
);
assert.equal(useAppStore.getState().bookmarkGroups[0].name, "Legacy");
// The legacy data is kept (recoverable); a marker, written once the
// project is saved, stops a second copy.
assert.equal(storage.has("geolibre-bookmarks"), true);
assert.equal(storage.has("geolibre-bookmarks-migrated"), false, "not before a save");
useAppStore.getState().markSaved();
assert.ok(storage.get("geolibre-bookmarks-migrated"));
closeBookmarkPanel(app);
useAppStore.getState().newProject();
openBookmarkPanel(app);
await tick();
assert.deepEqual(useAppStore.getState().bookmarks, [], "not copied into a second project");
});
it("copies legacy bookmarks again when the first project is discarded unsaved", async () => {
const storage = installLocalStorage({ "geolibre-bookmarks": JSON.stringify([view("old")]) });
openBookmarkPanel(app);
await tick();
closeBookmarkPanel(app);
useAppStore.getState().newProject();
useAppStore.getState().markSaved();
assert.equal(storage.has("geolibre-bookmarks-migrated"), false);
openBookmarkPanel(app);
await tick();
assert.deepEqual(
useAppStore.getState().bookmarks.map((bookmark) => bookmark.id),
["old"],
);
});
it("leaves legacy bookmarks alone when the project already has its own", async () => {
const storage = installLocalStorage({ "geolibre-bookmarks": JSON.stringify([view("old")]) });
useAppStore.getState().setBookmarks([view("mine")], []);
openBookmarkPanel(app);
await tick();
assert.deepEqual(
useAppStore.getState().bookmarks.map((bookmark) => bookmark.id),
["mine"],
);
assert.equal(storage.has("geolibre-bookmarks"), true);
});
});
+34 -1
View File
@@ -250,5 +250,38 @@
]
}
]
}
},
"bookmarkGroups": [
{
"id": "hoods",
"name": "Neighborhoods",
"collapsed": false
}
],
"bookmarks": [
{
"id": "downtown",
"name": "Downtown",
"lng": -83.92,
"lat": 35.96,
"zoom": 14.0,
"pitch": 30.0,
"bearing": 340.0,
"createdAt": 1700000000000,
"groupId": "hoods",
"extra": {
"visibleLayerIds": ["cities"]
}
},
{
"id": "region",
"name": "Region",
"lng": -84.0,
"lat": 36.0,
"zoom": 6.0,
"pitch": 0.0,
"bearing": 0.0,
"createdAt": 1700000000001
}
]
}
+115
View File
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
import {
createEmptyProject,
normalizeBookmarkGroups,
normalizeBookmarks,
parseProject,
projectFromStore,
serializeProject,
useAppStore,
type ProjectBookmark,
} from "@geolibre/core";
const view = (id: string, over: Partial<ProjectBookmark> = {}): ProjectBookmark => ({
id,
name: `View ${id}`,
lng: -122.4,
lat: 37.8,
zoom: 12,
pitch: 30,
bearing: 45,
createdAt: 1_700_000_000_000,
...over,
});
describe("normalizeBookmarks", () => {
it("keeps valid views and drops malformed or out-of-range ones", () => {
const bookmarks = normalizeBookmarks([
view("a"),
view("a"), // duplicate id
{ ...view("b"), lng: 200 },
{ ...view("c"), lat: Number.NaN },
{ ...view("d"), zoom: 30 },
{ name: "no id", lng: 0, lat: 0, zoom: 1 },
"not an object",
]);
assert.deepEqual(
bookmarks.map((bookmark) => bookmark.id),
["a"],
);
});
it("wraps the bearing, clamps the pitch, and fills missing optional numbers", () => {
const [bookmark] = normalizeBookmarks([
{ id: "x", name: "X", lng: 1, lat: 2, zoom: 3, pitch: 120, bearing: -90 },
]);
assert.equal(bookmark.bearing, 270);
assert.equal(bookmark.pitch, 85);
assert.equal(bookmark.createdAt, 0);
});
it("drops a groupId that names no folder and keeps the captured layer state", () => {
const groups = normalizeBookmarkGroups([{ id: "g1", name: "Parks", collapsed: true }]);
const bookmarks = normalizeBookmarks(
[
view("a", { groupId: "g1", extra: { visibleLayerIds: ["roads"] } }),
view("b", { groupId: "missing" }),
],
groups,
);
assert.equal(bookmarks[0].groupId, "g1");
assert.deepEqual(bookmarks[0].extra, { visibleLayerIds: ["roads"] });
assert.equal("groupId" in bookmarks[1], false);
});
it("matches a groupId to its folder despite surrounding spaces", () => {
const groups = normalizeBookmarkGroups([{ id: " g1 ", name: "Parks" }]);
const [bookmark] = normalizeBookmarks([view("a", { groupId: " g1 " })], groups);
assert.equal(bookmark.groupId, "g1");
});
});
describe("bookmarks in the project file", () => {
beforeEach(() => {
useAppStore.getState().newProject();
});
it("round-trips bookmarks and folders through save and load", () => {
const project = {
...createEmptyProject("Views"),
bookmarkGroups: [{ id: "g1", name: "Parks", collapsed: false }],
bookmarks: [view("a", { groupId: "g1" }), view("b")],
};
const parsed = parseProject(serializeProject(project));
assert.deepEqual(parsed.bookmarks, project.bookmarks);
assert.deepEqual(parsed.bookmarkGroups, project.bookmarkGroups);
useAppStore.getState().loadProject(parsed, null);
const state = useAppStore.getState();
assert.deepEqual(
state.bookmarks.map((bookmark) => bookmark.id),
["a", "b"],
);
assert.deepEqual(projectFromStore(state).bookmarks, project.bookmarks);
});
it("leaves the keys out of a project without bookmarks", () => {
const saved = projectFromStore(useAppStore.getState());
assert.equal("bookmarks" in saved, false);
assert.equal("bookmarkGroups" in saved, false);
});
it("marks the project dirty when the panel writes its bookmarks", () => {
useAppStore.getState().markSaved();
useAppStore.getState().setBookmarks([view("a")], []);
assert.equal(useAppStore.getState().isDirty, true);
assert.equal(useAppStore.getState().bookmarks.length, 1);
});
it("clears bookmarks on a new project", () => {
useAppStore.getState().setBookmarks([view("a")], []);
useAppStore.getState().newProject();
assert.deepEqual(useAppStore.getState().bookmarks, []);
});
});
+8
View File
@@ -81,4 +81,12 @@ describe("Python-authored project round trip", () => {
["overview", "close-up"],
);
});
it("keeps the bookmarks and their folder, already in normalized form", () => {
assert.ok(Array.isArray(fixture.bookmarks) && fixture.bookmarks.length === 2);
assert.deepEqual(asJson(project.bookmarks), fixture.bookmarks);
assert.deepEqual(asJson(project.bookmarkGroups), fixture.bookmarkGroups);
assert.equal(project.bookmarks?.[0].groupId, "hoods");
assert.deepEqual(project.bookmarks?.[0].extra, { visibleLayerIds: ["cities"] });
});
});