diff --git a/.changeset/README.md b/.changeset/README.md index 22b5b92..d23f606 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -5,6 +5,10 @@ Run `pnpm changeset` for a change that should ship in the next release. Select configuration migration. App, IIIF, renderer and canvas-panel changes all ship as this one package, even when their source lives in another workspace folder. +These summaries become the package changelog and GitHub release notes. Keep each +to a few sentences about user-visible changes. Include required migration steps +and removed options; link to documentation for examples and detailed behavior. + Content-only changes belong in the content repository's history. Changes that do not need a release do not need a changeset. diff --git a/.changeset/content-independent-development.md b/.changeset/content-independent-development.md new file mode 100644 index 0000000..1dc3932 --- /dev/null +++ b/.changeset/content-independent-development.md @@ -0,0 +1,7 @@ +--- +"@allmaps/slides": patch +--- + +Root development commands now accept a content directory instead of selecting +one automatically, for example `pnpm dev ./content/slides-template`. +Remove unused app code and dependencies. diff --git a/.changeset/fresh-narrative-map.md b/.changeset/fresh-narrative-map.md new file mode 100644 index 0000000..3855be8 --- /dev/null +++ b/.changeset/fresh-narrative-map.md @@ -0,0 +1,7 @@ +--- +"@allmaps/slides": minor +--- + +Add `slides init [directory]` (alias `slides create`) to create a minimal English +project. Prompts ask for a title and optional Protomaps key; use `--title`, +`--protomaps-key` and `--yes` for automation. Existing files are never overwritten. diff --git a/.changeset/global-overlay-layers.md b/.changeset/global-overlay-layers.md new file mode 100644 index 0000000..c657f5d --- /dev/null +++ b/.changeset/global-overlay-layers.md @@ -0,0 +1,12 @@ +--- +"@allmaps/slides": minor +--- + +Add global `layers` defaults and custom MapLibre layers, including GeoJSON labels. +Each slide resolves its overrides independently in the viewer and previews. +GeoJSON layers fade using native transitions; feature-dependent circle and symbol +opacity may switch abruptly. + +Fix duplicate layer prefixes. Remove any workaround `user-` prefix from authored +overrides: use `-line`, `-fill`, `-point-circle`, +`-point-symbol`, or a custom layer's `id`. diff --git a/.changeset/optional-basemaps.md b/.changeset/optional-basemaps.md new file mode 100644 index 0000000..e3a8e61 --- /dev/null +++ b/.changeset/optional-basemaps.md @@ -0,0 +1,7 @@ +--- +"@allmaps/slides": patch +--- + +Show no basemap when no custom style or Protomaps key is supplied, including in +previews. An empty `map.styles` block is no longer needed. Keys can still come +from configuration or `PUBLIC_PROTOMAPS_KEY`. diff --git a/.changeset/quiet-dev-startup.md b/.changeset/quiet-dev-startup.md new file mode 100644 index 0000000..cc7ef24 --- /dev/null +++ b/.changeset/quiet-dev-startup.md @@ -0,0 +1,6 @@ +--- +"@allmaps/slides": patch +--- + +Fix the missing base `tsconfig.json` warning when starting development, build or +preview commands. diff --git a/.changeset/remove-image-map-mode.md b/.changeset/remove-image-map-mode.md new file mode 100644 index 0000000..be9d5c9 --- /dev/null +++ b/.changeset/remove-image-map-mode.md @@ -0,0 +1,9 @@ +--- +"@allmaps/slides": minor +--- + +Remove `warpedMaps[].type: Image`, its `region` and `wiggle` options, the +`model/map/image` export and the unused `imageLayer` label. +Move non-georeferenced images to [zoomable IIIF figures](https://github.com/allmaps/slides/blob/main/docs/images.md#iiif-figures) +in slide Markdown; use `data-region` to retain a crop and `hideBasemap: true` on +the slide when needed. diff --git a/.changeset/shared-camera-fit.md b/.changeset/shared-camera-fit.md new file mode 100644 index 0000000..e804140 --- /dev/null +++ b/.changeset/shared-camera-fit.md @@ -0,0 +1,12 @@ +--- +"@allmaps/slides": minor +--- + +Support `fit: contain` (default), `cover` and `equal` on slides and start screens, +in both the viewer and previews. Set `padding` for a uniform inner margin without +changing the space reserved by the interface. Use `fit: cover` with `padding: 0` +for edge-to-edge fitting, or negative padding for extra cropping. + +Remove unused chapter fields `caption`, `freeze` and `contain`, and interface +labels `chapterCountSingular`, `chapterCountPlural` and `noCredits`. +Per-map `warpedMaps[].caption` remains supported. diff --git a/.changeset/space-map-comparison.md b/.changeset/space-map-comparison.md new file mode 100644 index 0000000..58a60c8 --- /dev/null +++ b/.changeset/space-map-comparison.md @@ -0,0 +1,8 @@ +--- +"@allmaps/slides": patch +--- + +Hold Space to hide warped maps for comparison with the basemap; release it to +restore them. This replaces the backtick shortcut. Fix shortcuts after map +interaction and text-panel scrolling, and make both Space and layers-panel +hide/show controls redraw immediately after zooming. diff --git a/.github/test-paths.yml b/.github/test-paths.yml new file mode 100644 index 0000000..6fe06e9 --- /dev/null +++ b/.github/test-paths.yml @@ -0,0 +1,12 @@ +# Used with dorny/paths-filter's `every` predicate in both test workflows. +# Run tests unless every changed file is documentation. Keep content Markdown, +# licenses, workflow files and unknown paths covered by default. +tests: + - '**' + - '!docs/**' + - '!README.md' + - '!packages/*/README.md' + - '!apps/*/README.md' + - '!packages/*/CHANGELOG.md' + - '!apps/*/CHANGELOG.md' + - '!.changeset/*.md' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02b1102..fcb4dc2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,26 +1,115 @@ -name: Release notes +name: Publish release on: push: + branches: [main] tags: ['@allmaps/slides@*'] + paths: ['packages/slides/package.json'] + workflow_dispatch: + inputs: + tag: + description: 'Optional existing tag: repair GitHub release only. Leave empty to publish the current version on main.' + required: false + type: string + +concurrency: + group: publish-slides + cancel-in-progress: false permissions: - contents: write + contents: read jobs: + publish: + if: github.ref == 'refs/heads/main' && inputs.tag == '' + runs-on: ubuntu-24.04 + environment: npm + timeout-minutes: 20 + permissions: + contents: write + id-token: write + outputs: + release: ${{ steps.plan.outputs.release }} + tag: ${{ steps.plan.outputs.tag }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + package-manager-cache: false + - name: Check version and previous publication + id: plan + env: + RELEASE_BEFORE: ${{ github.event.before }} + run: node scripts/prepare-npm-release.mjs + - uses: pnpm/action-setup@v4 + if: steps.plan.outputs.release == 'true' && steps.plan.outputs.published == 'false' + - name: Install npm with trusted publishing support + if: steps.plan.outputs.release == 'true' && steps.plan.outputs.published == 'false' + run: npm install --global npm@11 + - name: Install packaging dependencies + if: steps.plan.outputs.release == 'true' && steps.plan.outputs.published == 'false' + # Packaging does not run the native renderer or need content repositories. + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Build and pack + if: steps.plan.outputs.release == 'true' && steps.plan.outputs.published == 'false' + # pnpm applies publishConfig.exports and removes workspace dependencies. + run: pnpm --filter @allmaps/slides pack --out "$RUNNER_TEMP/slides.tgz" + - name: Publish to npm + if: steps.plan.outputs.release == 'true' && steps.plan.outputs.published == 'false' + env: + NPM_TAG: ${{ steps.plan.outputs.npmTag }} + run: npm publish "$RUNNER_TEMP/slides.tgz" --access public --tag "$NPM_TAG" --provenance + - name: Create and push the release tag + if: steps.plan.outputs.release == 'true' + env: + RELEASE_TAG: ${{ steps.plan.outputs.tag }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + if ! git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then + git tag -a "$RELEASE_TAG" "$GITHUB_SHA" -m "$RELEASE_TAG" + fi + gh auth setup-git + git push origin "refs/tags/$RELEASE_TAG" + notes: + needs: publish + # Tags pushed by GITHUB_TOKEN do not start another workflow; create the + # release here too. User-pushed tags and manual repairs keep working. + if: >- + always() && !cancelled() && + ((needs.publish.result == 'success' && needs.publish.outputs.release == 'true') || + startsWith(github.ref, 'refs/tags/@allmaps/slides@') || + (github.event_name == 'workflow_dispatch' && inputs.tag != '')) runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + env: + RELEASE_TAG: ${{ inputs.tag || needs.publish.outputs.tag || github.ref_name }} steps: - uses: actions/checkout@v6 + with: + ref: ${{ env.RELEASE_TAG }} + persist-credentials: false - uses: actions/setup-node@v6 with: node-version: 24 - name: Extract notes for the tagged version - run: node scripts/release-notes.mjs > "$RUNNER_TEMP/slides-release-notes.md" + run: GITHUB_REF_NAME="$RELEASE_TAG" node scripts/release-notes.mjs > "$RUNNER_TEMP/slides-release-notes.md" - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/slides-release-notes.md") - if [[ "$GITHUB_REF_NAME" == *-beta.* ]]; then args+=(--prerelease); fi - gh release create "$GITHUB_REF_NAME" "${args[@]}" + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + echo "Release $RELEASE_TAG already exists." + exit 0 + fi + args=(--verify-tag --title "$RELEASE_TAG" --notes-file "$RUNNER_TEMP/slides-release-notes.md") + if [[ "$RELEASE_TAG" == *-beta.* ]]; then args+=(--prerelease); fi + gh release create "$RELEASE_TAG" "${args[@]}" diff --git a/.github/workflows/slides.yml b/.github/workflows/slides.yml index 913386f..64f69ae 100644 --- a/.github/workflows/slides.yml +++ b/.github/workflows/slides.yml @@ -1,16 +1,7 @@ name: Slides architecture on: + # Always report the required check; skip expensive steps for documentation-only PRs. pull_request: - paths: - - "packages/**" - - "package.json" - - ".changeset/**" - - "apps/slides/**" - - "scripts/**" - - "content/**" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - ".github/workflows/slides.yml" workflow_dispatch: jobs: integration: @@ -18,28 +9,49 @@ jobs: timeout-minutes: 20 permissions: contents: read + pull-requests: read steps: - uses: actions/checkout@v6 + - name: Detect changes that need testing + id: changes + # Manual runs and PRs beyond the API's 3,000-file limit run all tests. + if: github.event_name == 'pull_request' && github.event.pull_request.changed_files <= 3000 + uses: dorny/paths-filter@v4 with: - submodules: recursive + predicate-quantifier: every + filters: .github/test-paths.yml + - name: Documentation-only changes + if: steps.changes.outputs.tests == 'false' + run: echo "Documentation-only changes; integration tests were not needed." >> "$GITHUB_STEP_SUMMARY" + - name: Check out the integration presentation + if: steps.changes.outputs.tests != 'false' + run: git submodule update --init content/slides-template - uses: pnpm/action-setup@v4 + if: steps.changes.outputs.tests != 'false' with: version: 10.22.0 - uses: actions/setup-node@v6 + if: steps.changes.outputs.tests != 'false' with: node-version: 24 cache: pnpm - name: Set up native renderer + if: steps.changes.outputs.tests != 'false' run: sudo sh packages/static-render/scripts/install-system-deps.sh - run: pnpm install --frozen-lockfile + if: steps.changes.outputs.tests != 'false' - name: Unit and component checks + if: steps.changes.outputs.tests != 'false' run: | pnpm -r test pnpm --filter @allmaps/slides --filter @allmaps/iiif --filter @allmaps/static-render check - pnpm exec slides check ./content/gravity-at-sea + pnpm check ./content/slides-template - name: Native pixel alignment + if: steps.changes.outputs.tests != 'false' run: xvfb-run -a pnpm --filter @allmaps/static-render test:native - name: Independent development sites + if: steps.changes.outputs.tests != 'false' run: pnpm --filter @allmaps/slides test:dev - name: Fresh installed consumer + if: steps.changes.outputs.tests != 'false' run: xvfb-run -a pnpm --filter @allmaps/slides test:package diff --git a/.github/workflows/static-render.yml b/.github/workflows/static-render.yml index 1249637..2d98c3d 100644 --- a/.github/workflows/static-render.yml +++ b/.github/workflows/static-render.yml @@ -1,24 +1,31 @@ name: Static renderer on: + # Always report the required check; skip expensive steps for documentation-only PRs. pull_request: - paths: - - "packages/**" - - "apps/slides/**" - - "scripts/**" - - ".dockerignore" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - ".github/workflows/static-render.yml" workflow_dispatch: jobs: container: runs-on: ubuntu-24.04 permissions: contents: read + pull-requests: read steps: - uses: actions/checkout@v6 + - name: Detect changes that need testing + id: changes + # Manual runs and PRs beyond the API's 3,000-file limit run all tests. + if: github.event_name == 'pull_request' && github.event.pull_request.changed_files <= 3000 + uses: dorny/paths-filter@v4 + with: + predicate-quantifier: every + filters: .github/test-paths.yml + - name: Documentation-only changes + if: steps.changes.outputs.tests == 'false' + run: echo "Documentation-only changes; container tests were not needed." >> "$GITHUB_STEP_SUMMARY" - uses: docker/setup-buildx-action@v3 + if: steps.changes.outputs.tests != 'false' - name: Build image and run native smoke test + if: steps.changes.outputs.tests != 'false' uses: docker/build-push-action@v6 with: context: . @@ -30,5 +37,6 @@ jobs: cache-from: type=gha,scope=slides-renderer cache-to: type=gha,mode=max,scope=slides-renderer - name: Check container entrypoint + if: steps.changes.outputs.tests != 'false' timeout-minutes: 1 run: docker run --rm slides-renderer:check node packages/static-render/bin/render.mjs --help diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 7ec889f..c62b9cd 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -25,7 +25,6 @@ jobs: with: fetch-depth: 0 persist-credentials: false - submodules: recursive - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: diff --git a/README.md b/README.md index d93a07b..0e8624f 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,23 @@ website you can host yourself. Principles of the project: -- Ready for academic environments. This project has been tested in the classroom, and was developed in collaboration with groups of students from various disciplines, with the aim to make them familiar with digital humanities workflows and open formats. -- Flexibility between self-contained and API-driven. Allmaps annotations, IIIF resources, map styles and GeoJSON data, additional imagery, and even map tiles can be called from remote APIs or integrated in the repository directly. -- Reusable, openly licensed software. The CLI and libraries use MIT; the app uses GPL-3.0-or-later with a [content permission](docs/licensing.md) that lets authors choose licenses such as CC BY for their own material. +- Developed for academic use. Tested in the classroom and developed with students from various disciplines, Allmaps Slides helps students become familiar with digital humanities workflows and open data formats. +- Shared software, independent content. A centrally maintained codebase lets presentations benefit from shared improvements without each project maintaining its own software. Authors manage their content in separate repositories and choose when to update. The [license structure](docs/licensing.md) supports this independence, allowing authors to choose their own content license. +- Combine local and remote resources. Annotations, IIIF resources, map styles, GeoJSON, images and map tiles can be loaded from remote services or included in the content repository. Authors choose which resources to keep locally and which services to depend on. + +View in action: + +- [Gravity at Sea](https://tu-delft-heritage.github.io/gravity-expeditions-app) +- [Kattenburg Atlas](https://kattenburg.amsterdamtimemachine.nl/) (Dutch only) +- [Reuzenarbeid](https://tu-delft-heritage.github.io/reuzenarbeid/) (Dutch only) +- [From Image to Map](http://pages.allmaps.org/slides-template) (GitHub template) + +_Allmaps Slides is currently in beta. Try it out and share your feedback, ideas or bug reports through [GitHub issues](https://github.com/allmaps/slides/issues)._ ## Create a slideshow -Start with the [package quick start](packages/slides/README.md) or the +Create a minimal project with `slides init` using the +[package quick start](packages/slides/README.md), or copy the [GitHub template repository](https://github.com/allmaps/slides-template), which includes a small example and annotated configuration files. @@ -29,18 +39,21 @@ git submodule update --init content/slides-template pnpm exec slides dev ./content/slides-template ``` -Open the URL printed by the server. The template uses a plain background and -remote images, so no basemap API key is needed. +Open the URL printed by the server. The template uses remote images and a +Protomaps basemap. Follow its [API key setup](content/slides-template/README.md#preview) +when copying or forking it. - [Development and releases](docs/development.md): source setup, checks and npm bundles. - [Architecture](docs/architecture.md): application, package and content boundaries. - [Content examples](content): independent repositories included as Git submodules. -`pnpm bundle` builds the npm package. `pnpm build` builds the Gravity at Sea -site after its content submodule has been initialized. +`pnpm bundle` builds the npm package. Root shortcuts take a content directory: +for example, `pnpm build ./content/slides-template` builds the template site. ## Inspiration and previous versions - [Reuzenarbeid](https://tu-delft-heritage.github.io/reuzenarbeid/) was originally a Jekyll site and the first narrative map made with Allmaps. It was inspired by Bert Spaan's [The Changing Shoreline of New York City](https://github.com/nypl-spacetime/the-changing-shoreline-of-nyc), which was in turn inspired by [Travel the path of the solar eclipse](https://www.washingtonpost.com/graphics/national/mapping-the-2017-eclipse/). - [City Atlas](https://cityatlas.theberlage.nl/), one of three atlases made with the postmaster's programme of the [The Berlage Center for Advanced Studies in Architecture and Urban Design](https://theberlage.nl/) at TU Delft, in collaboration with Allmaps. - Existing templates for storytelling applications using MapLibre such as [Interactive Storytelling with MapLibre](https://github.com/digidem/maplibre-storymap/). + +_The development of Allmaps Slides was financially supported by the [Samenwerkende Maritieme Fondsen](https://www.samenwerkendemaritiemefondsen.nl/) for the publication of [Kattenburg Atlas](https://kattenburg.amsterdamtimemachine.nl/)._ diff --git a/apps/slides/package.json b/apps/slides/package.json index ab2f93b..a900c01 100644 --- a/apps/slides/package.json +++ b/apps/slides/package.json @@ -14,7 +14,6 @@ }, "devDependencies": { "@allmaps/annotation": "1.0.0-beta.38", - "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.0", @@ -34,7 +33,6 @@ "@allmaps/stdlib": "1.0.0-beta.42", "@lucide/svelte": "^1.34.0", "@sveltejs/adapter-static": "^3.0.10", - "@turf/turf": "^7.3.5", "maplibre-gl": "^6.11.2", "pmtiles": "^4.4.1" } diff --git a/apps/slides/src/lib/components/MadeWith.svelte b/apps/slides/src/lib/components/MadeWith.svelte index 318eea7..26ba521 100644 --- a/apps/slides/src/lib/components/MadeWith.svelte +++ b/apps/slides/src/lib/components/MadeWith.svelte @@ -1,11 +1,13 @@ @@ -13,7 +15,11 @@

{label ?? t("madeWith")} -

-
+
diff --git a/apps/slides/src/lib/shared/content-schema.ts b/apps/slides/src/lib/shared/content-schema.ts deleted file mode 100644 index 6868bc7..0000000 --- a/apps/slides/src/lib/shared/content-schema.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@allmaps/slides/model/content-schema"; diff --git a/apps/slides/src/lib/shared/interface-settings.ts b/apps/slides/src/lib/shared/interface-settings.ts index 61ec6e2..f184b87 100644 --- a/apps/slides/src/lib/shared/interface-settings.ts +++ b/apps/slides/src/lib/shared/interface-settings.ts @@ -44,8 +44,6 @@ export const interfaceDefaults = { hide: 'Hide', sections: 'Sections', chapterNumber: 'Chapter {number}', - chapterCountSingular: '{count} chapter in this slideshow', - chapterCountPlural: '{count} chapters in this slideshow', madeWith: 'Made with', productName: 'Allmaps Slides', softwareSource: 'Source code', @@ -74,7 +72,6 @@ export const interfaceDefaults = { backToTitle: 'Back to {title}', scrollToTop: 'Back to top', credits: 'Credits', - noCredits: 'No credits have been provided for this slideshow.', expandAll: 'Expand all', collapseAll: 'Collapse all', slides: 'Slides', @@ -87,7 +84,6 @@ export const interfaceDefaults = { mapCountSingular: '{count} map', mapCountPlural: '{count} maps', mapLayer: 'Map layer {count}', - imageLayer: 'Image layer', georeferenceAnnotation: 'Georeference annotation', noLayers: 'No map layers for this slide', visibleLayerSingular: '{count} map layer visible', diff --git a/apps/slides/src/lib/shared/keyboard.ts b/apps/slides/src/lib/shared/keyboard.ts index b1381a8..a97c101 100644 --- a/apps/slides/src/lib/shared/keyboard.ts +++ b/apps/slides/src/lib/shared/keyboard.ts @@ -1,10 +1,41 @@ -export type SlideshowShortcut = 'next' | 'previous' | 'back' | 'togglePanel'; +export type SlideshowShortcut = 'next' | 'previous' | 'back' | 'togglePanel' | 'hideMaps' | 'closePanel'; -export function slideshowShortcut(event: { +const textEntry = 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="combobox"]'; +const spaceControls = 'button, summary, [role="button"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [role^="menuitem"], [role="option"], [role="slider"]'; +const arrowControls = '[role="slider"], [role="spinbutton"], [role="tablist"], [role="listbox"], [role="menu"], [role="menubar"], [role="radiogroup"], [role="tree"], [role="grid"]'; + +type KeyboardInput = { key: string; altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; defaultPrevented?: boolean; blocked?: boolean; -}): SlideshowShortcut | undefined { - if (event.defaultPrevented || event.blocked || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + isComposing?: boolean; repeat?: boolean; + target?: Pick; +}; + +export function isTextEntry(target?: Pick | null): boolean { + return !!target?.closest(textEntry); +} + +/** Whether an unhandled key will scroll the reading panel itself. */ +export function isPanelScrollKey(event: KeyboardInput): boolean { + if (event.defaultPrevented || event.isComposing || event.altKey || event.ctrlKey || event.metaKey || isTextEntry(event.target)) return false; + // Unmodified Space belongs to map comparison, including held-key repeats. + // Shift+Space retains the browser's native upward scrolling behavior. + if (event.key === ' ') return !!event.shiftKey && !event.target?.closest(spaceControls); + if (event.shiftKey || event.target?.closest(arrowControls)) return false; + return ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End'].includes(event.key); +} + +export function slideshowShortcut(event: KeyboardInput): SlideshowShortcut | undefined { + if (event.defaultPrevented || event.blocked || event.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + if (event.key === 'Escape') return 'closePanel'; + if (isTextEntry(event.target)) return; + // The map disables its own keyboard controls, so focus there must not block + // slideshow shortcuts. Links use Enter, leaving Space free for comparison. + if (event.key === ' ') { + if (!event.repeat && !event.target?.closest(spaceControls)) return 'hideMaps'; + return; + } + if (event.key.startsWith('Arrow') && event.target?.closest(arrowControls)) return; if (event.key === 'ArrowRight') return 'next'; if (event.key === 'ArrowLeft') return 'previous'; if (event.key.toLowerCase() === 'b') return 'back'; diff --git a/apps/slides/src/lib/shared/map/comparison.ts b/apps/slides/src/lib/shared/map/comparison.ts new file mode 100644 index 0000000..d032bcd --- /dev/null +++ b/apps/slides/src/lib/shared/map/comparison.ts @@ -0,0 +1,14 @@ +import type { WarpedMapLayer } from '@allmaps/maplibre'; +import type { Map as MapLibreMap } from 'maplibre-gl'; + +export function setComparisonOpacity( + map: Pick, + layer: Pick, + hidden: boolean, +) { + // Keep each map's authored opacity and visibility intact. + layer.setLayerOptions({ opacity: hidden ? 0 : 1 }, { animate: false }); + // Allmaps can omit its change event when layer opacity is multiplied by a + // map-specific opacity. A settled MapLibre map then retains its old frame. + map.triggerRepaint(); +} diff --git a/apps/slides/src/lib/shared/map/focus.ts b/apps/slides/src/lib/shared/map/focus.ts new file mode 100644 index 0000000..79415be --- /dev/null +++ b/apps/slides/src/lib/shared/map/focus.ts @@ -0,0 +1,20 @@ +import { isTextEntry, slideshowShortcut } from '../keyboard.ts'; + +/** Wheel gestures leave focus on the previous control; clicking uses native focus. */ +export function focusMapOnWheel(canvas: HTMLCanvasElement) { + const wheel = (event: WheelEvent) => { + // Wheel zoom does not normally transfer keyboard focus. Preserve typing and + // browser shortcuts, but release focus held by a map or navigation button. + // Ctrl+wheel is included because MapLibre uses it for trackpad pinch zoom. + if (event.defaultPrevented || event.metaKey || event.altKey || !(event.deltaX || event.deltaY)) return; + const target = canvas.ownerDocument.activeElement ?? undefined; + if (isTextEntry(target) || slideshowShortcut({ key: ' ', target })) return; + canvas.focus({ preventScroll: true }); + }; + + // Listen on the canvas itself so controls and overlays retain their focus. + canvas.addEventListener('wheel', wheel, { passive: true }); + return () => { + canvas.removeEventListener('wheel', wheel); + }; +} diff --git a/apps/slides/src/lib/shared/map/image.ts b/apps/slides/src/lib/shared/map/image.ts deleted file mode 100644 index 50e7d75..0000000 --- a/apps/slides/src/lib/shared/map/image.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@allmaps/slides/model/map/image"; diff --git a/apps/slides/src/lib/shared/map/layer-transitions.ts b/apps/slides/src/lib/shared/map/layer-transitions.ts new file mode 100644 index 0000000..8e5a489 --- /dev/null +++ b/apps/slides/src/lib/shared/map/layer-transitions.ts @@ -0,0 +1,69 @@ +import type { AllPaintProperties, LayerSpecification, Map as MapLibreMap, TransitionSpecification } from 'maplibre-gl'; +import { LAYER_TYPES } from '../settings.ts'; + +const TRANSITION = { duration: 300, delay: 0 }; +const IMMEDIATE = { duration: 0, delay: 0 }; + +type LayerState = { visible: boolean; cancelHide?: () => void }; + +/** Let MapLibre animate opacity; only defer visibility:none until the fade ends. */ +export function createLayerTransitions( + map: Pick, +) { + const states = new Map(); + + return { + set(layer: LayerSpecification, visible: boolean, immediate = false) { + const previous = states.get(layer.id); + const wasRenderable = previous?.visible || !!previous?.cancelHide; + previous?.cancelHide?.(); + const state: LayerState = { visible }; + states.set(layer.id, state); + const animate = !!previous && !immediate; + const paint = layer.paint as Partial | undefined; + const opacityProperties = LAYER_TYPES[layer.type as keyof typeof LAYER_TYPES] ?? []; + // Whole-layer opacity preserves feature-dependent line/fill styling. + // Circle/symbol opacity uses native transitions with their usual limits. + const fadeProperties = layer.type === 'line' || layer.type === 'fill' + ? [`${layer.type}-layer-opacity`] : opacityProperties; + let hideAfter = 0; + + for (const property of new Set([...opacityProperties, ...fadeProperties])) { + const opacity = property as keyof AllPaintProperties; + const transition = `${property}-transition` as keyof AllPaintProperties; + const fades = fadeProperties.includes(property); + const timing = animate + ? { ...TRANSITION, ...paint?.[transition] as TransitionSpecification | undefined } + : IMMEDIATE; + map.setPaintProperty(layer.id, transition, timing); + map.setPaintProperty(layer.id, opacity, fades && !visible ? 0 : paint?.[opacity]); + if (fades) hideAfter = Math.max(hideAfter, timing.duration + timing.delay); + } + + if (visible || !wasRenderable || !hideAfter) { + map.setLayoutProperty(layer.id, 'visibility', visible ? 'visible' : 'none'); + return; + } + + // Start the visibility timer after MapLibre has applied the paint update. + // Cancelling it on the next slide prevents an old fade from hiding a layer. + let timer: ReturnType | undefined; + const rendered = () => { + map.off('render', rendered); + timer = setTimeout(() => { + state.cancelHide = undefined; + if (map.getLayer(layer.id)) map.setLayoutProperty(layer.id, 'visibility', 'none'); + }, hideAfter); + }; + state.cancelHide = () => { + map.off('render', rendered); + clearTimeout(timer); + }; + map.on('render', rendered); + }, + destroy() { + for (const state of states.values()) state.cancelHide?.(); + states.clear(); + }, + }; +} diff --git a/apps/slides/src/lib/shared/panel-scroll.ts b/apps/slides/src/lib/shared/panel-scroll.ts new file mode 100644 index 0000000..3ed96f3 --- /dev/null +++ b/apps/slides/src/lib/shared/panel-scroll.ts @@ -0,0 +1,38 @@ +import { isPanelScrollKey, isTextEntry } from './keyboard.ts'; + +/** Keep reading gestures separate from control activation and slideshow shortcuts. */ +export function panelScroll(element: HTMLElement, onInterrupt: () => void) { + const targetElement = (event: Event) => + event.target && 'closest' in event.target ? event.target as Element : undefined; + + const wheel = (event: WheelEvent) => { + // Ctrl+wheel is also used for trackpad pinch-to-zoom. + if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || !event.deltaY) return; + const target = targetElement(event); + if (isTextEntry(target)) return; + onInterrupt(); + // Wheel scrolling does not move browser focus. A previously clicked map + // or navigation button would otherwise keep receiving Space indefinitely. + if (!isTextEntry(element.ownerDocument.activeElement)) element.focus({ preventScroll: true }); + }; + const pointerdown = () => onInterrupt(); + const keydown = (event: KeyboardEvent) => { + const target = targetElement(event); + if (isPanelScrollKey({ key: event.key, target, + defaultPrevented: event.defaultPrevented, shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, metaKey: event.metaKey, altKey: event.altKey, + isComposing: event.isComposing })) onInterrupt(); + }; + + element.addEventListener('wheel', wheel, { passive: true }); + element.addEventListener('pointerdown', pointerdown, { passive: true }); + element.addEventListener('keydown', keydown); + return { + update(callback: typeof onInterrupt) { onInterrupt = callback; }, + destroy() { + element.removeEventListener('wheel', wheel); + element.removeEventListener('pointerdown', pointerdown); + element.removeEventListener('keydown', keydown); + }, + }; +} diff --git a/apps/slides/src/lib/shared/utils.ts b/apps/slides/src/lib/shared/utils.ts deleted file mode 100644 index c248fda..0000000 --- a/apps/slides/src/lib/shared/utils.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - bbox, - transformRotate, - multiPolygon, - bboxPolygon, - center, - getCoords, - distance, - lineString, -} from "@turf/turf"; -import * as maplibregl from "maplibre-gl"; - -type BBox = [number, number, number, number]; -type Coord = [number, number]; -type GeoMask = Coord[]; - -export const fetchJson = (url: string) => - fetch(url).then((resp) => resp.json()); - -export const getValueAsArray = (value: T | T[]): T[] => - Array.isArray(value) ? value : [value]; - -// Need to take into account display ratio! -export const getNarrowBbox = (mapsBbox: BBox) => { - const [xCenter, yCenter] = getCoords(center(bboxPolygon(mapsBbox))) as Coord; - const [xMin, yMin, xMax, yMax] = mapsBbox; - const distanceX = distance([xMin, yMin], [xMax, yMin]); - const distanceY = distance([xMin, yMin], [xMin, yMax]); - if (distanceX < distanceY) { - const line = lineString([ - [xMin, yCenter], - [xMax, yCenter], - ]); - return bbox(line) as BBox; - // return [xCenter, yMin, xCenter, yMax] - } else { - const line = lineString([ - [xCenter, yMin], - [xCenter, yMax], - ]); - return bbox(line) as BBox; - // return [xMin, yCenter, xMax, yCenter] - } -}; - -export const getAxisAlignedBboxAndCenter = ( - geoMasks: GeoMask[], - bearing: number, -) => { - // Use first mask coordinate as pivot - const pivot: Coord = geoMasks[0][0]; - - // Create a MultiPolygon from the masks - const polygons = geoMasks.map((geoMask) => [geoMask.concat([geoMask[0]])]); - const multiPolygonFeature = multiPolygon(polygons); - - // Rotate this polygon, calculate bbox and center - const rotatedPolygon = transformRotate(multiPolygonFeature, bearing, { - pivot, - }); - const rotatedBbox = bbox(rotatedPolygon) as BBox; - const rotatedCenter = center(bboxPolygon(rotatedBbox)); - - // Rotate back the center - const viewportCenter = transformRotate(rotatedCenter, -bearing, { pivot }); - const viewportCenterCoord = getCoords(viewportCenter) as Coord; - - // Return bbox and center coords - return { - bounds: rotatedBbox, - center: new maplibregl.LngLat(...viewportCenterCoord), - }; -}; - -export { createFauxGeoreferencedMap } from "./map/image"; diff --git a/apps/slides/svelte.config.js b/apps/slides/svelte.config.js index 02022dc..4179f51 100644 --- a/apps/slides/svelte.config.js +++ b/apps/slides/svelte.config.js @@ -36,12 +36,7 @@ export function createSlidesConfig() { const config = { kit: { outDir: process.env.SLIDES_KIT_OUT_DIR ?? ".svelte-kit", - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. adapter: adapter({ - // default options are shown. On some platforms - // these options are set automatically — see below pages: buildOutput, assets: buildOutput, fallback: undefined, @@ -77,9 +72,6 @@ export function createSlidesConfig() { } }, }, - // router: { - // type: "hash", - // }, }, preprocess: mdsvex({ extensions: [".svx", ".md"], diff --git a/apps/slides/tests/interface.test.mjs b/apps/slides/tests/interface.test.mjs index 30065fb..c2bd43c 100644 --- a/apps/slides/tests/interface.test.mjs +++ b/apps/slides/tests/interface.test.mjs @@ -21,8 +21,59 @@ test('slideshow shortcuts leave typing, modified browser shortcuts, and consumed assert.equal(slideshowShortcut({ key: 'ArrowLeft' }), 'previous'); assert.equal(slideshowShortcut({ key: 'b' }), 'back'); assert.equal(slideshowShortcut({ key: 'h' }), 'togglePanel'); - for (const option of ['blocked', 'defaultPrevented', 'ctrlKey', 'metaKey', 'altKey', 'shiftKey']) { + for (const option of ['blocked', 'defaultPrevented', 'ctrlKey', 'metaKey', 'altKey', 'shiftKey', 'isComposing']) { assert.equal(slideshowShortcut({ key: 'ArrowLeft', [option]: true }), undefined, option); } assert.equal(slideshowShortcut({ key: 'Tab' }), undefined); }); + +test('Space starts a temporary map comparison and replaces backtick', () => { + assert.equal(slideshowShortcut({ key: ' ' }), 'hideMaps'); + assert.equal(slideshowShortcut({ key: '`' }), undefined); + for (const option of ['blocked', 'defaultPrevented', 'ctrlKey', 'metaKey', 'altKey', 'shiftKey', 'isComposing', 'repeat']) { + assert.equal(slideshowShortcut({ key: ' ', [option]: true }), undefined, option); + } + // Repeated navigation keys still advance chapters. + assert.equal(slideshowShortcut({ key: 'ArrowRight', repeat: true }), 'next'); +}); + +// Model the selectors matched by a focused element or one of its ancestors. +function targetMatching(...matches) { + return { closest(selectors) { + return selectors.split(',').some(selector => matches.includes(selector.trim())) ? this : null; + } }; +} + +test('map canvas and chapter-link focus keep all slideshow shortcuts available', () => { + for (const target of [targetMatching('.maplibregl-canvas'), targetMatching('a[href]'), targetMatching('[role="link"]')]) { + for (const [key, action] of [['ArrowRight', 'next'], ['ArrowLeft', 'previous'], ['b', 'back'], ['h', 'togglePanel'], [' ', 'hideMaps']]) { + assert.equal(slideshowShortcut({ key, target }), action, key); + } + } +}); + +test('Space still activates focused controls, including icons inside buttons', () => { + for (const selector of ['button', 'summary', '[role="button"]', '[role="checkbox"]', '[role="switch"]']) { + const target = targetMatching(selector); + assert.equal(slideshowShortcut({ key: ' ', target }), undefined, selector); + assert.equal(slideshowShortcut({ key: 'h', target }), 'togglePanel', selector); + assert.equal(slideshowShortcut({ key: 'ArrowRight', target }), 'next', selector); + } + const slider = targetMatching('[role="slider"]'); + assert.equal(slideshowShortcut({ key: 'ArrowRight', target: slider }), undefined); + assert.equal(slideshowShortcut({ key: ' ', target: slider }), undefined); + assert.equal(slideshowShortcut({ key: 'h', target: slider }), 'togglePanel'); +}); + +test('typing and modal dialogs retain their keyboard controls, including Escape', () => { + for (const selector of ['input', 'textarea', 'select', '[contenteditable]:not([contenteditable="false"])', '[role="textbox"]', '[role="combobox"]']) { + for (const key of ['ArrowRight', 'ArrowLeft', 'b', 'h', ' ']) { + assert.equal(slideshowShortcut({ key, target: targetMatching(selector) }), undefined, `${selector}: ${key}`); + } + } + assert.equal(slideshowShortcut({ key: 'Escape' }), 'closePanel'); + for (const key of ['Escape', 'ArrowRight', 'h', ' ']) { + assert.equal(slideshowShortcut({ key, blocked: true }), undefined, key); + assert.equal(slideshowShortcut({ key, defaultPrevented: true }), undefined, key); + } +}); diff --git a/apps/slides/tests/layer-transitions.test.mjs b/apps/slides/tests/layer-transitions.test.mjs new file mode 100644 index 0000000..f902df8 --- /dev/null +++ b/apps/slides/tests/layer-transitions.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createLayerTransitions } from '../src/lib/shared/map/layer-transitions.ts'; +import { getGeoJsonLayers } from '@allmaps/slides/model/geojson'; + +function setup(layer, visible = false) { + const listeners = new Set(); + const writes = []; + const paint = { ...layer.paint }; + const layout = { ...layer.layout }; + let exists = true; + const map = { + getLayer: () => exists, + setLayoutProperty: (_, key, value) => { writes.push(['layout', key, value]); layout[key] = value; }, + setPaintProperty: (_, key, value) => { writes.push(['paint', key, value]); paint[key] = value; }, + on: (_, callback) => listeners.add(callback), + off: (_, callback) => listeners.delete(callback), + }; + const transitions = createLayerTransitions(map); + transitions.set(layer, visible); + return { transitions, paint, layout, writes, listeners, + render: () => { for (const callback of [...listeners]) callback(); }, + remove: () => { exists = false; }, + }; +} + +const line = getGeoJsonLayers('route')[1]; +const circle = getGeoJsonLayers('places')[2]; +const labels = { id: 'labels', type: 'symbol', source: 'places', paint: { 'text-opacity': 0.8 } }; + +test('native whole-layer opacity fades preserve feature styling and hide only after the rendered transition', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { transitions, paint, layout, render, writes, listeners } = setup(line); + assert.equal(layout.visibility, 'none'); + assert.equal(paint['line-layer-opacity'], 0); + assert.equal(listeners.size, 0); + transitions.set(line, true); + assert.equal(layout.visibility, 'visible'); + assert.equal(paint['line-layer-opacity'], undefined, 'restore authored/default opacity'); + assert.deepEqual(paint['line-layer-opacity-transition'], { duration: 300, delay: 0 }); + assert.deepEqual(paint['line-opacity'], line.paint['line-opacity']); + + transitions.set(line, false); + assert.equal(paint['line-layer-opacity'], 0); + t.mock.timers.tick(1000); + assert.equal(layout.visibility, 'visible', 'wait for MapLibre to render the paint update'); + render(); + const count = writes.length; + t.mock.timers.tick(299); + assert.equal(writes.length, count, 'no per-frame paint writes'); + assert.equal(layout.visibility, 'visible'); + t.mock.timers.tick(1); + assert.equal(layout.visibility, 'none'); + assert.deepEqual(paint['line-opacity'], line.paint['line-opacity']); +}); + +test('circle and symbol opacity is passed to MapLibre unchanged, including feature expressions', () => { + for (const [layer, properties] of [[circle, ['circle-opacity', 'circle-stroke-opacity']], [labels, ['text-opacity', 'icon-opacity']]]) { + const original = structuredClone(layer); + const { transitions, paint, layout } = setup(layer); + for (const property of properties) assert.equal(paint[property], 0); + transitions.set(layer, true); + for (const property of properties) { + assert.deepEqual(paint[property], layer.paint?.[property]); + assert.deepEqual(paint[`${property}-transition`], { duration: 300, delay: 0 }); + } + assert.equal(layout.visibility, 'visible'); + assert.deepEqual(layer, original); + } +}); + +test('rapid reversals cancel pending hiding before and after the paint update renders', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + for (const rendered of [false, true]) { + const { transitions, paint, layout, render, listeners } = setup(labels, true); + transitions.set(labels, false); + if (rendered) { render(); t.mock.timers.tick(150); } + transitions.set(labels, true); + assert.equal(paint['text-opacity'], 0.8); + assert.equal(listeners.size, 0); + render(); + t.mock.timers.tick(1000); + assert.equal(layout.visibility, 'visible'); + } +}); + +test('visibility waits for the longest native duration and delay, and omitted overrides restore defaults', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { transitions, paint, layout, render } = setup(labels, true); + const updated = { ...labels, paint: { 'text-opacity': 0.4, 'text-opacity-transition': { duration: 700, delay: 40 } } }; + transitions.set(updated, false); + assert.deepEqual(paint['text-opacity-transition'], { duration: 700, delay: 40 }); + render(); + t.mock.timers.tick(739); + assert.equal(layout.visibility, 'visible'); + t.mock.timers.tick(1); + assert.equal(layout.visibility, 'none'); + transitions.set(labels, true); + assert.equal(paint['text-opacity'], 0.8); + assert.deepEqual(paint['text-opacity-transition'], { duration: 300, delay: 0 }); +}); + +test('initial rendering and immediate updates bypass transitions and pending hiding', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { transitions, paint, layout, render, listeners } = setup(labels, true); + assert.deepEqual(paint['text-opacity-transition'], { duration: 0, delay: 0 }); + transitions.set(labels, false); + render(); + transitions.set(labels, false, true); + assert.equal(layout.visibility, 'none'); + assert.equal(listeners.size, 0); + transitions.set(labels, true, true); + assert.equal(layout.visibility, 'visible'); + assert.equal(paint['text-opacity'], 0.8); + assert.deepEqual(paint['text-opacity-transition'], { duration: 0, delay: 0 }); + t.mock.timers.tick(1000); + assert.equal(layout.visibility, 'visible'); +}); + +test('destroy cancels deferred hiding and removed layers are not updated', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + for (const operation of ['destroy-before-render', 'destroy-after-render', 'remove']) { + const { transitions, writes, listeners, render, remove } = setup(labels, true); + transitions.set(labels, false); + if (operation !== 'destroy-before-render') render(); + if (operation === 'remove') remove(); else transitions.destroy(); + const count = writes.length; + render(); + t.mock.timers.tick(1000); + assert.equal(writes.length, count); + assert.equal(listeners.size, 0); + } +}); diff --git a/apps/slides/tests/map-comparison.test.mjs b/apps/slides/tests/map-comparison.test.mjs new file mode 100644 index 0000000..29e627a --- /dev/null +++ b/apps/slides/tests/map-comparison.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { WarpedMapList, WarpedMapEventType } from '@allmaps/render'; +import { WebGL2WarpedMap } from '@allmaps/render/webgl2'; +import { setComparisonOpacity } from '../src/lib/shared/map/comparison.ts'; + +const fixture = JSON.parse(readFileSync(new URL('../../../packages/slides/tests/fixtures/projective-map.json', import.meta.url))); + +test('comparison redraws an idle map even when Allmaps omits the opacity change event', () => { + // Exercise the installed renderer's actual option merging/event dispatch. + // No GPU is needed to test it; only WebGL object allocation is stubbed. + const gl = { createVertexArray: () => ({}), createTexture: () => ({}) }; + const list = new WarpedMapList({ createRTree: false, + warpedMapFactory: (id, map, listOptions, mapOptions) => + new WebGL2WarpedMap(id, map, gl, {}, {}, {}, listOptions, mapOptions), + }); + const id = list.addGeoreferencedMap(fixture, { opacity: 0.7, visible: true }); + let paintedOpacity = 0.7; + const map = { triggerRepaint() { paintedOpacity = list.getWarpedMap(id).options.opacity; } }; + list.addEventListener(WarpedMapEventType.IMMEDIATECHANGE, () => map.triggerRepaint()); + const layer = { setLayerOptions: (...args) => list.setListOptions(...args) }; + + for (const opacity of [0.7, 1, 0.3]) { + list.setMapsOptions(() => ({ opacity }), { animate: false }); + // Model the last frame drawn as zooming finishes and MapLibre becomes idle. + map.triggerRepaint(); + for (let repeat = 0; repeat < 2; repeat++) { + setComparisonOpacity(map, layer, true); + assert.equal(paintedOpacity, 0, `hide authored opacity ${opacity}`); + assert.equal(list.getWarpedMap(id).mapOptions.opacity, opacity); + assert.equal(list.getWarpedMap(id).options.visible, true); + setComparisonOpacity(map, layer, false); + assert.equal(paintedOpacity, opacity, 'release restores authored opacity'); + } + } +}); diff --git a/apps/slides/tests/map-focus.test.mjs b/apps/slides/tests/map-focus.test.mjs new file mode 100644 index 0000000..adb4862 --- /dev/null +++ b/apps/slides/tests/map-focus.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { focusMapOnWheel } from '../src/lib/shared/map/focus.ts'; +import { slideshowShortcut } from '../src/lib/shared/keyboard.ts'; + +function targetMatching(selector) { + return { closest(selectors) { + return selectors.split(',').some(value => value.trim() === selector) ? this : null; + } }; +} + +function setup(focused = targetMatching('button')) { + const canvas = Object.assign(new EventTarget(), targetMatching('.maplibregl-canvas')); + const document = { activeElement: focused }; + const focusCalls = []; + canvas.ownerDocument = document; + canvas.focus = options => { focusCalls.push(options); document.activeElement = canvas; }; + const cleanup = focusMapOnWheel(canvas); + const dispatch = (type, props = {}) => { + const event = Object.assign(new Event(type, { cancelable: true }), props); + canvas.dispatchEvent(event); + return event; + }; + return { canvas, document, focusCalls, dispatch, cleanup }; +} + +test('wheel and trackpad map zoom release Space from a previously focused control', () => { + for (const ctrlKey of [false, true]) { + const { canvas, document, focusCalls, dispatch } = setup(); + assert.equal(slideshowShortcut({ key: ' ', target: document.activeElement }), undefined); + assert.equal(dispatch('wheel', { deltaY: -30, ctrlKey }).defaultPrevented, false); + assert.equal(document.activeElement, canvas); + assert.deepEqual(focusCalls, [{ preventScroll: true }]); + assert.equal(slideshowShortcut({ key: ' ', target: document.activeElement }), 'hideMaps'); + } +}); + +test('wheel zoom preserves typing focus and pointer focus is left to the browser', () => { + const input = targetMatching('textarea'); + const { document, dispatch, focusCalls } = setup(input); + dispatch('wheel', { deltaY: 50 }); + assert.equal(document.activeElement, input); + assert.equal(dispatch('pointerdown').defaultPrevented, false); + assert.deepEqual(focusCalls, []); +}); + +test('wheel zoom leaves focus alone when Space can already compare maps', () => { + for (const selector of ['body', '.slideshow-scroll', '.maplibregl-canvas', 'a[href]']) { + const target = targetMatching(selector); + const { document, dispatch, focusCalls } = setup(target); + dispatch('wheel', { deltaY: 50 }); + assert.equal(document.activeElement, target); + assert.deepEqual(focusCalls, []); + } +}); + +test('cleanup and non-zoom wheel events leave focus alone', () => { + const { dispatch, cleanup, focusCalls } = setup(); + dispatch('wheel', { deltaY: 0 }); + dispatch('wheel', { deltaY: 50, metaKey: true }); + dispatch('wheel', { deltaY: 50, altKey: true }); + cleanup(); + dispatch('wheel', { deltaY: 50 }); + dispatch('pointerdown'); + assert.deepEqual(focusCalls, []); +}); diff --git a/apps/slides/tests/panel-scroll.test.mjs b/apps/slides/tests/panel-scroll.test.mjs new file mode 100644 index 0000000..86a88ed --- /dev/null +++ b/apps/slides/tests/panel-scroll.test.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { panelScroll } from '../src/lib/shared/panel-scroll.ts'; +import { slideshowShortcut } from '../src/lib/shared/keyboard.ts'; + +function targetMatching(...matches) { + return { closest(selectors) { + return selectors.split(',').some(selector => matches.includes(selector.trim())) ? this : null; + } }; +} + +function setup(focused = targetMatching('button')) { + const element = Object.assign(new EventTarget(), targetMatching()); + const document = { activeElement: focused }; + const focusCalls = []; + element.ownerDocument = document; + element.focus = (options) => { focusCalls.push(options); document.activeElement = element; }; + const interruptions = []; + const action = panelScroll(element, () => interruptions.push('interrupt')); + const dispatch = (type, props = {}) => { + const { target, prevented, ...attributes } = props; + const event = Object.assign(new Event(type, { cancelable: true }), attributes); + if (target) Object.defineProperty(event, 'target', { value: target }); + if (prevented) event.preventDefault(); + element.dispatchEvent(event); + return event; + }; + return { element, document, focusCalls, interruptions, action, dispatch }; +} + +test('scrolling text after clicking a button returns Space to map comparison without jumping the panel', () => { + const { document, element, dispatch, focusCalls, interruptions } = setup(); + assert.equal(slideshowShortcut({ key: ' ', target: document.activeElement }), undefined); + assert.equal(dispatch('wheel', { deltaY: 50 }).defaultPrevented, false); + assert.equal(document.activeElement, element); + assert.deepEqual(focusCalls, [{ preventScroll: true }]); + assert.equal(slideshowShortcut({ key: ' ', target: document.activeElement }), 'hideMaps'); + assert.equal(interruptions.length, 1); +}); + +test('Space consumed later by the slideshow does not interrupt an in-progress chapter scroll', async () => { + const { dispatch, interruptions } = setup(); + const event = dispatch('keydown', { key: ' ' }); + assert.equal(slideshowShortcut({ key: event.key, target: event.target }), 'hideMaps'); + // Native dispatch can run microtasks between the panel and window listeners. + await Promise.resolve(); + assert.equal(interruptions.length, 0); + // The slideshow handles the key after it bubbles through the reading panel. + event.preventDefault(); + dispatch('keydown', { key: ' ', repeat: true }); + assert.equal(interruptions.length, 0); + + dispatch('keydown', { key: 'PageDown' }); + assert.equal(interruptions.length, 1); +}); + +test('native scrolling keys still interrupt navigation, including Shift+Space', () => { + const { dispatch, interruptions } = setup(); + for (const key of ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' ']) { + const event = dispatch('keydown', { key, shiftKey: key === ' ' }); + assert.equal(event.defaultPrevented, false, key); + } + assert.equal(interruptions.length, 7); +}); + +test('focused controls keep their keyboard behavior and text entry keeps focus while scrolling', () => { + const { dispatch, interruptions, focusCalls } = setup(); + for (const [key, selector] of [[' ', 'button'], [' ', 'summary'], ['PageDown', 'textarea'], ['ArrowDown', '[role="slider"]']]) { + assert.equal(dispatch('keydown', { key, target: targetMatching(selector) }).defaultPrevented, false); + } + assert.equal(interruptions.length, 0); + assert.equal(focusCalls.length, 0); + + const input = targetMatching('textarea'); + const typing = setup(input); + typing.dispatch('wheel', { deltaY: 50 }); + assert.equal(typing.document.activeElement, input); + assert.equal(typing.focusCalls.length, 0); + const before = typing.interruptions.length; + typing.dispatch('wheel', { deltaY: 50, target: input }); + assert.equal(typing.interruptions.length, before); +}); + +test('zoom gestures, consumed events and browser shortcuts do not take reading focus or interrupt', () => { + const { dispatch, focusCalls, interruptions } = setup(); + for (const modifier of ['ctrlKey', 'metaKey', 'altKey']) { + dispatch('wheel', { deltaY: 50, [modifier]: true }); + dispatch('keydown', { key: 'Home', [modifier]: true }); + } + dispatch('wheel', { deltaY: 0 }); + dispatch('keydown', { key: 'PageDown', prevented: true }); + dispatch('wheel', { deltaY: 50, prevented: true }); + assert.equal(interruptions.length, 0); + assert.equal(focusCalls.length, 0); +}); + +test('cleanup removes gesture handlers', () => { + const { dispatch, action, interruptions, focusCalls } = setup(); + action.destroy(); + dispatch('keydown', { key: 'PageDown' }); + dispatch('wheel', { deltaY: 50 }); + dispatch('pointerdown'); + assert.equal(interruptions.length, 0); + assert.equal(focusCalls.length, 0); +}); diff --git a/content/gravity-at-sea b/content/gravity-at-sea index dd89674..8b7624c 160000 --- a/content/gravity-at-sea +++ b/content/gravity-at-sea @@ -1 +1 @@ -Subproject commit dd8967428ab67fa3590802dc848097fb7fef30f8 +Subproject commit 8b7624c2eac5e32d93320b2b374f2a314a8d3d98 diff --git a/content/kattenburg-atlas b/content/kattenburg-atlas index 6743e27..89f9d48 160000 --- a/content/kattenburg-atlas +++ b/content/kattenburg-atlas @@ -1 +1 @@ -Subproject commit 6743e277c6f521aec3ad64278036256558cdad0e +Subproject commit 89f9d48c2092a52d05c4aefa0dece27a8caa1747 diff --git a/content/reuzenarbeid b/content/reuzenarbeid index 5ac2cec..e700525 160000 --- a/content/reuzenarbeid +++ b/content/reuzenarbeid @@ -1 +1 @@ -Subproject commit 5ac2cec19bb684fb9083c2e6e78627b8070900ab +Subproject commit e700525bbd6644b657144901990bc8a56cd9e175 diff --git a/content/slides-template b/content/slides-template index 9ec591f..c1a8ab5 160000 --- a/content/slides-template +++ b/content/slides-template @@ -1 +1 @@ -Subproject commit 9ec591f07e22042beede7504ce2f9f8ba11dc524 +Subproject commit c1a8ab5935218e0f17776a863fe5e8bbaeb7bb50 diff --git a/docs/README.md b/docs/README.md index 4afe69a..1702ebe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,8 @@ small English example and [annotated configuration references](https://github.co | Reference | What it covers | | --- | --- | -| [Development and releases](development.md) | Source setup, bundling, package checks and publication. | +| [Development](development.md) | Source setup, bundling and package checks. | +| [Releases](releases.md) | Versions, npm publication, GitHub releases and content upgrades. | | [Architecture](architecture.md) | Workspace boundaries, data flow, caches and watching. | | [Slides API](api.md) | Public JavaScript exports and a build example. | | [Interface behavior](interface.md) | Navigation, mobile layouts and viewer integration. | diff --git a/docs/architecture-migration.md b/docs/architecture-migration.md index e2aa44a..7dc0a9e 100644 --- a/docs/architecture-migration.md +++ b/docs/architecture-migration.md @@ -25,8 +25,7 @@ thumbnails refresh on build or an explicit command, not on ordinary dev edits. ## Final verification - All workspace unit/component tests and Slides/IIIF/renderer TypeScript checks pass. -- Existing content roots validate: Gravity at Sea (31 slides), Kattenburg (35) - and basemap fixtures (10). +- Existing content repositories validate. - Svelte checks exercise actual app sources, including when installed under `node_modules`: zero errors, one existing `Map.svelte` warning about the initial `anticipate` value. diff --git a/docs/authoring.md b/docs/authoring.md index 61faebd..6c54325 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -75,17 +75,34 @@ warpedMaps: ``` For a local annotation, use `path: assets/annotations/map.json` instead of `url`. -To show an original image without georeferencing, use an IIIF Image API service: +To show an original image without georeferencing, add a +[zoomable IIIF figure](images.md#iiif-figures) to the slide's text. + +Each slide supplies its own maps; repeat an entry to retain it on the next slide. +Omit `location` to fit the maps automatically, or supply a center and zoom. +Set `fit: contain` (the default) to show all map bounds, `fit: cover` to fill the +available map area with some cropping, or `fit: equal` to give the map bounds +the same area as the available viewport. This applies to the live view and +generated previews, and also works under `slideshows[].start`. `useZoom: true` +overrides the fitted zoom with the map's resource scale; an explicit +`location.zoom` takes priority over both. + +Use `padding` to set a uniform inner margin in pixels, inside the area left by +the reading panel and other layout reservations. Positive values add space; +negative values enlarge the fitting area, zooming in for extra cropping. +For edge-to-edge fitting within that area: ```yaml -warpedMaps: - - type: Image - url: https://elo.memorix.io/resources/iiif/3/c5cc0a2c-df58-4d69-8a25-fe8d6024ddc7 +fit: cover +padding: 0 ``` -For `type: Image`, use the service URL without `/info.json`. -Each slide supplies its own maps; repeat an entry to retain it on the next slide. -Omit `location` to fit the maps automatically, or supply a center and zoom. +For extra cropping, try `padding: -20`. The interface still controls the reserved +space and camera offset. An explicit +padding also applies to generated slide previews and sharing images. When +omitted, the existing margins remain: 25 pixels in the live view, 20 in slide +previews and 32 in sharing images. This also works under `slideshows[].start`. +The temporary "Show full map" view always uses the normal 25-pixel margin. Use [configuration](configuration.md) for basemaps, dark-mode map options and shared GeoJSON overlays. Use [images and captions](images.md) for images inside diff --git a/docs/commands.md b/docs/commands.md index f7554d4..de8c115 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,10 +1,53 @@ # CLI commands -Use Node.js 24 or later. Install the published package in your content repository -with `pnpm add -D @allmaps/slides@beta`. For source checkouts or local release archives, -see [development](development.md). +Use Node.js 24 or later. For source checkouts or local release archives, see +[development](development.md). -All examples below run from a content directory containing `slides.config.yml`. +## Create a project + +Run this before installing dependencies in the new folder: + +```sh +pnpm dlx @allmaps/slides@beta init my-story +cd my-story +pnpm install +pnpm dev +``` + +If the CLI is already installed, use `pnpm exec slides init my-story`. +`slides create` is an alias. Omitting the directory initializes the current +folder; relative paths are resolved from your terminal's working directory. + +In a terminal, `init` asks for a title and an optional Protomaps API key. Press +Enter to use "My narrative map" and leave the key empty. Both can be changed +later in `slides.config.yml`. The key prompt links to [protomaps.com/api](https://protomaps.com/api) +and explains that the key is included in the public website. + +Use `--title` and `--protomaps-key` to supply either answer directly. `--yes` +(or `-y`) skips all prompts and uses defaults for omitted values: + +```sh +pnpm exec slides init my-story --yes --title "My narrative map" +``` + +Piped and other non-interactive runs also use defaults without prompting. +Ctrl+C cancels an interactive setup before files are created. + +The command creates `slides.config.yml`, `chapters/01-welcome.md`, `package.json`, +`pnpm-workspace.yaml`, `.gitignore` and a short `README.md`. If you skip the key, +the config includes `protomaps.key: ""` and the starter uses a plain background; +no `map` block is needed. The Slides dependency is pinned to the version running +the command. The pnpm settings keep the project +independent of parent workspaces and allow the native dependencies to build. + +No dependencies are installed by `init`. Existing starter filenames, alternative +Slides config files or a `chapters` directory cause it to stop before writing. +Unrelated files, including an existing `.git` directory, are left in place. +Choose a license for your own content; the command does not assign one. + +## Work on a project + +The following examples run from a content directory containing `slides.config.yml`. | Command | Purpose | | --- | --- | diff --git a/docs/configuration.md b/docs/configuration.md index f5fa636..6150ba2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,18 +22,34 @@ URLs and [generation](generation.md) for IIIF and thumbnail settings. ## Basemaps -The quick-start example uses an empty MapLibre style. To use the default -Protomaps basemap, remove that `map.styles` block and supply your public key: +With no custom `map.styles` entry or Protomaps API key, Slides shows no basemap. +You can omit the entire `map` block, as the starter created by `slides init` does. +Your georeferenced maps, images and other layers still appear. +The starter asks for a Protomaps key and includes `protomaps.key: ""` if you skip +it. An empty key also allows `PUBLIC_PROTOMAPS_KEY` to supply one later. + +To add a Protomaps basemap, supply your public key in `slides.config.yml`: + +```yaml +protomaps: + key: your-key +``` + +Alternatively, supply the key through the environment: ```sh PUBLIC_PROTOMAPS_KEY=your-key pnpm exec slides dev . ``` Use the same key for builds and thumbnails, and allow your deployment origin -in the provider's settings. Alternatively, point `map.styles.light` and -`map.styles.dark` to your own MapLibre style URLs or files under -`assets/map-styles/`. Protomaps color overrides below apply to generated basemaps, -not custom styles. +in the provider's settings. Without a key, Slides does not request Protomaps +tiles, glyphs or sprites. This applies to the live view and generated previews. + +For a custom basemap, point `map.styles.light` and `map.styles.dark` to MapLibre +style URLs or files under `assets/map-styles/`. These styles do not need a +Protomaps key and take precedence over generated Protomaps styles. An explicit +empty style still works if you want to suppress a configured Protomaps basemap. +Protomaps color overrides below apply only to generated basemaps. ## Start-screen maps @@ -45,7 +61,7 @@ settings: slideshows: - id: main path: slideshows/00-main - title: Gravity Expeditions at Sea + title: My narrative map start: location: center: [4.9, 52.37] @@ -227,22 +243,100 @@ slide under `map.protomaps`; they do not modify custom `map.styles` files. ## Shared GeoJSON overlays -Declare GeoJSON sources in the content configuration to show them throughout -the project, in both the interactive map and slide/social thumbnails: +Declare GeoJSON sources in `slides.config.yml`. Each source generates four +layers: `-fill`, `-line`, `-point-circle` and +`-point-symbol`. These are visible by default, in both the interactive +map and slide/social thumbnails. Use top-level `layers` to change their defaults: ```yaml sources: route: type: geojson path: assets/geojson/route.geojson +layers: + - layer: route-line + visibility: none ``` +Enable the route in one slide's Markdown frontmatter: + +```yaml +layers: + - layer: route-line + visibility: visible + opacity: 0.8 + duration: 300 +``` + +Each slide resolves its overrides against the global defaults. Omitted settings +return to those defaults; they never carry over from a previous slide. The same +rules apply to `slideshows[].start.layers`. `opacity` accepts 0–1 and `duration` +is a non-negative transition duration in milliseconds. Without an opacity +override, the layer keeps its paint expression, including per-feature styles. +Without a duration, the global paint transition or MapLibre default applies. +Use authored IDs without an internal `user-` prefix. + Feature properties control the appearance using SimpleStyle: `stroke`, `stroke-width`, `stroke-opacity`, `fill`, `fill-opacity`, `marker-color` and `marker-size`. Omitted properties use the shared default style. For example, `{"stroke":"#64c18f","stroke-width":8}` draws a green route. Regenerate thumbnails after changing the geometry or style with `slides thumbnails .`. +### Custom layers and labels + +A global `layers` entry with `id` defines a full +[MapLibre style layer](https://maplibre.org/maplibre-style-spec/layers/). +Entries with `layer` adjust existing layers. Custom layers are drawn above the +generated layers in list order, with the last on top. A custom `id` matching a +generated ID replaces that generated layer. Unknown source/layer references +and duplicate IDs are rejected during content validation. + +For example, Amsterdam's historic-building GeoJSON contains points with names +in the `Naam` property. Add the following alongside your existing configuration: + +```yaml +sources: + buildings: + type: geojson + url: https://maps.amsterdam.nl/open_geodata/geojson_lnglat.php?KAARTLAAG=HISTORISCHE_BEBOUWING&THEMA=archeologie +layers: + - layer: buildings-point-circle + visibility: none + - id: building-labels + type: symbol + source: buildings + layout: + visibility: none + text-field: [get, Naam] + text-font: [Noto Sans Regular] + text-size: 14 + text-anchor: bottom + text-offset: [0, -0.7] + paint: + text-color: '#34373d' + text-halo-color: '#ffffff' + text-halo-width: 2 +``` + +Then enable the points and labels on the desired slide: + +```yaml +layers: + - layer: buildings-point-circle + visibility: visible + - layer: building-labels + visibility: visible +``` + +`text-field: [get, Naam]` reads each feature's `properties.Naam`. MapLibre +expressions can also combine fields or filter features. Text uses the basemap's +glyph endpoint when available; otherwise Slides supplies the Protomaps font +endpoint, which supports `Noto Sans Regular`. A custom font needs a compatible +glyph endpoint in the map style or `map.protomaps.glyphs`. Label collisions are +handled by MapLibre, so zooming in can reveal additional labels. +Sources and layer definitions live in the project configuration; a slide selects +them with `layers` overrides. + ## Credits and chapter numbering All slides are included in chapter numbering and totals. The start screen shows @@ -310,21 +404,10 @@ interface: mapLayers: Kaarten chapterPosition: "Hoofdstuk {current} van {total}" backToTitle: "Terug naar {title}" - readMore: Lees meer + startButton: Start + madeWith: Gemaakt met ``` Unspecified keys use English. Existing `interface.startScreen` settings remain supported; `interface.text` takes precedence. Kattenburg Atlas includes the full Dutch translation in its `slides.config.yml`. - -Start-screen text can be translated from the project configuration. Use -`{count}` where the number of chapters should appear: - -```yml -interface: - startScreen: - startButton: Start - chapterCountSingular: "{count} hoofdstuk in deze presentatie" - chapterCountPlural: "{count} hoofdstukken in deze presentatie" - madeWith: Gemaakt met -``` diff --git a/docs/deployment.md b/docs/deployment.md index c03038a..4a83056 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -39,12 +39,25 @@ see [licensing and source distribution](licensing.md#distributing-a-site). ## Workflows and headers -The existing content repositories provide source-checkout workflow examples: +The content repositories install an exact `@allmaps/slides` version from their +own `package.json` and `pnpm-lock.yaml`, using `pnpm install --frozen-lockfile`. +They do not clone the Slides source. Examples: [Gravity at Sea](https://github.com/tu-delft-heritage/gravity-expeditions-app/blob/main/.github/workflows/deploy-pages.yml), -[Reuzenarbeid](https://github.com/tu-delft-heritage/reuzenarbeid/blob/main/.github/workflows/deploy-pages.yml) -and [Kattenburg Atlas](https://github.com/amsterdamtimemachine/kattenburg-atlas/blob/main/.github/workflows/deploy-pages.yml). -They install renderer system dependencies and run the build under Xvfb. -The [template](https://github.com/allmaps/slides-template) does not configure deployment. +[Reuzenarbeid](https://github.com/tu-delft-heritage/reuzenarbeid/blob/main/.github/workflows/deploy-pages.yml), +[Kattenburg Atlas](https://github.com/amsterdamtimemachine/kattenburg-atlas/blob/main/.github/workflows/deploy-pages.yml) +and the [template](https://github.com/allmaps/slides-template/blob/main/.github/workflows/deploy-pages.yml). + +Select **GitHub Actions** as the content repository's Pages source. The workflow +uses Pages' configured URL and base path, including a custom domain when configured. +It installs the native renderer's Ubuntu libraries inline and builds under Xvfb. +IIIF, annotation and thumbnail caches have separate reset options for manual runs. +Private content repositories can use the public npm package without a Slides +checkout token; Pages availability depends on the repository's GitHub plan. + +To upgrade, run `pnpm add -D -E @allmaps/slides@` in the content repository, +validate/build, then commit its manifest and lockfile. Pushing to `main` redeploys. +The `SLIDES_REF` variable is no longer used. See [software releases](releases.md) +for how npm publishing and GitHub releases work. Kattenburg also has a Docker build that serves the exported site with Nginx; see its [deployment guide](https://github.com/amsterdamtimemachine/kattenburg-atlas/blob/main/docs/deployment.md). diff --git a/docs/development.md b/docs/development.md index f50b480..ffcdfdf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -13,7 +13,9 @@ git submodule update --init content/slides-template pnpm exec slides dev ./content/slides-template ``` -The template uses remote images and an empty basemap, so it needs no provider key. +The template uses remote images and a Protomaps basemap. Its +[README](../content/slides-template/README.md#preview) explains how to replace +the demo API key when copying or forking it. To work on another bundled content repository, initialize its submodule and pass that directory instead. `git submodule update --init --recursive` initializes all content repositories in a fresh checkout. @@ -25,18 +27,28 @@ pnpm exec slides dev ../my-story pnpm exec slides validate ../my-story ``` +To create a new project from this checkout, run `pnpm exec slides init ../my-story`. +The starter pins the checkout's package version. To test unreleased changes as an +installed consumer, replace that dependency with a local package archive as +described below. + Keep editing the original content files. Markdown and assets reload automatically; configuration edits restart the server. Use a different `--port` for each server. Refresh generated images with the [generation commands](generation.md). The root shortcuts `pnpm dev`, `pnpm build`, `pnpm check`, `pnpm validate`, -`pnpm iiif` and `pnpm preview` select Gravity at Sea. Initialize that submodule -before using them. Local fixtures can live in the ignored `content/tests/`: +`pnpm iiif` and `pnpm preview` forward to the CLI. Pass the content directory +explicitly; initialize it first if it is a submodule: ```sh -pnpm exec slides dev ./content/tests --port 5175 +pnpm dev ./content/slides-template +pnpm check ./content/slides-template +pnpm build ../my-story ``` +Local fixtures can live in the ignored `content/tests/`; for example, +`pnpm dev ./content/tests --port 5175`. + Generated sites, `node_modules/.vite/slides` caches and `.slides/` development runners are ignored by Git. Add `dist/`, `node_modules/` and `.slides/` to the ignore file in a new standalone content repository. @@ -55,8 +67,8 @@ declarations and application source. The helper packages are private workspace packages; only `@allmaps/slides` is published. External npm dependencies, including Sharp and Chiitiler, remain runtime dependencies installed by the consumer's package manager. -`pack` and `publish` rebuild through the `prepack` hook. `pnpm build` at the -repository root builds a presentation; `pnpm bundle` builds the npm package. +`pack` and `publish` rebuild through the `prepack` hook. At the repository root, +`pnpm build ` builds a presentation; `pnpm bundle` builds the npm package. The smoke test packs, installs and exercises a release without publishing it. The implementation lives in: @@ -84,58 +96,14 @@ needs [system dependencies](static-render.md#linux-setup). ## Releases -`packages/slides/package.json` owns the public version, starting at -`0.1.0-beta.1`. The bundled app and helper packages share that release. Their -internal manifest versions are not separate public releases. - -For a user-visible change to the CLI, app or a bundled helper, run -`pnpm changeset`, select `@allmaps/slides`, and describe what changed. Commit the -generated Markdown file with the change. Content-only changes belong in their -content repository's history and do not need a Slides changeset. - -The version workflow opens a release PR after changesets reach `main`. It updates -the version, changelog and prerelease state. Enable GitHub Actions' permission to -create pull requests in the repository settings. To prepare the same changes -locally, run `pnpm release:version` and commit the result. During beta, Changesets -advances `0.1.0-beta.1` to `0.1.0-beta.2`, and so on. A minor or major changeset can -also change the target regular version. - -Keep the content submodules initialized when refreshing the workspace lockfile -(`git submodule update --init --recursive` in a clean release checkout). The -version workflow does this too, so it retains their dependency entries. - -For each release: - -1. Review the version PR and run the [checks below](#checks). The release PR uses - `GITHUB_TOKEN`, so run the Slides CI workflow manually on its branch if GitHub - does not trigger checks automatically. Merge the reviewed version changes. -2. Check out the resulting clean, pushed commit and run `pnpm install --frozen-lockfile` - followed by `pnpm release:check`. Local content changes are excluded from this - check. Confirm that the software commit is accessible on GitHub before publishing. -3. With npm access to `@allmaps/slides`, run `pnpm release:publish`. This rebuilds - and publishes the package, explicitly selects the `beta` npm tag for a beta version, and - creates a local Git tag such as `@allmaps/slides@0.1.0-beta.1`. -4. Run `git push --follow-tags`. The release workflow creates a GitHub prerelease - with the corresponding changelog section. GitHub supplies its usual source - downloads; no separate source archive is uploaded. - -Use this publish command rather than `changeset publish`: Changesets can choose -`latest` for packages that have not had a stable release, leaving `beta` behind. - -The initial `0.1.0-beta.1` version and changelog are already prepared; no version -bump is needed for its first publication. Packing and testing never publish. -An npm package's first publication may also receive the `latest` tag, so check -the registry tags after that first release. The documented install uses `@beta`. - -When ready for a regular release, run `pnpm changeset pre exit` followed by -`pnpm release:version`, review and commit the changes, then follow the same -release steps. With the current target, this produces `0.1.0` on npm's `latest` -tag. Keep Changesets' generated `.changeset/pre/` records in Git until it removes -them during that transition. - -The package's [README](../packages/slides/README.md) is included at the archive root -and becomes its npm landing page. Keep its documentation links absolute so they -work on npm. Detailed guides remain in this repository's `docs/` directory. +Only `@allmaps/slides` is published; the bundled app and helpers share its version. +Add a changeset for changes to the CLI, app or helpers with `pnpm changeset`. +See [publish versions and GitHub releases](releases.md) for the complete process, +workflow responsibilities, npm authentication and recovery steps. + +Content repositories have their own pinned package dependency and lockfile. +They are not workspace packages, so preparing a software release does not need +content submodules. Initialize a submodule only when developing that presentation. ## Software identity in credits @@ -162,6 +130,15 @@ and [licensing](licensing.md#distributing-a-site). ## Checks +Every pull request reports the required `integration` and `container` checks. +Documentation-only changes pass after a quick file check, without installing +dependencies or building the container. The shared rules in +[`.github/test-paths.yml`](../.github/test-paths.yml) exclude guides, software +READMEs, changelogs and changeset notes. All other paths run the full checks, +including configuration, licenses, workflows and content submodules. Manual +workflow runs always run the full checks; a failed change-detection step fails +the required check. + ```sh pnpm -r test pnpm --filter @allmaps/slides check @@ -175,9 +152,9 @@ pnpm test:package The dev smoke runs two independent sites and checks HTTP/WebSocket updates. The asset smoke checks generated overview pages. The package smoke installs a -single archive in a fresh content repository and exercises type checks, dev, -IIIF, native pixels and repeat production builds. It requires registry access -and a working native renderer. +single archive in a fresh content repository and exercises project initialization, +type checks, dev, IIIF, native pixels and repeat production builds. It requires +registry access and a working native renderer. Package-specific tests and Docker checks are documented with the [renderer](static-render.md), [IIIF generator](iiif.md) and diff --git a/docs/interface.md b/docs/interface.md index cea769a..51a224d 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -40,11 +40,22 @@ between the top screen margin and navigator. Expanding the mobile card to its maximum height preserves the map's previous framing. Keyboard shortcuts: **Left / Right** for previous / next chapter, **B** to return -to the main slideshow and **H** to hide / show the sidebar. Shortcuts leave text -entry, modified browser shortcuts and image dialogs alone. The mobile handle also -supports **Up / Down**, **Home** (hide) and **End** (expand). +to the main slideshow and **H** to hide / show the sidebar. Hold **Space** to +temporarily hide the warped maps and see the basemap; release it to restore them. +Shortcuts leave text entry, modified browser shortcuts and image dialogs alone. +Shortcuts work while the map or a chapter link has focus. Space keeps its usual +activation behavior on focused buttons and other controls. Scrolling the text +panel moves focus back to the reading area. Wheel zoom moves focus to the map +only when a previously focused control would consume Space; clicks use native +browser focus. The +mobile handle also supports **Up / Down**, **Home** (hide) and **End** (expand). Rapid chapter navigation advances from the latest requested chapter while smooth scrolling settles; manual scrolling can interrupt it without snapping back. +GeoJSON overlays use native MapLibre opacity transitions when their slide +visibility changes (300 ms by default). Readers who prefer reduced motion see +immediate changes. Lines and fills fade as whole layers, preserving feature +styles. Feature-dependent circle or symbol opacity does not fade smoothly; +use a constant opacity if these layers need to fade. ## Image viewer diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..60eef62 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,143 @@ +# Publish versions and GitHub releases + +Only `@allmaps/slides` goes to npm. The application and bundled helpers share +its version in [`packages/slides/package.json`](../packages/slides/package.json). +GitHub Actions publishes the package and creates its Git tag and GitHub release. +Content repositories choose when to upgrade and redeploy their websites. + +## Publish the next version + +1. Make and test the software change. Run `pnpm changeset`, select + `@allmaps/slides`, choose the change type and describe the user-visible change. + Commit the changeset with the code and merge it into `main`. Multiple pending + changesets are collected into one release. +2. **Prepare release** opens or updates a version PR with the new version, + changelog and lockfile. During beta, the next version after `0.1.0-beta.1` is + `0.1.0-beta.2`. Review the PR, let its checks pass, and merge it. +3. **Publish release** detects the version change on `main`, checks the release, + builds and packs the software, and publishes it to npm. Beta versions use + npm's `beta` channel; stable versions use `latest`. It then creates and pushes + an annotated tag such as `@allmaps/slides@0.1.0-beta.2` and creates the matching + GitHub release from the changelog. Beta versions are GitHub prereleases. + No local publish command or manual tagging is needed. +4. Upgrade each content repository that should use the new version: + + ```sh + pnpm add -D -E @allmaps/slides@0.1.0-beta.2 + pnpm validate + pnpm build + ``` + + Commit its `package.json` and `pnpm-lock.yaml` and push to deploy the website. + A Slides release does not automatically upgrade existing presentations. + +An ordinary change to the package manifest without a version change does not +publish anything. Pending changesets alone do not publish anything either: +they prepare the version PR. A release requires a clean software commit, +consumed changesets and a changelog entry matching the version. + +[Software checks](development.md#checks) cover the package and application. +Content changes belong in their own repositories and need no Slides changeset. +Local content edits do not block `pnpm release:check`. + +## npm trusted publisher + +The package's npm settings must authorize this GitHub trusted publisher: + +| Setting | Value | +| --- | --- | +| Organization | `allmaps` | +| Repository | `slides` | +| Workflow filename | `release.yml` | +| Environment name | `npm` | +| Allowed actions | Enable **npm publish** for direct publication. | + +The publishing job uses the GitHub environment named `npm`. Under repository +**Settings → Environments → npm**, restrict deployment branches to `main`. +The job already runs only on `main`, including manual publication. Tag-triggered +GitHub release repairs do not publish to npm or use this environment. +The environment name in npm's trusted-publisher settings must match `npm`. + +The publishing job has `id-token: write` permission and uses npm 11 with OIDC. +No npm token secret or interactive login is needed in Actions. pnpm prepares +the tarball so `publishConfig.exports` and bundled workspace packages are +handled correctly; npm publishes that tarball with provenance. Packaging does +not run the native renderer or clone content repositories. + +See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). +If direct publication is disabled on npm, this workflow fails; it does not +silently switch to staged publishing. + +## What the actions do + +| Workflow | Trigger | Result | +| --- | --- | --- | +| Prepare release (`version.yml`) | Changesets on `main`, or manual run on `main` | Opens/updates the version and changelog PR. | +| Publish release (`release.yml`) | Package manifest changes on `main` | Publishes only when the version changes, then creates the tag and GitHub release. | +| Publish release (`release.yml`) | Manual run on `main` with an empty tag | Attempts the current prepared release, including retrying an interrupted publication. | +| Publish release (`release.yml`) | Push of `@allmaps/slides@*`, or manual run with an existing tag | Creates the GitHub release only; does not publish to npm. | +| Slides architecture (`slides.yml`) | Every pull request, or manual run | Tests the CLI, app, renderer, development server and installed package; skips tests for documentation-only PRs. | +| Static renderer (`static-render.yml`) | Every pull request, or manual run | Tests the Linux renderer container; skips tests for documentation-only PRs. | +| Deploy to GitHub Pages (content repos) | Push to `main`, or manual run | Installs the pinned npm package, builds content and deploys the site. | +| Build and Publish Docker Image (Kattenburg) | Push to `main`, version tag, or manual run | Builds the site and publishes an Nginx image to GHCR. | + +GitHub Actions must be allowed to create pull requests under repository +**Settings → Actions → General**. GitHub may ask you to **Approve workflows to +run** on the automatically created version PR. Approve them and let the required +checks finish before merging. If the PR has no checks or approval prompt, +close and reopen it from your own account. Manual workflow runs do not satisfy +required PR checks. See [GitHub's workflow trigger rules](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow#triggering-a-workflow-from-a-workflow). + +The release job creates the GitHub release directly after publishing because +tags pushed by `GITHUB_TOKEN` do not trigger another workflow run. +Content repositories must select **GitHub Actions** as their Pages source under +**Settings → Pages**. The software integration workflow initializes only the +public Slides template. + +## Recover an interrupted release + +- Rerun the failed **Publish release** run to retry the same source commit. + If npm already has that version, the workflow verifies its published source + stamp and finishes tagging and creating the GitHub release without republishing. +- If the version on npm or an existing tag belongs to a different commit, the + workflow stops. Rerun the original source commit or prepare a new version; + never move a published version's tag to different source code. +- For a missing GitHub release when the tag already exists, run **Publish + release** manually with that exact tag. An existing release is left unchanged. +- A manual run with an empty tag uses the selected `main` commit. Use it only + when that commit is the prepared release. If more work has landed on `main`, + rerun the original failed run instead. + +GitHub supplies source ZIP/tar downloads for tagged releases. The first npm +publication also received `latest`; use an explicit version or `@beta` for beta +updates. A version already accepted by npm cannot be overwritten. + +## Local fallback + +If Actions is unavailable, publish from the exact prepared release commit with +Node.js 24, pnpm 10.22.0 and a clean software working tree: + +```sh +pnpm install --frozen-lockfile +pnpm release:check +npm login --auth-type=web +pnpm release:publish +git push origin '@allmaps/slides@0.1.0-beta.2' +``` + +Replace the example tag with the version just published. `release:publish` +rebuilds the package, selects `beta` or `latest`, publishes and creates a local +annotated tag. Complete npm's publishing authentication when prompted. Pushing +the tag creates the GitHub release. Coordinate this fallback with any running +Actions publication so they do not attempt the same version concurrently. + +Do not use `changeset publish` here: it can select `latest` for a package whose +first stable release has not yet been published. + +## Finish beta + +Run `pnpm changeset pre exit`, then `pnpm release:version`. Review, commit and +merge the changes. With the current target, this produces `0.1.0` and triggers +publication on `latest`. Keep Changesets' `.changeset/pre/` records until +Changesets removes them. To prepare a version PR's changes locally instead of +using the action, run `pnpm release:version` and commit its output. diff --git a/docs/thumbnail-generation.md b/docs/thumbnail-generation.md index 4768861..d905463 100644 --- a/docs/thumbnail-generation.md +++ b/docs/thumbnail-generation.md @@ -11,8 +11,8 @@ thumbnails without a browser, DOM, or a running web server. | Map layers panel | 256 × 256 WebP per map-entry/theme variant | Transparent, north-up, fitted to the applied mask; no basemap | | Social metadata | 1200 × 630 JPEG per slideshow route | First slide in light theme; optional overall short title and subtitle with a soft halo | -A map-entry variant includes its URL, type, image crop, wiggle and renderer -options. An AnnotationPage with several maps produces one combined layer-row +A map-entry variant includes its URL and renderer options. +An AnnotationPage with several maps produces one combined layer-row preview. A panel thumbnail is never upscaled into a slide: warped maps render again in the slide's shared viewport. @@ -77,8 +77,10 @@ data. Small slide cards and hover previews never receive this overlay. See [the configuration examples](configuration.md#overall-titles-descriptions-and-sharing-images) for short/long copy and fallback rules. -GeoJSON sources declared in the content configuration use the same SimpleStyle -layers in the live map and upper thumbnail pass. The native adapter removes +GeoJSON sources use the same generated and custom layers in the live map and +upper thumbnail pass. Global `layers` defaults and the current slide's overrides +are resolved independently for each preview, including text-label expressions. +The native adapter removes null feature IDs (leaving properties and valid IDs intact), since those features would otherwise be silently omitted. The render caches include this correction. @@ -198,12 +200,12 @@ newly generated files are saved even if a later build step fails. Only complete source bodies and completed render jobs are cached. Manual workflow inputs can independently skip restoring each cache. -The Pages workflow sets up Node 24 and pnpm, calls the renderer package's -shared Ubuntu dependency setup script, and runs `slides build` under Xvfb. +The Pages workflow sets up Node 24 and pnpm, installs Ubuntu renderer libraries +inline and the pinned npm dependencies, and runs `slides build` under Xvfb. It uploads `dist/site` directly. No Docker image is built or run for Pages. Container deployment has a separate `docker-publish.yml` workflow. Kattenburg's -single multi-stage Dockerfile obtains the Slides source, installs dependencies, +single multi-stage Dockerfile installs the pinned Slides npm package, generates all derivatives and thumbnails, prerenders the site and produces an Nginx serving image. Only public site files enter that final image. Its Nginx configuration supports clean URLs for prerendered chapter HTML files. @@ -212,12 +214,13 @@ The container build uses three BuildKit cache mounts and explicit Actions cache import/export; ordinary image-layer caching alone does not persist cache mounts on hosted runners. A daily `CACHE_EPOCH` build argument lets unchanged content revalidate remote inputs. The renderer cache is namespaced by the -framework lockfile. See [Kattenburg's deployment guide](https://github.com/amsterdamtimemachine/kattenburg-atlas/blob/main/docs/deployment.md) -for build arguments and local source overrides. +content lockfile. See [Kattenburg's deployment guide](https://github.com/amsterdamtimemachine/kattenburg-atlas/blob/main/docs/deployment.md) +for build arguments and package upgrades. The optional renderer/build-tool image remains useful for standalone batches. -It shares `install-system-deps.sh` with the Pages and web-image builds. The -monorepo's `static-render.yml` workflow tests that tool image and native pixels. +The software workspace uses `install-system-deps.sh` for its own native CI setup; +consumer workflows install the equivalent libraries inline. The monorepo's +`static-render.yml` workflow tests the tool image and native pixels. ## Validation and limits @@ -242,7 +245,7 @@ xvfb-run -a pnpm --filter @allmaps/static-render test:native ``` The supported scene is planar Web Mercator at pitch zero. Masks, transforms, -opacity, saturation, background-color removal, colorization, image regions, +opacity, saturation, background-color removal, colorization, basemap themes and vector overlays are handled. Sharp's raw-pixel pipeline applies map effects before alpha composition. WebGL-only distortion shading, mask/grid/GCP/vector diagnostics and unknown options produce warnings and are diff --git a/package.json b/package.json index 5d2cfeb..ad5c6fa 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "license": "SEE LICENSE IN LICENSE.md", "type": "module", "scripts": { - "dev": "slides dev ./content/gravity-at-sea", - "build": "slides build ./content/gravity-at-sea", - "preview": "slides preview ./content/gravity-at-sea", - "check": "slides check ./content/gravity-at-sea", - "validate": "slides validate ./content/gravity-at-sea", - "iiif": "slides iiif ./content/gravity-at-sea", + "dev": "slides dev", + "build": "slides build", + "preview": "slides preview", + "check": "slides check", + "validate": "slides validate", + "iiif": "slides iiif", "bundle": "pnpm --filter @allmaps/slides run build", "test:package": "pnpm --filter @allmaps/slides test:package", "changeset": "changeset", diff --git a/packages/slides/README.md b/packages/slides/README.md index d37a9e5..8bb8517 100644 --- a/packages/slides/README.md +++ b/packages/slides/README.md @@ -1,70 +1,42 @@ # @allmaps/slides -Create interactive map stories from Markdown, images and georeferenced maps. +Create interactive narrative maps from Markdown, images and georeferenced maps. The `slides` CLI previews your story locally and builds a static website. It includes the application, image tools and map thumbnail renderer. ## Quick start -Use **Node.js 24 or later**. Install the npm package in a new directory: +Use **Node.js 24 or later** and pnpm 10. Create a project, install its dependencies +and start the preview: ```sh -mkdir my-story +pnpm dlx @allmaps/slides@beta init my-story cd my-story -pnpm add -D @allmaps/slides@beta -mkdir chapters +pnpm install +pnpm dev ``` -Slides is currently in beta. Commit your lockfile to keep builds reproducible. -For a source checkout or a local package archive, see -[development setup](https://github.com/allmaps/slides/blob/main/docs/development.md). - -Create `slides.config.yml`: - -```yaml -title: My first map story -slideshows: - - id: main - path: chapters -map: - styles: - light: &plain - version: 8 - sources: {} - layers: [] - dark: *plain -``` - -This uses a plain background, so the example needs no basemap API key. -Create `chapters/01-welcome.md`: +`init` asks for a title and an optional Protomaps key; you can change both later +in `slides.config.yml`. Press Enter to keep the defaults and use a plain +background. Use `--yes` to skip prompts. `create` is an alias for `init`; +neither overwrites existing files. -```md ---- -title: Welcome -location: - center: [4.4924, 52.1590] - zoom: 14 ---- - -Every map has a story. This is the first chapter of mine. -``` +Open the preview URL printed in your terminal and edit `chapters/01-welcome.md`. -Start the preview and open the URL printed in your terminal: - -```sh -pnpm exec slides dev . -``` +Slides is currently in beta. The starter pins your Slides version; commit your +lockfile to keep builds reproducible. For a source checkout or a local package +archive, see [development setup](https://github.com/allmaps/slides/blob/main/docs/development.md). -Edit the Markdown file to see your changes. For an example with historical maps, +For an example with historical maps, use the [Slides template](https://github.com/allmaps/slides-template). To add a background map, follow [basemap setup](https://github.com/allmaps/slides/blob/main/docs/configuration.md#basemaps). ## Check and build ```sh -pnpm exec slides validate . -pnpm exec slides build . -pnpm exec slides preview . +pnpm exec slides validate +pnpm exec slides build +pnpm exec slides preview ``` The build writes a static site to `dist/` and generates image derivatives and diff --git a/packages/slides/package.json b/packages/slides/package.json index ecaba6b..932f5ff 100644 --- a/packages/slides/package.json +++ b/packages/slides/package.json @@ -33,7 +33,6 @@ "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.0", - "@turf/turf": "^7.3.5", "@types/node": "^25.9.1", "commander": "^14.0.2", "maplibre-gl": "^6.11.2", @@ -70,7 +69,6 @@ "./model/types": "./src/model/types.ts", "./model/basemap": "./src/model/basemap.ts", "./model/config": "./src/model/config.ts", - "./model/map/image": "./src/model/map/image.ts", "./model/map/annotations": "./src/model/map/annotations.ts", "./model/map/layers": "./src/model/map/layers.ts", "./model/map/camera": "./src/model/map/camera.ts", @@ -90,7 +88,7 @@ "engines": { "node": ">=24" }, - "description": "Build map stories from Markdown and local assets", + "description": "Build narrative maps from Markdown and local assets", "publishConfig": { "access": "public", "exports": { @@ -130,10 +128,6 @@ "types": "./dist/model/config.d.ts", "default": "./dist/model/config.js" }, - "./model/map/image": { - "types": "./dist/model/map/image.d.ts", - "default": "./dist/model/map/image.js" - }, "./model/map/annotations": { "types": "./dist/model/map/annotations.d.ts", "default": "./dist/model/map/annotations.js" diff --git a/packages/slides/src/build/index.ts b/packages/slides/src/build/index.ts index 890e518..38ecb88 100644 --- a/packages/slides/src/build/index.ts +++ b/packages/slides/src/build/index.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { loadSlidesConfig, getAppEnvironment, booleanOption, type LoadSlidesConfigOptions } from "../content/config.ts"; import { loadContent } from "../content/index.ts"; import { buildThumbnails, thumbnailPaths } from "./thumbnails.ts"; -import { prepareRunner } from "./runner.ts"; +import { prepareRunner, writeRunnerTsconfig } from "./runner.ts"; import { prepareIiif } from "./iiif.ts"; import { runNode } from "./process.ts"; export { buildThumbnails } from "./thumbnails.ts"; @@ -26,17 +26,24 @@ export async function runSite(command: "dev" | "build" | "preview" | "check" | " if (command === "build" && booleanOption(env.SLIDES_THUMBNAILS_ENABLED, true)) await buildThumbnails(config); if (command === "build") await prepareIiif(config); const require = createRequire(import.meta.url); + const syncRunner = async (runner: string) => { + await runNode(path.join(path.dirname(require.resolve("@sveltejs/kit/package.json")), "svelte-kit.js"), ["sync"], { cwd: runner, env }); + await writeRunnerTsconfig(config, runner); + }; if (command === "check") { // svelte-check deliberately skips node_modules, including Vite's default // cache location. A temporary linked workspace keeps diagnostics effective. const runner = await realpath(await mkdtemp(path.join(tmpdir(), "slides-check-"))); try { await prepareRunner(config, runner); - await runNode(path.join(path.dirname(require.resolve("@sveltejs/kit/package.json")), "svelte-kit.js"), ["sync"], { cwd: runner, env }); + await syncRunner(runner); return await runNode(path.join(path.dirname(require.resolve("svelte-check/package.json")), "bin/svelte-check"), ["--tsconfig", "./tsconfig.json", ...args], { cwd: runner, env }); } finally { await rm(runner, { recursive: true, force: true }); } } const runner = await prepareRunner(config); + // Vite reads the runner's tsconfig while bundling its config, before the + // SvelteKit plugin can generate the base config that it extends. + await syncRunner(runner); return runNode(path.join(path.dirname(require.resolve("vite/package.json")), "bin/vite.js"), [command, ...args], { cwd: runner, env }); } export const buildSite = (options: LoadSlidesConfigOptions = {}) => runSite("build", options); diff --git a/packages/slides/src/build/layers.ts b/packages/slides/src/build/layers.ts index 8879821..af75694 100644 --- a/packages/slides/src/build/layers.ts +++ b/packages/slides/src/build/layers.ts @@ -1,6 +1,5 @@ -import { parseAnnotation, type GeoreferencedMap } from "@allmaps/annotation"; +import { parseAnnotation } from "@allmaps/annotation"; import type { WarpedMap } from "@allmaps/render"; -import { createFauxGeoreferencedMap } from "../model/map/image.ts"; import type { ThemeMode, WarpedMapProps } from "../model/types.ts"; import { getWarpedMapOptions } from "../model/map/annotations.ts"; import { recipeHash, type RemoteCache, type CachedResource } from "@allmaps/static-render/cache"; @@ -12,7 +11,7 @@ export type LoadedLayer = { maps: WarpedMap[]; effects: WarpedMapEffects; revision: string; - annotation?: CachedResource; + annotation: CachedResource; }; export async function loadLayer( @@ -22,21 +21,10 @@ export async function loadLayer( theme: ThemeMode = "light", ): Promise { const { options, effects } = staticMapOptions(getWarpedMapOptions(props, theme), props.url); - let annotation: CachedResource | undefined; - let maps: GeoreferencedMap[]; - if (props.type === "Image") - maps = [ - await createFauxGeoreferencedMap(props.url, { - ...props, - fetchFn: sources.fetch, - }), - ]; - else { - annotation = await (/^https?:\/\//.test(props.url) - ? annotations.get(props.url) - : sources.get(props.url)); - maps = parseAnnotation(JSON.parse(annotation.bytes.toString())); - } + const annotation = await (/^https?:\/\//.test(props.url) + ? annotations.get(props.url) + : sources.get(props.url)); + const maps = parseAnnotation(JSON.parse(annotation.bytes.toString())); if (!maps.length) throw new Error(`No georeferenced maps: ${props.url}`); const imageRevisions = await Promise.all( maps.map((map) => sources.imageRevision(map.resource.id)), diff --git a/packages/slides/src/build/prepare.ts b/packages/slides/src/build/prepare.ts index acc5d62..f55c3f4 100644 --- a/packages/slides/src/build/prepare.ts +++ b/packages/slides/src/build/prepare.ts @@ -6,12 +6,10 @@ import { getEffectiveBasemapStyleConfig, getEffectiveBasemapLayerState, } from "../model/basemap.ts"; -import { getGeoJsonLayers } from "../model/geojson.ts"; import { - prepareUserLayers, applyUserLayerChanges, } from "../model/map/layers.ts"; -import { layerPreviewKey, hidesBasemap } from "../model/map/annotations.ts"; +import { layerPreviewKey } from "../model/map/annotations.ts"; import { resolveChapterCamera, type Camera } from "../model/map/camera.ts"; import { emptyThumbnails, @@ -93,7 +91,7 @@ export async function prepareThumbnails(content: ContentSnapshot, options: { options: map.mapOptions, })), }; - if (layer.annotation && /^https?:/.test(props.url)) { + if (/^https?:/.test(props.url)) { plan.resources[props.url] = { base64: layer.annotation.bytes.toString("base64"), extension: "json", @@ -154,7 +152,7 @@ export async function prepareThumbnails(content: ContentSnapshot, options: { const styles = sceneStyles( style, state, - hidesBasemap(chapter), + !!chapter.hideBasemap, resolvedSources, overlayLayers, ); @@ -187,13 +185,7 @@ export async function prepareThumbnails(content: ContentSnapshot, options: { light: { center: [0, 0], zoom: 14, bearing: 0 }, dark: { center: [0, 0], zoom: 14, bearing: 0 }, }; - let overlays: LayerSpecification[] = Object.entries( - slideshow.sources, - ).flatMap(([id, source]) => - source.type === "geojson" - ? prepareUserLayers(getGeoJsonLayers(id)) - : [], - ); + const defaultOverlays = slideshow.layers ?? []; const chapters = [ ...(slideshow.start ? [slideshow.start] : []), ...slideshow.chapters, @@ -203,8 +195,7 @@ export async function prepareThumbnails(content: ContentSnapshot, options: { throw new Error( "Warped sprite previews are not supported; use IIIF image sources", ); - // User-layer changes advance once per chapter, independently of theme. - overlays = applyUserLayerChanges(overlays, chapter.layers); + const overlays = applyUserLayerChanges(defaultOverlays, chapter.layers); const previews = {} as Record; for (const theme of ["light", "dark"] as const) { const selected = await Promise.all( @@ -238,7 +229,7 @@ export async function prepareThumbnails(content: ContentSnapshot, options: { }); } const previousCamera = cameras[theme]; - const camera = resolveChapterCamera(chapter, mapsFor, size, 20, previousCamera); + const camera = resolveChapterCamera(chapter, mapsFor, size, chapter.padding ?? 20, previousCamera); cameras[theme] = camera; if (!("slug" in chapter)) continue; previews[theme] = await renderScene( @@ -255,7 +246,7 @@ export async function prepareThumbnails(content: ContentSnapshot, options: { chapter, mapsFor, socialSize, - 32, + chapter.padding ?? 32, previousCamera, ); manifest.social[slideshow.id] = await renderScene( diff --git a/packages/slides/src/build/runner.ts b/packages/slides/src/build/runner.ts index 8bc6c7c..534be4b 100644 --- a/packages/slides/src/build/runner.ts +++ b/packages/slides/src/build/runner.ts @@ -13,12 +13,9 @@ export async function prepareRunner(config: RuntimeSlidesConfig, runner = path.j await atomicWrite(path.join(runner, "package.json"), JSON.stringify({ private: true, type: "module", dependencies: packageJson.dependencies })); await atomicWrite(path.join(runner, "vite.config.js"), `export { default } from ${JSON.stringify(pathToFileURL(path.join(config.appDir, "vite.config.js")).href)};\n`); await atomicWrite(path.join(runner, "svelte.config.js"), `import * as app from ${JSON.stringify(pathToFileURL(path.join(config.appDir, "svelte.config.js")).href)};\nexport default app.createSlidesConfig ? app.createSlidesConfig() : app.default;\n`); - await atomicWrite(path.join(runner, "tsconfig.json"), JSON.stringify({ - extends: path.join(config.workDir, "svelte-kit", "tsconfig.json"), - compilerOptions: { rewriteRelativeImportExtensions: true, allowJs: true, checkJs: true, esModuleInterop: true, skipLibCheck: true, strict: true, moduleResolution: "bundler", rootDirs: [runner, config.appDir, path.join(config.workDir, "svelte-kit/types")] }, - include: [path.join(runner, "src/**/*.ts"), path.join(runner, "src/**/*.js"), path.join(runner, "src/**/*.svelte"), path.join(config.workDir, "svelte-kit/**/*.d.ts")], - exclude: [], - }, null, 2)); + // Sync may itself bundle the app config. Do not expose an extends reference + // until SvelteKit has generated its target, including when reusing a runner. + await rm(path.join(runner, "tsconfig.json"), { force: true }); for (const name of ["src", "static"]) { const source = path.join(config.appDir, name), link = path.join(runner, name); if (await exists(source) && !await exists(link)) await symlink(source, link, "junction"); @@ -53,3 +50,12 @@ export async function prepareRunner(config: RuntimeSlidesConfig, runner = path.j await symlink(modules, runnerModules, "junction"); return runner; } + +export async function writeRunnerTsconfig(config: RuntimeSlidesConfig, runner: string) { + await atomicWrite(path.join(runner, "tsconfig.json"), JSON.stringify({ + extends: path.join(config.workDir, "svelte-kit", "tsconfig.json"), + compilerOptions: { rewriteRelativeImportExtensions: true, allowJs: true, checkJs: true, esModuleInterop: true, skipLibCheck: true, strict: true, moduleResolution: "bundler", rootDirs: [runner, config.appDir, path.join(config.workDir, "svelte-kit/types")] }, + include: [path.join(runner, "src/**/*.ts"), path.join(runner, "src/**/*.js"), path.join(runner, "src/**/*.svelte"), path.join(config.workDir, "svelte-kit/**/*.d.ts")], + exclude: [], + }, null, 2)); +} diff --git a/packages/slides/src/build/styles.ts b/packages/slides/src/build/styles.ts index 94ce94d..0f6ac13 100644 --- a/packages/slides/src/build/styles.ts +++ b/packages/slides/src/build/styles.ts @@ -1,5 +1,6 @@ import type { LayerSpecification, SourceSpecification, StyleSpecification } from "maplibre-gl"; import { getLayerWithVisibility, type EffectiveBasemapLayerState, type ResolvedBasemapStyle } from "../model/basemap.ts"; +import { getUserLayerGlyphs } from "../model/map/layers.ts"; export function sceneStyles( style: ResolvedBasemapStyle, state: EffectiveBasemapLayerState, @@ -26,10 +27,9 @@ export function sceneStyles( const make = (layers: LayerSpecification[]): StyleSpecification => ({ version: 8, sources: { ...style.sources, ...sources }, - glyphs: style.glyphs, + glyphs: style.glyphs ?? getUserLayerGlyphs(layers), sprite: style.sprite, layers: layers.filter((layer) => layer.layout?.visibility !== "none"), }); return { lower: make(lower), upper: make(upper) }; } - diff --git a/packages/slides/src/cli/commands/init.ts b/packages/slides/src/cli/commands/init.ts new file mode 100644 index 0000000..b8a13e8 --- /dev/null +++ b/packages/slides/src/cli/commands/init.ts @@ -0,0 +1,183 @@ +import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { createInterface } from "node:readline/promises"; +import { stringify } from "yaml"; +import { CommandInterruptedError } from "../../build/process.ts"; +import { packageRoot } from "../../package.ts"; + +type InitOptions = { title?: string; protomapsKey?: string; yes?: boolean }; + +async function projectSettings(options: InitOptions) { + const defaultTitle = "My narrative map"; + let title = options.title?.trim(); + let key = options.protomapsKey?.trim(); + if (title === "") throw new Error("The project title must not be empty."); + + if (!options.yes && process.stdin.isTTY && process.stdout.isTTY && + (title === undefined || key === undefined)) { + const readline = createInterface({ input: process.stdin, output: process.stdout }); + const cancellation = new AbortController(); + const cancel = () => cancellation.abort(); + readline.on("SIGINT", cancel); + readline.on("close", cancel); + try { + console.log("You can change the title and Protomaps key later in slides.config.yml.\n"); + if (title === undefined) { + title = (await readline.question(`Project title [${defaultTitle}]: `, { signal: cancellation.signal })).trim() || defaultTitle; + } + if (key === undefined) { + console.log("\nGet an optional key at https://protomaps.com/api. It will be included in your public website."); + key = (await readline.question("Protomaps API key (optional; Enter for no basemap): ", { signal: cancellation.signal })).trim(); + } + } catch (error) { + if (cancellation.signal.aborted) { + console.log("\nProject creation cancelled. No files were written."); + throw new CommandInterruptedError("SIGINT"); + } + throw error; + } finally { + readline.off("SIGINT", cancel); + readline.off("close", cancel); + readline.close(); + } + } + return { title: title ?? defaultTitle, key: key ?? "" }; +} + +async function entryExists(filename: string) { + try { return await lstat(filename); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +export async function runInitCommand(directory = ".", options: InitOptions = {}) { + const root = path.resolve(directory); + const existing = await entryExists(root); + if (existing && !existing.isDirectory()) throw new Error(`Project path must be a directory: ${root}`); + + // Reserve the whole chapter directory and every supported config filename so + // an existing project cannot accidentally become part of the starter. + const reserved = ["package.json", "pnpm-workspace.yaml", ".gitignore", "README.md", "chapters", + "slides.config.yml", "slides.config.yaml", "slides.config.json"]; + const conflicts = []; + for (const filename of reserved) { + if (await entryExists(path.join(root, filename))) conflicts.push(filename); + } + if (conflicts.length) { + throw new Error(`Cannot initialize ${root}: already contains ${conflicts.join(", ")}. Choose an empty folder; existing files have not been changed.`); + } + + const { title, key } = await projectSettings(options); + const { version } = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8")); + const name = path.basename(root).toLowerCase().replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "").slice(0, 214) || "slides-project"; + const files: Record = { + "package.json": JSON.stringify({ + name, + private: true, + type: "module", + scripts: { + dev: "slides dev .", + build: "slides build .", + preview: "slides preview .", + validate: "slides validate .", + check: "slides check .", + }, + devDependencies: { "@allmaps/slides": version }, + packageManager: "pnpm@10.22.0", + engines: { node: ">=24" }, + }, null, 2) + "\n", + "pnpm-workspace.yaml": `# Keep this project's dependencies and lockfile independent of parent workspaces. +packages: [] + +onlyBuiltDependencies: + - '@maplibre/maplibre-gl-native' + - esbuild + - sharp +`, + ".gitignore": `node_modules/ +.pnpm-store/ +.slides/ +.svelte-kit/ +dist/ +static/iiif/ +.env +.env.* +!.env.example +.DS_Store +`, + "slides.config.yml": stringify({ + title, + main: "main", + slideshows: [{ id: "main", path: "chapters" }], + protomaps: { key }, + }), + "chapters/01-welcome.md": `--- +title: Rokovoko +location: + center: [-145, -12] + zoom: 3 +--- + +> Queequeg was a native of Rokovoko, an island far away to the West and South. It is not down on any map; true places never are. + +— Herman Melville, *Moby-Dick*, [Chapter 12](https://www.gutenberg.org/files/2701/2701-h/2701-h.htm#link2HCH0012) + +Edit this slide and add more Markdown files in the chapters folder. +`, + "README.md": `# My Allmaps Slides project + +Create a narrative map with [Allmaps Slides](https://github.com/allmaps/slides). + +## Preview + +Use Node.js 24 or later and pnpm 10: + +\`\`\`sh +pnpm install +pnpm dev +\`\`\` + +Open the URL printed in the terminal. Edit \`chapters/01-welcome.md\` and add more +Markdown files in \`chapters/\`. +Commit your lockfile to keep builds reproducible. + +Change the title and optional \`protomaps.key\` in \`slides.config.yml\` at any time. +With no key, the example uses a plain background. Get your own key at +[protomaps.com/api](https://protomaps.com/api) to add a basemap; it is included +in the public website. To add maps and images, +see [authoring](https://github.com/allmaps/slides/blob/main/docs/authoring.md) and +[basemap setup](https://github.com/allmaps/slides/blob/main/docs/configuration.md#basemaps). + +## Build and publish + +\`\`\`sh +pnpm validate +pnpm build +pnpm preview +\`\`\` + +The static website is written to \`dist/\`. See +[deployment](https://github.com/allmaps/slides/blob/main/docs/deployment.md) for +public URLs, hosting and Linux rendering dependencies. + +Choose a license for your own content before sharing it. The Slides software +has [separate licensing terms](https://github.com/allmaps/slides/blob/main/docs/licensing.md). +`, + }; + + await mkdir(root, { recursive: true }); + await mkdir(path.join(root, "chapters")); + for (const [filename, contents] of Object.entries(files)) { + // Exclusive writes also protect files created after the preflight check. + await writeFile(path.join(root, filename), contents, { flag: "wx" }); + } + console.log(`Created an Allmaps Slides project in ${root}.`); + console.log("\nNext steps:"); + if (root !== process.cwd()) console.log(` Open ${root} in your terminal.`); + console.log(" pnpm install\n pnpm dev"); + console.log("\nEdit chapters/01-welcome.md to begin."); + console.log("You can change the title and Protomaps key later in slides.config.yml."); +} diff --git a/packages/slides/src/cli/main.js b/packages/slides/src/cli/main.js index c24947a..2022c4e 100644 --- a/packages/slides/src/cli/main.js +++ b/packages/slides/src/cli/main.js @@ -8,6 +8,7 @@ import { runBuildIiifCommand } from "./commands/build-iiif.ts"; import { runAppCommand } from "./commands/run-app.ts"; import { runValidateCommand } from "./commands/validate.ts"; import { runCachePurgeCommand } from "./commands/cache.ts"; +import { runInitCommand } from "./commands/init.ts"; const program = new Command(); @@ -42,6 +43,15 @@ program .showHelpAfterError() .showSuggestionAfterError(); +program.command("init") + .alias("create") + .description("Create a minimal Slides project without overwriting existing files") + .argument("[directory]", "New project directory", ".") + .option("--title ", "Project title (prompted when omitted)") + .option("--protomaps-key <key>", "Optional Protomaps API key (prompted when omitted)") + .option("-y, --yes", "Skip prompts and use defaults for omitted values") + .action((directory, options) => runInitCommand(directory, options)); + addAppCommand("thumbnails", "Generate map thumbnails without building the app"); addAppCommand("dev", "Validate content and start the Slides dev server"); addAppCommand("build", "Validate content and build the static app"); diff --git a/packages/slides/src/model/basemap.ts b/packages/slides/src/model/basemap.ts index 58ff393..de8b302 100644 --- a/packages/slides/src/model/basemap.ts +++ b/packages/slides/src/model/basemap.ts @@ -13,6 +13,7 @@ import { DEFAULT_DARK_FLAVOR, DEFAULT_LIGHT_FLAVOR, DEFAULT_LOCALE, + DEFAULT_GLYPHS, } from "./settings.ts"; import type { BasemapLabelPosition, @@ -30,8 +31,6 @@ export const FOREGROUND_LAYER_ID = "foreground"; const PROTOMAPS_SOURCE_ID = "protomaps"; const PROTOMAPS_ATTRIBUTION = '<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>'; -const DEFAULT_GLYPHS = - "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf"; const DEFAULT_SPRITES = { light: "https://protomaps.github.io/basemaps-assets/sprites/v4/light", dark: "https://protomaps.github.io/basemaps-assets/sprites/v4/dark", @@ -331,7 +330,7 @@ const resolveConfiguredStyle = async ( } catch (error) { if (strict) throw error; console.warn( - `Could not load basemap style ${styleReference}. Falling back to Protomaps.\n - ${error instanceof Error ? error.message : String(error)}`, + `Could not load basemap style ${styleReference}. Using fallback basemap settings.\n - ${error instanceof Error ? error.message : String(error)}`, ); return undefined; @@ -542,8 +541,11 @@ export const resolveBasemapStyle = async ({ const configuredStyle = config.style ? await resolveConfiguredStyle(config.style, fetchFn, strict, mapStyleFiles) : undefined; - const style = - configuredStyle ?? createProtomapsStyle(theme, config.protomaps); + const style: StyleSpecification = configuredStyle ?? ( + config.protomaps.key?.trim() + ? createProtomapsStyle(theme, config.protomaps) + : { version: 8, sources: {}, layers: [] } + ); const foregroundColor = getForegroundColor( style, config.foregroundColor, @@ -569,7 +571,7 @@ export const resolveBasemapStyle = async ({ defaultVisibilityById: new Map( allLayers.map((layer) => [layer.id, getLayerDefaultVisibility(layer)]), ), - glyphs: style.glyphs, + glyphs: style.glyphs ?? config.protomaps.glyphs, sprite: style.sprite, foregroundColor, }; diff --git a/packages/slides/src/model/content-schema.ts b/packages/slides/src/model/content-schema.ts index ba3536e..cab50e3 100644 --- a/packages/slides/src/model/content-schema.ts +++ b/packages/slides/src/model/content-schema.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import type { MapLibreWarpedMapLayerOptions } from "@allmaps/maplibre"; import type { SlidesConfig } from "./types.ts"; -import type { StyleSpecification } from "maplibre-gl"; +import type { LayerSpecification, StyleSpecification } from "maplibre-gl"; import { THEME_NAMES } from "./theme.ts"; const nullableToUndefined = (value: unknown) => @@ -49,12 +49,6 @@ const optionalArray = <Schema extends z.ZodType>(schema: Schema) => }, z.array(schema).optional()); const coordinateSchema = z.tuple([z.number(), z.number()]); -const fourNumberTupleSchema = z.tuple([ - z.number(), - z.number(), - z.number(), - z.number(), -]); const themeModeSchema = z.enum(["light", "dark"]); const themeColorSchema = z.string().trim().regex( /^#(?:[\da-f]{3}|[\da-f]{6})$/i, @@ -124,8 +118,6 @@ const protomapsConfigSchema = z const startScreenTextSchema = z .object({ startButton: optionalString, - chapterCountSingular: optionalString, - chapterCountPlural: optionalString, madeWith: optionalString, }) .passthrough(); @@ -148,11 +140,27 @@ export const mapConfigSchema = z const mapLayerSchema = z.object({ layer: nonEmptyString, - opacity: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), visibility: z.enum(["visible", "none"]).optional(), - duration: z.number().optional(), + duration: z.number().nonnegative().optional(), }); +const customLayerSchema = z.object({ + id: nonEmptyString, + type: z.enum(["fill", "line", "symbol", "circle", "heatmap", "fill-extrusion", "raster", "hillshade", "background", "color-relief"]), + source: optionalNonEmptyString, + layout: z.object({ visibility: z.enum(["visible", "none"]).optional() }).passthrough().optional(), + paint: unknownRecordSchema.optional(), + filter: z.array(z.unknown()).optional(), + minzoom: z.number().min(0).max(24).optional(), + maxzoom: z.number().min(0).max(24).optional(), +}).passthrough().superRefine((layer, ctx) => { + if (layer.type !== "background" && !layer.source) + ctx.addIssue({ code: "custom", path: ["source"], message: "provide a source for this layer" }); +}).transform(layer => layer as LayerSpecification); + +const userLayerConfigSchema = z.union([customLayerSchema, mapLayerSchema]); + const subslideshowReferenceSchema = z.union([ nonEmptyString, z.object({ @@ -168,7 +176,6 @@ const warpedMapOptionsSchema = z.custom<Partial<MapLibreWarpedMapLayerOptions>>( const warpedMapSchema = z .object({ - type: z.literal("Image").optional(), url: optionalNonEmptyString, path: optionalNonEmptyString, caption: optionalString, @@ -179,8 +186,6 @@ const warpedMapSchema = z useZoom: z.boolean().optional(), options: warpedMapOptionsSchema.optional(), darkOptions: warpedMapOptionsSchema.optional(), - region: fourNumberTupleSchema.optional(), - wiggle: z.boolean().optional(), }) .passthrough() .refine((warpedMap) => warpedMap.url || warpedMap.path, { @@ -210,12 +215,9 @@ const mapChapterPropsSchema = z dimensions: coordinateSchema, }), ), - caption: optionalString, - freeze: z.boolean().optional(), - padding: z.number().optional(), fit: z.enum(["cover", "contain", "equal"]).optional(), + padding: z.number().optional(), hideBasemap: z.boolean().optional(), - contain: z.boolean().optional(), warpedMaps: optionalArray(warpedMapSchema), layers: optionalArray(mapLayerSchema), }) @@ -293,6 +295,7 @@ export const slidesConfigSchema = z .record(z.string(), sourceDefinitionSchema) .optional() .default({}), + layers: optionalArray(userLayerConfigSchema), map: mapConfigSchema.optional(), protomaps: protomapsConfigSchema.optional(), interface: interfaceConfigSchema.optional(), @@ -397,6 +400,7 @@ export const parseSlidesConfig = ( (config.slideshows.length === 1 ? config.slideshows[0].id : "main"), slideshows: config.slideshows, sources: config.sources, + layers: config.layers, map: config.map, protomaps: config.protomaps, interface: config.interface, diff --git a/packages/slides/src/model/geojson.ts b/packages/slides/src/model/geojson.ts index 4d6e951..de61f39 100644 --- a/packages/slides/src/model/geojson.ts +++ b/packages/slides/src/model/geojson.ts @@ -73,7 +73,7 @@ export const getGeoJsonLayers = ( ) => { return [ { - id: `user-${sourceId}-fill`, + id: `${sourceId}-fill`, type: "fill", source: sourceId, layout: { @@ -91,7 +91,7 @@ export const getGeoJsonLayers = ( filter: ["==", "$type", "Polygon"], }, { - id: `user-${sourceId}-line`, + id: `${sourceId}-line`, type: "line", source: sourceId, layout: { @@ -119,7 +119,7 @@ export const getGeoJsonLayers = ( filter: ["in", "$type", "LineString", "Polygon"], }, { - id: `user-${sourceId}-point-circle`, + id: `${sourceId}-point-circle`, type: "circle", source: sourceId, layout: { @@ -150,7 +150,7 @@ export const getGeoJsonLayers = ( filter: ["all", ["==", "$type", "Point"], ["!has", "icon-image"]], }, { - id: `user-${sourceId}-point-symbol`, + id: `${sourceId}-point-symbol`, type: "symbol", source: sourceId, layout: { diff --git a/packages/slides/src/model/map/annotations.ts b/packages/slides/src/model/map/annotations.ts index 39a7bc4..9f5e592 100644 --- a/packages/slides/src/model/map/annotations.ts +++ b/packages/slides/src/model/map/annotations.ts @@ -18,12 +18,6 @@ export const getUniqueAnnotations = (annotations: WarpedMapProps[]) => { export const getAnnotationsFromChapters = (chapters: MapChapterProps[]) => getUniqueAnnotations(chapters.flatMap((chapter) => chapter.warpedMaps ?? [])); -export const hidesBasemap = (chapter: MapChapterProps) => - !!( - chapter.hideBasemap || - chapter.warpedMaps?.some((map) => map.type === "Image") - ); - export function stableStringify(value: unknown): string { return JSON.stringify(value, (_, entry) => entry && typeof entry === "object" && !Array.isArray(entry) @@ -34,15 +28,12 @@ export function stableStringify(value: unknown): string { ); } -/** URL stays the authored identity; variants include crop/transformation/effects. */ +/** URL stays the authored identity; variants include transformation/effects. */ export const layerPreviewKey = ({ url, - type, - region, - wiggle, options, darkOptions, }: WarpedMapProps, theme: ThemeMode = "light") => stableStringify({ - url, type, region, wiggle, + url, options: theme === "dark" && darkOptions ? { ...options, ...darkOptions } : options, }); diff --git a/packages/slides/src/model/map/camera.ts b/packages/slides/src/model/map/camera.ts index 9621953..9d155e1 100644 --- a/packages/slides/src/model/map/camera.ts +++ b/packages/slides/src/model/map/camera.ts @@ -1,9 +1,10 @@ import { computeWarpedMapBearing } from "@allmaps/bearing"; import { webMercatorToLonLat } from "@allmaps/project"; -import { computeRotatedBboxProperties } from "@allmaps/stdlib"; +import { computeRotatedBboxProperties, sizesToScale } from "@allmaps/stdlib"; import type { WarpedMap } from "@allmaps/render"; import type { PaddingOptions, PointLike } from "maplibre-gl"; import type { MapChapterProps, WarpedMapProps } from "../types.ts"; +import { DEFAULT_PADDING } from "../settings.ts"; export const WORLD_WIDTH = 40075016.68557849; export const TILE_SIZE = 512; @@ -19,6 +20,25 @@ export type CameraLayoutOptions = { export const unitsPerPixel = (zoom: number) => WORLD_WIDTH / (TILE_SIZE * 2 ** zoom); +/** + * Add a signed inner margin without changing the UI's layout offset. + * Negative results are for our fitting calculation, not MapLibre's native padding. + */ +export function getCameraPadding( + layoutPadding: number | PaddingOptions = 0, + padding = DEFAULT_PADDING, +): PaddingOptions { + const layout = typeof layoutPadding === "number" + ? { top: layoutPadding, right: layoutPadding, bottom: layoutPadding, left: layoutPadding } + : layoutPadding; + return { + top: (layout.top ?? 0) + padding, + right: (layout.right ?? 0) + padding, + bottom: (layout.bottom ?? 0) + padding, + left: (layout.left ?? 0) + padding, + }; +} + export function getCameraLayoutOptions( padding: number | PaddingOptions, ): CameraLayoutOptions { @@ -45,7 +65,7 @@ export function resolveChapterCamera( chapter: MapChapterProps, getMaps: (annotation: WarpedMapProps) => WarpedMap[], size: [number, number], - padding: number | PaddingOptions = 25, + padding: number | PaddingOptions = chapter.padding ?? DEFAULT_PADDING, previous: Camera = { center: [0, 0], zoom: 14, bearing: 0 }, ): Camera { const annotations = chapter.warpedMaps ?? []; @@ -70,11 +90,15 @@ export function resolveChapterCamera( typeof padding === "number" ? { left: padding, right: padding, top: padding, bottom: padding } : padding; + // Negative margins enlarge the fit area, zooming beyond its visible edges. const width = Math.max(1, size[0] - (p.left ?? 25) - (p.right ?? 25)); const height = Math.max(1, size[1] - (p.top ?? 25) - (p.bottom ?? 25)); - const scale = Math.max( - (bbox[2] - bbox[0]) / width, - (bbox[3] - bbox[1]) / height, + // Use the same fit modes as Allmaps' getMapCenterZoomBearing, with the + // available viewport shared by the live map and generated previews. + const scale = sizesToScale( + [bbox[2] - bbox[0], bbox[3] - bbox[1]], + [width, height], + chapter.fit ?? "contain", ); camera = { center: webMercatorToLonLat(center), diff --git a/packages/slides/src/model/map/image.ts b/packages/slides/src/model/map/image.ts deleted file mode 100644 index 1f6d86d..0000000 --- a/packages/slides/src/model/map/image.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { getCoords, rhumbDestination, point } from "@turf/turf"; -import type { GeoreferencedMap } from "@allmaps/annotation"; -type Coord = [number, number]; -type FauxGcp = { resource: Coord; geo: Coord }; - -export const createFauxGeoreferencedMap = async ( - imageId: string, - options: { - bounds?: [number, number, number, number]; - region?: [number, number, number, number]; - center?: [number, number]; - bearing?: number; - wiggle?: boolean; - fetchFn?: typeof fetch; - }, -) => { - let { region, bounds, center } = options; - // if (!bounds) { - // bounds = [-0.1, -0.1, 0.1, 0.1] - // } - if (!center) { - center = [0, 0]; - } - const imageInfo = (await (options.fetchFn ?? fetch)( - `${imageId}/info.json`, - ).then((response) => { - if (!response.ok) throw new Error(`Image metadata: ${response.status}`); - return response.json(); - })) as { - width: number; - height: number; - }; - const { width, height } = imageInfo; - let gcps: FauxGcp[]; - let [resourceX, resourceY, resourceWidth, resourceHeight] = [ - 0, - 0, - width, - height, - ]; - if (region) { - [resourceX, resourceY, resourceWidth, resourceHeight] = region; - } - const resourceMask: Coord[] = [ - [resourceX, resourceY], - [resourceX + resourceWidth, resourceY], - [resourceX + resourceWidth, resourceY + resourceHeight], - [resourceX, resourceY + resourceHeight], - ]; - if (bounds) { - const [xMin, yMin, xMax, yMax] = bounds; - gcps = [ - { - resource: [resourceX, resourceY + resourceHeight], - geo: [xMin, yMin], - }, - { - resource: [resourceX + resourceWidth, resourceY], - geo: [xMax, yMax], - }, - ]; - } else { - const landscape = resourceWidth > resourceHeight; - let bearing = landscape ? -90 : 0; - if (options.wiggle) { - bearing += 2.5; - } - const centerX = Math.round(resourceX + resourceWidth / 2); - const centerY = Math.round(resourceY + resourceHeight / 2); - gcps = [ - { - resource: [centerX, centerY], - geo: center, - }, - { - resource: landscape ? [resourceX, centerY] : [centerX, resourceY], - geo: getCoords(rhumbDestination(point(center), 100, bearing)) as Coord, - }, - ]; - } - return { - ["@context"]: "https://schemas.allmaps.org/map/2/context.json", - id: imageId, - type: "GeoreferencedMap", - resource: { - id: imageId, - width, - height, - type: "ImageService3", - }, - gcps, - resourceMask, - transformation: { - type: "helmert", - }, - } satisfies GeoreferencedMap; -}; diff --git a/packages/slides/src/model/map/layers.ts b/packages/slides/src/model/map/layers.ts index 0de326d..30da815 100644 --- a/packages/slides/src/model/map/layers.ts +++ b/packages/slides/src/model/map/layers.ts @@ -1,10 +1,52 @@ -import type { LayerSpecification } from "maplibre-gl"; -import type { MapLayerProps } from "../types.ts"; -import { LAYER_TYPES } from "../settings.ts"; +import type { LayerSpecification, SourceSpecification } from "maplibre-gl"; +import type { MapLayerProps, UserLayerConfig } from "../types.ts"; +import { DEFAULT_GLYPHS, LAYER_TYPES } from "../settings.ts"; +import { getGeoJsonLayers } from "../geojson.ts"; + +export const getUserLayerGlyphs = (layers: LayerSpecification[]) => + layers.some(layer => layer.type === "symbol" && layer.layout?.["text-field"]) + ? DEFAULT_GLYPHS : undefined; /** Keep authored layer IDs/order consistent between MapLibre and static scenes. */ export const prepareUserLayers = (layers: LayerSpecification[]) => - layers.map((layer) => ({ ...layer, id: `user-${layer.id}` })).reverse(); + layers.map((layer) => ({ ...layer, id: `user-${layer.id}` })); + +/** Generated layers first, custom layers above them, in authored drawing order. */ +export function resolveUserLayers( + sources: Record<string, SourceSpecification>, + config: UserLayerConfig[] = [], +): LayerSpecification[] { + const layers = new Map(Object.entries(sources).flatMap(([id, source]) => + source.type === "geojson" ? getGeoJsonLayers(id).map(layer => [layer.id, layer] as const) : [], + ) as [string, LayerSpecification][]); + const customIds = new Set<string>(); + const defaults: MapLayerProps[] = []; + for (const entry of config) { + if ("id" in entry) { + if (customIds.has(entry.id)) throw new Error(`Duplicate layer id: ${entry.id}`); + customIds.add(entry.id); + if ("source" in entry && !sources[entry.source]) + throw new Error(`Unknown source ${entry.source} for layer ${entry.id}`); + layers.delete(entry.id); + layers.set(entry.id, entry); + } else { + defaults.push(entry); + } + } + const prepared = prepareUserLayers([...layers.values()]); + validateUserLayerChanges(prepared, defaults, "global layers"); + return applyUserLayerChanges(prepared, defaults); +} + +export function validateUserLayerChanges(layers: LayerSpecification[], changes: MapLayerProps[] = [], context: string) { + const ids = new Set(layers.map(layer => layer.id)); + const seen = new Set<string>(); + for (const change of changes) { + if (!ids.has(`user-${change.layer}`)) throw new Error(`Unknown layer ${change.layer} in ${context}`); + if (seen.has(change.layer)) throw new Error(`Duplicate layer ${change.layer} in ${context}`); + seen.add(change.layer); + } +} export function getUserLayerChange(change: MapLayerProps, type: string) { const properties = LAYER_TYPES[type as keyof typeof LAYER_TYPES] ?? []; @@ -31,10 +73,14 @@ export function applyUserLayerChanges( ); if (!change) return layer; const { visibility, paint } = getUserLayerChange(change, layer.type); + const transitions = change.duration === undefined ? {} : Object.fromEntries( + (LAYER_TYPES[layer.type as keyof typeof LAYER_TYPES] ?? []) + .map(property => [`${property}-transition`, { duration: change.duration }]), + ); return { ...layer, ...(visibility ? { layout: { ...layer.layout, visibility } } : {}), - paint: { ...layer.paint, ...paint }, + paint: { ...layer.paint, ...paint, ...transitions }, } as LayerSpecification; }); } diff --git a/packages/slides/src/model/project.ts b/packages/slides/src/model/project.ts index 1fc3886..5a8978a 100644 --- a/packages/slides/src/model/project.ts +++ b/packages/slides/src/model/project.ts @@ -1,5 +1,6 @@ import type { SourceSpecification } from "maplibre-gl"; import { parseSlideMetadata } from "./content-schema.ts"; +import { resolveUserLayers, validateUserLayerChanges } from "./map/layers.ts"; import type { SlidesConfig, MapChapter, @@ -102,9 +103,14 @@ export const buildProject = ( createSource(source, getContentAssetUrl), ]), ); + const layers = resolveUserLayers(sources, slidesConfig.layers); const slideshows: Slideshow[] = slidesConfig.slideshows.map( (rawSlideshow) => { const slideshow = normalizeSlideshow(rawSlideshow, slidesConfig.main); + const chapters = slidesBySlideshow.get(slideshow.path) ?? []; + validateUserLayerChanges(layers, slideshow.start?.layers, `${slideshow.id} start`); + for (const chapter of chapters) + validateUserLayerChanges(layers, chapter.layers, chapter.sourcePath); return { ...slideshow, @@ -112,8 +118,9 @@ export const buildProject = ( start: slideshow.start ? resolveWarpedMaps(slideshow.start, getContentAssetUrl) : undefined, - chapters: slidesBySlideshow.get(slideshow.path) ?? [], + chapters, sources, + layers, }; }, ); diff --git a/packages/slides/src/model/settings.ts b/packages/slides/src/model/settings.ts index c160d60..01a2ab3 100644 --- a/packages/slides/src/model/settings.ts +++ b/packages/slides/src/model/settings.ts @@ -56,8 +56,12 @@ export const DEFAULT_WARPED_MAP_OPTIONS: Partial<MapLibreWarpedMapLayerOptions> distortionMeasure: undefined, }; -// From https://github.com/digidem/maplibre-storymap/blob/main/demo/index.html +export const DEFAULT_GLYPHS = + "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf"; + export const LAYER_TYPES = { + background: ["background-opacity"], + "color-relief": ["color-relief-opacity"], fill: ["fill-opacity"], line: ["line-opacity"], circle: ["circle-opacity", "circle-stroke-opacity"], diff --git a/packages/slides/src/model/types.ts b/packages/slides/src/model/types.ts index b50c193..3498f61 100644 --- a/packages/slides/src/model/types.ts +++ b/packages/slides/src/model/types.ts @@ -1,6 +1,6 @@ import type { MapLibreWarpedMapLayerOptions } from "@allmaps/maplibre"; import type { Flavor } from "@protomaps/basemaps"; -import type { SourceSpecification, StyleSpecification } from "maplibre-gl"; +import type { LayerSpecification, SourceSpecification, StyleSpecification } from "maplibre-gl"; import type { ThemeConfig } from "./theme.ts"; export type ThemeMode = "light" | "dark"; @@ -47,8 +47,6 @@ export type MapConfig = { export type StartScreenTextConfig = { startButton?: string; - chapterCountSingular?: string; - chapterCountPlural?: string; madeWith?: string; }; @@ -77,6 +75,8 @@ export type SlidesConfig = { main: string; slideshows: SlideshowDefinition[]; sources: Record<string, SourceDefinition>; + /** Defaults for generated layers, plus optional custom MapLibre layers. */ + layers?: UserLayerConfig[]; map?: MapConfig; protomaps?: ProtomapsStyleConfig; interface?: InterfaceConfig; @@ -85,7 +85,6 @@ export type SlidesConfig = { }; export type WarpedMapProps = { - type?: "Image"; url: string; caption?: string; provenance?: string; @@ -96,8 +95,6 @@ export type WarpedMapProps = { options?: Partial<MapLibreWarpedMapLayerOptions>; /** Overrides merged over options when the interface is in dark mode. */ darkOptions?: Partial<MapLibreWarpedMapLayerOptions>; - region?: [number, number, number, number]; - wiggle?: boolean; }; export type MapLayerProps = { @@ -107,6 +104,8 @@ export type MapLayerProps = { duration?: number; }; +export type UserLayerConfig = MapLayerProps | LayerSpecification; + export type SubslideshowReference = | string | { @@ -127,12 +126,11 @@ export type MapChapterProps = { image: string; dimensions: [number, number]; }; - caption?: string; - freeze?: boolean; + /** Fit map bounds to the available viewport; defaults to contain. */ + fit?: "cover" | "contain" | "equal"; + /** Uniform inner fitting margin in pixels; negative values enlarge the fit area. */ padding?: number; - fit?: "cover" | "contain" | "equal" | undefined; hideBasemap?: boolean; - contain?: boolean; warpedMaps?: WarpedMapProps[]; layers?: MapLayerProps[]; subslideshows?: SubslideshowReference[]; @@ -185,4 +183,6 @@ export type Slideshow = { start?: MapChapterProps; chapters: MapChapter[]; sources: Record<string, SourceSpecification>; + /** Resolved global defaults, with internal layer IDs. */ + layers?: LayerSpecification[]; }; diff --git a/packages/slides/tests/basemap.test.mjs b/packages/slides/tests/basemap.test.mjs new file mode 100644 index 0000000..29dd375 --- /dev/null +++ b/packages/slides/tests/basemap.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { getEffectiveBasemapStyleConfig, resolveBasemapStyle } from '../src/model/basemap.ts'; + +test('no style or Protomaps key means no basemap resources in either theme', async () => { + for (const theme of ['light', 'dark']) { + for (const appProtomaps of [undefined, {}, { key: '' }, { key: ' ' }, { flavor: 'light' }]) { + const config = getEffectiveBasemapStyleConfig({ theme, appProtomaps }); + const style = await resolveBasemapStyle({ theme, config, + fetchFn: () => assert.fail('An unconfigured basemap must not fetch resources'), + }); + assert.deepEqual(style.sources, {}); + assert.deepEqual(style.layerIds, []); + assert.equal(style.glyphs, undefined); + assert.equal(style.sprite, undefined); + } + } +}); + +test('explicit Protomaps keys still enable the generated basemap and inherit per slide', async () => { + for (const theme of ['light', 'dark']) { + const config = getEffectiveBasemapStyleConfig({ theme, + appProtomaps: { key: 'project-key' }, + chapterMap: { protomaps: { key: 'slide-key' } }, + }); + const style = await resolveBasemapStyle({ theme, config }); + assert.equal(style.sources['basemap:protomaps'].url, 'https://api.protomaps.com/tiles/v4.json?key=slide-key'); + assert.ok(style.baseLayers.length > 0); + assert.ok(style.labelLayers.length > 0); + assert.ok(style.glyphs); + assert.ok(style.sprite); + } +}); + +test('custom styles work without a key and take precedence over Protomaps', async () => { + const custom = { version: 8, sources: {}, layers: [ + { id: 'background', type: 'background', paint: { 'background-color': '#123456' } }, + ] }; + for (const key of [undefined, 'project-key']) { + const config = getEffectiveBasemapStyleConfig({ theme: 'light', + appProtomaps: { key }, appMap: { styles: { light: custom } }, + }); + const style = await resolveBasemapStyle({ theme: 'light', config }); + assert.deepEqual(style.sources, {}); + assert.deepEqual(style.layerIds, ['basemap:background']); + assert.equal(style.foregroundColor, '#123456'); + } + const darkConfig = getEffectiveBasemapStyleConfig({ theme: 'dark', appMap: { styles: { light: custom } } }); + assert.deepEqual((await resolveBasemapStyle({ theme: 'dark', config: darkConfig })).layerIds, []); +}); diff --git a/packages/slides/tests/cli-config.test.mjs b/packages/slides/tests/cli-config.test.mjs index 67c323e..8aa4d67 100644 --- a/packages/slides/tests/cli-config.test.mjs +++ b/packages/slides/tests/cli-config.test.mjs @@ -4,6 +4,26 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises"; import path from "node:path"; import { tmpdir } from "node:os"; import { loadSlidesConfig, getAppEnvironment } from "../src/content/config.ts"; +import { getEffectiveBasemapStyleConfig, resolveBasemapStyle } from "../src/model/basemap.ts"; + +test("an environment key enables Protomaps without a map block", async t => { + const root = await mkdtemp(path.join(tmpdir(), "slides-basemap-config-")); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(path.join(root, "slides.config.yml"), "title: Map\n"); + for (const key of ["", "environment-key"]) { + const config = await loadSlidesConfig({ content: root, publicOverrides: { PUBLIC_PROTOMAPS_KEY: key } }); + const style = await resolveBasemapStyle({ theme: "light", config: getEffectiveBasemapStyleConfig({ + theme: "light", appMap: config.slidesConfig.map, appProtomaps: config.slidesConfig.protomaps, + }) }); + assert.equal(getAppEnvironment(config).PUBLIC_PROTOMAPS_KEY, key); + if (key) assert.equal(style.sources["basemap:protomaps"].url, "https://api.protomaps.com/tiles/v4.json?key=environment-key"); + else assert.deepEqual(style.sources, {}); + } + await writeFile(path.join(root, "slides.config.yml"), 'title: Map\nprotomaps:\n key: ""\n'); + assert.equal((await loadSlidesConfig({ content: root, + publicOverrides: { PUBLIC_PROTOMAPS_KEY: "environment-key" }, + })).protomapsKey, "environment-key"); +}); test("generation defaults, IIIF options, and config reloads preserve only explicit environment overrides", async t => { const root = await mkdtemp(path.join(tmpdir(), "slides-generation-")); diff --git a/packages/slides/tests/dev-smoke.mjs b/packages/slides/tests/dev-smoke.mjs index a8c9f69..a7adf96 100644 --- a/packages/slides/tests/dev-smoke.mjs +++ b/packages/slides/tests/dev-smoke.mjs @@ -71,6 +71,7 @@ async function start(name) { const origin = `http://127.0.0.1:${number}`; const get = async url => fetch(origin + url, { signal: AbortSignal.timeout(10_000) }); await until(async () => { const response = await get('/'); return response.ok && (await response.text()).includes(`${name} first`); }, `${name} startup`); + assert.doesNotMatch(entry.log, /Cannot find base config file/, 'a fresh dev runner must generate its base tsconfig before Vite loads'); return { content, config, runtime, appDir, origin, get, image, child, geojson, geojsonPath }; } try { diff --git a/packages/slides/tests/init.test.mjs b/packages/slides/tests/init.test.mjs new file mode 100644 index 0000000..4d3efc0 --- /dev/null +++ b/packages/slides/tests/init.test.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawn, spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; +import { test } from 'node:test'; +import { parse } from 'yaml'; +import { runInitCommand } from '../src/cli/commands/init.ts'; +import { loadSlidesConfig } from '../src/content/config.ts'; +import { loadContent } from '../src/content/index.ts'; + +const cli = fileURLToPath(new URL('../bin/slides.js', import.meta.url)); +const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); +async function fixture(t) { + const root = await mkdtemp(path.join(tmpdir(), 'slides-init-')); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} + +test('init creates a valid independent project with the running package version and an escaped title', async t => { + const root = await fixture(t); + const project = path.join(root, 'nested', 'My Atlas'); + const title = 'Amsterdam: "maps" & images #1\nA second line'; + const output = execFileSync(process.execPath, [cli, 'init', project, '--title', title], { + cwd: root, encoding: 'utf8', env: { ...process.env, npm_package_version: '99.0.0' }, + }); + assert.match(output, /pnpm install[\s\S]*pnpm dev/); + assert.doesNotMatch(output, /Project title \[/, 'piped runs must not prompt'); + assert.deepEqual(parse(await readFile(path.join(project, 'slides.config.yml'), 'utf8')).protomaps, { key: '' }); + const manifest = JSON.parse(await readFile(path.join(project, 'package.json'), 'utf8')); + assert.equal(manifest.name, 'my-atlas'); + assert.equal(manifest.private, true); + assert.equal(manifest.devDependencies['@allmaps/slides'], pkg.version); + assert.equal(manifest.scripts.dev, 'slides dev .'); + const workspace = parse(await readFile(path.join(project, 'pnpm-workspace.yaml'), 'utf8')); + assert.deepEqual(workspace.packages, []); + assert.ok(workspace.onlyBuiltDependencies.includes('sharp')); + const content = await loadContent(await loadSlidesConfig({ content: project, cwd: project })); + assert.equal(content.project.title, title); + assert.equal(content.slideCount, 1); + assert.equal(content.config.slidesConfig.map, undefined); + assert.equal(content.config.protomapsKey, ''); + assert.match(execFileSync(process.execPath, [cli, 'validate', '.'], { cwd: project, encoding: 'utf8' }), /1 slides/); + assert.equal((await readdir(project)).includes('node_modules'), false, 'init must not install dependencies'); +}); + +// Exercise the real CLI and readline with controllable streams. Marking the +// pipes as a terminal avoids depending on a platform-specific PTY test library. +async function interactive(t, root, args, answers) { + const terminal = 'data:text/javascript,' + encodeURIComponent(` + Object.defineProperty(process.stdin, 'isTTY', { value: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: true }); + `); + const child = spawn(process.execPath, ['--import', terminal, cli, ...args], { + cwd: root, stdio: ['pipe', 'pipe', 'pipe'], + }); + t.after(() => { if (child.exitCode === null) child.kill('SIGKILL'); }); + let stdout = '', stderr = '', answered = 0; + child.stdout.on('data', chunk => { + stdout += chunk; + const [prompt, answer] = answers[answered] ?? []; + if (prompt && stripVTControlCharacters(stdout).includes(prompt)) { + answered++; + if (answer === null) child.stdin.end(); + else child.stdin.write(answer); + } + }); + child.stderr.on('data', chunk => stderr += chunk); + const code = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + assert.equal(answered, answers.length, stdout + stderr); + return { code, stdout: stripVTControlCharacters(stdout), stderr }; +} + +test('interactive init saves both answers and explains that settings can change later', { timeout: 10_000 }, async t => { + const root = await fixture(t); + const result = await interactive(t, root, ['init'], [ + ['Project title [My narrative map]: ', ' Amsterdam: maps & images \n'], + ['Protomaps API key (optional; Enter for no basemap): ', ' demo-key \n'], + ]); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /change the title and Protomaps key later in slides.config.yml/); + assert.match(result.stdout, /https:\/\/protomaps.com\/api/); + const config = parse(await readFile(path.join(root, 'slides.config.yml'), 'utf8')); + assert.equal(config.title, 'Amsterdam: maps & images'); + assert.deepEqual(config.protomaps, { key: 'demo-key' }); +}); + +test('Enter accepts the title default and writes an empty key', { timeout: 10_000 }, async t => { + const root = await fixture(t); + const result = await interactive(t, root, ['create'], [ + ['Project title [My narrative map]: ', '\n'], + ['Protomaps API key (optional; Enter for no basemap): ', '\n'], + ]); + assert.equal(result.code, 0, result.stderr); + const config = parse(await readFile(path.join(root, 'slides.config.yml'), 'utf8')); + assert.equal(config.title, 'My narrative map'); + assert.deepEqual(config.protomaps, { key: '' }); +}); + +test('explicit options skip their prompts and --yes uses defaults even in a terminal', { timeout: 20_000 }, async t => { + const root = await fixture(t); + const cases = [ + { args: ['--yes'], answers: [], title: 'My narrative map', key: '' }, + { args: ['--yes', '--title', 'Fixed title', '--protomaps-key', 'fixed-key'], answers: [], title: 'Fixed title', key: 'fixed-key' }, + { args: ['--title', 'Fixed title', '--protomaps-key', ''], answers: [], title: 'Fixed title', key: '' }, + { args: ['--title', 'Fixed title'], answers: [['Protomaps API key (optional; Enter for no basemap): ', '\n']], title: 'Fixed title', key: '' }, + { args: ['--protomaps-key', 'fixed-key'], answers: [['Project title [My narrative map]: ', '\n']], title: 'My narrative map', key: 'fixed-key' }, + ]; + for (const [index, { args, answers, title, key }] of cases.entries()) { + const project = path.join(root, String(index)); + const result = await interactive(t, root, ['init', project, ...args], answers); + assert.equal(result.code, 0, result.stderr); + if (args.includes('--yes') || args.includes('--title')) assert.doesNotMatch(result.stdout, /Project title \[/); + if (args.includes('--yes') || args.includes('--protomaps-key')) assert.doesNotMatch(result.stdout, /Protomaps API key \(optional/); + const config = parse(await readFile(path.join(project, 'slides.config.yml'), 'utf8')); + assert.equal(config.title, title); + assert.deepEqual(config.protomaps, { key }); + } +}); + +test('cancelling or closing a prompt leaves no partial project', { timeout: 10_000 }, async t => { + const root = await fixture(t); + for (const [index, cancel] of ['\u0003', null].entries()) { + const project = path.join(root, String(index)); + const result = await interactive(t, root, ['init', project], [ + ['Project title [My narrative map]: ', 'My unfinished project\n'], + ['Protomaps API key (optional; Enter for no basemap): ', cancel], + ]); + assert.equal(result.code, 130, result.stderr); + assert.match(result.stdout, /creation cancelled/); + assert.deepEqual(await readdir(root), []); + } +}); + +test('create aliases init and defaults to the current folder, preserving unrelated files', async t => { + const root = await fixture(t); + await mkdir(path.join(root, '.git')); + await writeFile(path.join(root, 'notes.txt'), 'Keep these notes.'); + execFileSync(process.execPath, [cli, 'create'], { cwd: root }); + assert.equal(await readFile(path.join(root, 'notes.txt'), 'utf8'), 'Keep these notes.'); + assert.equal((await loadContent(await loadSlidesConfig({ content: root }))).slideCount, 1); + const welcome = await readFile(path.join(root, 'chapters/01-welcome.md'), 'utf8'); + const again = spawnSync(process.execPath, [cli, 'init'], { cwd: root, encoding: 'utf8' }); + assert.equal(again.status, 1); + assert.match(again.stderr, /already contains/); + assert.equal(await readFile(path.join(root, 'chapters/01-welcome.md'), 'utf8'), welcome); +}); + +test('conflicting starter files and alternative configs are detected before any writes', async t => { + const root = await fixture(t); + for (const name of ['package.json', 'README.md', '.gitignore', 'pnpm-workspace.yaml', + 'slides.config.yml', 'slides.config.yaml', 'slides.config.json', 'chapters']) { + const project = path.join(root, name); + await mkdir(project); + await writeFile(path.join(project, name), 'Existing content'); + await assert.rejects(runInitCommand(project), /already contains/); + assert.deepEqual(await readdir(project), [name]); + assert.equal(await readFile(path.join(project, name), 'utf8'), 'Existing content'); + } +}); + +test('init refuses existing chapter directories and dangling symlinks without following them', async t => { + const root = await fixture(t); + await mkdir(path.join(root, 'chapters')); + await writeFile(path.join(root, 'chapters/existing.md'), 'Keep this slide.'); + await assert.rejects(runInitCommand(root), /already contains chapters/); + assert.deepEqual(await readdir(root), ['chapters']); + assert.equal(await readFile(path.join(root, 'chapters/existing.md'), 'utf8'), 'Keep this slide.'); + const linked = path.join(root, 'linked'); + await mkdir(linked); + const target = path.join(root, 'missing-config'); + await symlink(target, path.join(linked, 'slides.config.yml')); + await assert.rejects(runInitCommand(linked), /already contains slides.config.yml/); + await assert.rejects(readFile(target), { code: 'ENOENT' }); + assert.deepEqual(await readdir(linked), ['slides.config.yml']); +}); + +test('invalid destinations and empty titles fail without changing the filesystem', async t => { + const root = await fixture(t); + const file = path.join(root, 'file'); + await writeFile(file, 'Keep this file.'); + await assert.rejects(runInitCommand(file), /must be a directory/); + assert.equal(await readFile(file, 'utf8'), 'Keep this file.'); + const project = path.join(root, 'empty-title'); + await assert.rejects(runInitCommand(project, { title: ' ' }), /title must not be empty/); + assert.deepEqual(await readdir(root), ['file']); +}); diff --git a/packages/slides/tests/model-camera.test.mjs b/packages/slides/tests/model-camera.test.mjs index 929a339..996bf35 100644 --- a/packages/slides/tests/model-camera.test.mjs +++ b/packages/slides/tests/model-camera.test.mjs @@ -7,6 +7,7 @@ import { resolveChapterCamera, unitsPerPixel, getCameraLayoutOptions, + getCameraPadding, } from "../src/model/map/camera.ts"; // Synthetic mask around Kattenburg; independent of installed content packages. @@ -94,6 +95,41 @@ test("explicit location overrides map fitting and a location-only chapter inheri ); }); +test("fit modes contain, cover or equal the available area at different bearings and aspect ratios", () => { + const map = new WarpedMap("map", georeferencedMap); + const padding = { left: 80, right: 20, top: 30, bottom: 50 }; + try { + for (const size of [[540, 400], [400, 700]]) { + for (const bearing of [0, 37, 90]) { + const chapter = { warpedMaps: [{ url: "map" }], location: { bearing } }; + const cameras = {}; + for (const fit of ["contain", "cover", "equal"]) { + const camera = resolveChapterCamera({ ...chapter, fit }, () => [map], size, padding); + cameras[fit] = camera; + const viewport = new Viewport(size, lonLatToWebMercator(camera.center), unitsPerPixel(camera.zoom), { + rotation: (-bearing * Math.PI) / 180, + }); + const [a, b, c, d, e, f] = viewport.projectedGeoToViewportHomogeneousTransform; + const pixels = map.projectedGeoAppliedMask.map(([x, y]) => [a * x + c * y + e, b * x + d * y + f]); + const width = Math.max(...pixels.map(p => p[0])) - Math.min(...pixels.map(p => p[0])); + const height = Math.max(...pixels.map(p => p[1])) - Math.min(...pixels.map(p => p[1])); + const ratios = [width / (size[0] - padding.left - padding.right), height / (size[1] - padding.top - padding.bottom)]; + const extent = fit === "contain" ? Math.max(...ratios) : fit === "cover" ? Math.min(...ratios) : ratios[0] * ratios[1]; + assert.ok(Math.abs(extent - 1) < 1e-6, `${fit}, ${bearing}°, ${size}: ${ratios}`); + assert.equal(camera.bearing, bearing); + } + assert.deepEqual(resolveChapterCamera(chapter, () => [map], size, padding), cameras.contain); + assert.deepEqual(cameras.cover.center, cameras.contain.center); + assert.deepEqual(cameras.equal.center, cameras.contain.center); + assert.ok(cameras.contain.zoom < cameras.equal.zoom); + assert.ok(cameras.equal.zoom < cameras.cover.zoom); + } + } + } finally { + map.destroy(); + } +}); + test("useBounds excludes distant maps while an explicit zoom overrides native-image zoom", () => { const map = new WarpedMap("map", georeferencedMap); const props = { url: "included", useBounds: true, useZoom: true }; @@ -102,12 +138,55 @@ test("useBounds excludes distant maps while an explicit zoom overrides native-im assert.equal(entry.url, "included"); return [map]; }; - const camera = resolveChapterCamera( - { warpedMaps: [props, { url: "excluded" }], location: { zoom: 11 } }, - getMaps, - [540, 400], - ); - assert.equal(camera.zoom, 11); + for (const fit of ["contain", "cover", "equal"]) { + const chapter = { fit, warpedMaps: [props, { url: "excluded" }] }; + const native = resolveChapterCamera(chapter, getMaps, [540, 400]); + assert.ok(Math.abs(unitsPerPixel(native.zoom) - 1 / map.resourceToProjectedGeoScale) < 1e-9); + const explicit = resolveChapterCamera({ ...chapter, location: { zoom: 11 } }, getMaps, [540, 400]); + assert.equal(explicit.zoom, 11); + } + } finally { + map.destroy(); + } +}); + +test("inner padding preserves layout reservations and offsets, including edge-to-edge cover", () => { + const map = new WarpedMap("map", georeferencedMap); + const warpedMaps = [{ url: "map" }]; + try { + for (const [size, layout, offset] of [ + [[1280, 800], { top: 0, right: 460, bottom: 0, left: 0 }, [-230, 0]], + [[400, 800], { top: 0, right: 0, bottom: 420, left: 0 }, [0, -210]], + [[540, 400], { top: 0, right: 0, bottom: 0, left: 0 }, [0, 0]], + ]) { + assert.deepEqual(getCameraPadding(layout, 0), layout); + assert.deepEqual(getCameraPadding(layout), getCameraPadding(layout, 25)); + for (const padding of [-30, 0, 25, 60]) { + const combined = getCameraPadding(layout, padding); + const framing = getCameraLayoutOptions(combined); + assert.deepEqual(framing.offset, offset, 'the content margin must not move the layout center'); + for (const side of ['top', 'right', 'bottom', 'left']) + assert.equal(combined[side] - padding, layout[side], 'the panel reservation must remain intact'); + const camera = resolveChapterCamera({ warpedMaps, fit: 'cover', padding }, () => [map], size, framing.padding); + const viewport = new Viewport(size, lonLatToWebMercator(camera.center), unitsPerPixel(camera.zoom)); + const [a, b, c, d, e, f] = viewport.projectedGeoToViewportHomogeneousTransform; + const pixels = map.projectedGeoAppliedMask.map(([x, y]) => [a * x + c * y + e + offset[0], b * x + d * y + f + offset[1]]); + const bounds = [Math.min(...pixels.map(p => p[0])), Math.min(...pixels.map(p => p[1])), + Math.max(...pixels.map(p => p[0])), Math.max(...pixels.map(p => p[1]))]; + const target = [combined.left, combined.top, size[0] - combined.right, size[1] - combined.bottom]; + assert.ok(bounds[0] <= target[0] + 1e-6 && bounds[1] <= target[1] + 1e-6 && + bounds[2] >= target[2] - 1e-6 && bounds[3] >= target[3] - 1e-6, 'cover must reach every available edge'); + assert.ok(Math.abs(bounds[0] - target[0]) < 1e-6 || Math.abs(bounds[1] - target[1]) < 1e-6, + 'cover must meet one pair of edges exactly'); + } + } + for (const padding of [-30, 0, 25]) { + assert.deepEqual( + resolveChapterCamera({ warpedMaps, padding }, () => [map], [540, 400]), + resolveChapterCamera({ warpedMaps }, () => [map], [540, 400], padding), + 'standalone camera resolution also honors the chapter margin', + ); + } } finally { map.destroy(); } diff --git a/packages/slides/tests/model-layers.test.mjs b/packages/slides/tests/model-layers.test.mjs new file mode 100644 index 0000000..ab81889 --- /dev/null +++ b/packages/slides/tests/model-layers.test.mjs @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { parseConfigDocument } from '../src/model/config.ts'; +import { parseSlidesConfig, slidesConfigSchema, slideMetadataSchema } from '../src/model/content-schema.ts'; +import { buildProject } from '../src/model/project.ts'; +import { resolveUserLayers, applyUserLayerChanges, getUserLayerGlyphs } from '../src/model/map/layers.ts'; +import { resolveBasemapStyle, getEffectiveBasemapStyleConfig } from '../src/model/basemap.ts'; + +const sources = { places: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } } }; +const labels = { + id: 'place-labels', type: 'symbol', source: 'places', + layout: { visibility: 'none', 'text-field': ['get', 'Naam'], 'text-font': ['Noto Sans Regular'] }, + paint: { 'text-color': '#34373d' }, +}; + +test('YAML preserves global defaults and custom label expressions through project generation', () => { + const raw = parseConfigDocument(` +title: Places +sources: + places: + type: geojson + path: assets/places.geojson +layers: + - layer: places-point-circle + visibility: none + - id: place-labels + type: symbol + source: places + layout: + visibility: none + text-field: [get, Naam] + text-font: [Noto Sans Regular] + paint: + text-color: '#34373d' +slideshows: + - id: main + path: chapters + start: + layers: + - layer: place-labels + visibility: visible +`, 'slides.config.yml'); + const parsed = parseSlidesConfig(raw, 'fixture'); + assert.equal(parsed.success, true); + const project = buildProject(parsed.data, { + 'chapters/01-place.md': { metadata: { title: 'Places', layers: [{ layer: 'place-labels', visibility: 'visible' }] } }, + }, path => `/content/${path}`); + const show = project.slideshows[0]; + assert.equal(show.sources.places.data, '/content/assets/places.geojson'); + assert.deepEqual(show.layers.map(layer => layer.id), [ + 'user-places-fill', 'user-places-line', 'user-places-point-circle', 'user-places-point-symbol', 'user-place-labels', + ]); + assert.equal(show.layers.find(layer => layer.type === 'circle').layout.visibility, 'none'); + assert.deepEqual(show.layers.at(-1).layout['text-field'], ['get', 'Naam']); + assert.equal(applyUserLayerChanges(show.layers, show.start.layers).at(-1).layout.visibility, 'visible'); + assert.equal(applyUserLayerChanges(show.layers, show.chapters[0].layers).at(-1).layout.visibility, 'visible'); +}); + +test('slide overrides reset to global defaults and feature expressions in any navigation order', () => { + const defaults = resolveUserLayers(sources, [ + { layer: 'places-line', visibility: 'none', duration: 700 }, + { layer: 'places-point-circle', opacity: 0.4 }, labels, + ]); + const original = structuredClone(defaults); + const changes = [ + { layer: 'places-line', visibility: 'visible', opacity: 0, duration: 0 }, + { layer: 'places-point-circle', opacity: 0.9 }, + { layer: 'place-labels', visibility: 'visible', opacity: 0.6 }, + ]; + for (const overrides of [changes, undefined, changes, [], changes, undefined]) { + const current = applyUserLayerChanges(defaults, overrides); + const line = current.find(layer => layer.type === 'line'); + const circle = current.find(layer => layer.type === 'circle'); + const symbol = current.at(-1); + if (overrides === changes) { + assert.equal(line.layout.visibility, 'visible'); + assert.equal(line.paint['line-opacity'], 0); + assert.deepEqual(line.paint['line-opacity-transition'], { duration: 0 }); + assert.equal(circle.paint['circle-opacity'], 0.9); + assert.equal(circle.paint['circle-stroke-opacity'], 0.9); + assert.equal(symbol.paint['text-opacity'], 0.6); + assert.equal(symbol.paint['icon-opacity'], 0.6); + } else { + assert.equal(line.layout.visibility, 'none'); + assert.ok(Array.isArray(line.paint['line-opacity'])); + assert.deepEqual(line.paint['line-opacity-transition'], { duration: 700 }); + assert.equal(circle.paint['circle-opacity'], 0.4); + assert.equal(symbol.layout.visibility, 'none'); + assert.equal(symbol.paint['text-opacity'], undefined); + } + } + assert.deepEqual(defaults, original, 'resolving a slide must not mutate shared defaults'); +}); + +test('a custom definition replaces a generated layer and follows authored drawing order', () => { + const custom = { id: 'places-point-circle', type: 'circle', source: 'places', paint: { 'circle-radius': 3 } }; + const layers = resolveUserLayers(sources, [custom, labels]); + assert.equal(layers.filter(layer => layer.id === 'user-places-point-circle').length, 1); + assert.deepEqual(layers.at(-2).paint, { 'circle-radius': 3 }); + assert.equal(layers.at(-1).id, 'user-place-labels'); +}); + +test('invalid layer configuration and references fail before loading a map', () => { + for (const layers of [ + [{ id: 'bad', type: 'symbol' }], + [{ id: 'bad', source: 'places', type: 'typo' }], + [{ layer: 'places-line', opacity: 2 }], + [{ layer: 'places-line', duration: -1 }], + [{ id: 'bad', source: 'places', type: 'symbol', layout: { visibility: 'hidden' } }], + ]) assert.equal(slidesConfigSchema.safeParse({ layers }).success, false); + assert.equal(slideMetadataSchema.safeParse({ title: 'Bad', layers: [{ layer: 'x', opacity: -1 }] }).success, false); + assert.throws(() => resolveUserLayers(sources, [{ layer: 'user-places-line', visibility: 'none' }]), /Unknown layer/); + assert.throws(() => resolveUserLayers(sources, [{ ...labels, source: 'missing' }]), /Unknown source/); + assert.throws(() => resolveUserLayers(sources, [labels, labels]), /Duplicate layer id/); + assert.throws(() => resolveUserLayers(sources, [{ layer: 'places-line' }, { layer: 'places-line' }]), /Duplicate layer/); + const config = { title: 'Places', main: 'main', sources: {}, slideshows: [{ id: 'main', path: 'chapters' }] }; + assert.throws(() => buildProject(config, { 'chapters/01-places.md': { metadata: { + title: 'Places', layers: [{ layer: 'missing', visibility: 'visible' }], + } } }), /Unknown layer missing in chapters\/01-places.md/); +}); + +test('text layers have glyphs without a basemap, while non-text overlays need none', async () => { + assert.equal(getUserLayerGlyphs(resolveUserLayers(sources)), undefined); + assert.match(getUserLayerGlyphs(resolveUserLayers(sources, [labels])), /\{fontstack\}\/\{range\}\.pbf$/); + const config = getEffectiveBasemapStyleConfig({ theme: 'light', appMap: { + protomaps: { glyphs: 'https://example.org/fonts/{fontstack}/{range}.pbf' }, + } }); + const style = await resolveBasemapStyle({ theme: 'light', config }); + assert.equal(style.glyphs, 'https://example.org/fonts/{fontstack}/{range}.pbf'); + assert.deepEqual(style.sources, {}); +}); diff --git a/packages/slides/tests/model-model.test.mjs b/packages/slides/tests/model-model.test.mjs index bf5ac55..dfd5f20 100644 --- a/packages/slides/tests/model-model.test.mjs +++ b/packages/slides/tests/model-model.test.mjs @@ -4,6 +4,7 @@ import { parseConfigDocument, parseFrontmatter } from "../src/model/config.ts"; import { parseSlidesConfig, slideMetadataSchema, + slidesConfigSchema, } from "../src/model/content-schema.ts"; import { buildProject } from "../src/model/project.ts"; import { @@ -29,6 +30,20 @@ test("config and frontmatter use one normalization contract without runtime glob assert.equal(slide.warpedMaps[0].url, "assets/map.json"); assert.throws(() => parseFrontmatter("---\ntitle: One\ntitle: Two\n---")); }); +test("fit is validated consistently in slide frontmatter and start-screen settings", () => { + for (const fit of ["contain", "cover", "equal"]) { + const slide = slideMetadataSchema.parse(parseFrontmatter(`---\ntitle: Map\nfit: ${fit}\n---\nMap`)); + assert.equal(slide.fit, fit); + const config = slidesConfigSchema.parse(parseConfigDocument( + `slideshows:\n - id: main\n path: chapters\n start:\n fit: ${fit}\n`, "slides.config.yml", + )); + assert.equal(config.slideshows[0].start.fit, fit); + } + for (const fit of [true, "stretch"]) { + assert.equal(slideMetadataSchema.safeParse({ title: "Map", fit }).success, false); + assert.equal(slidesConfigSchema.safeParse({ slideshows: [{ id: "main", path: "chapters", start: { fit } }] }).success, false); + } +}); test("project ordering, asset adapters and references are independent of Svelte", () => { const config = { title: "Atlas", @@ -75,6 +90,18 @@ test("project ordering, asset adapters and references are independent of Svelte" /Duplicate slide slug/, ); }); +test("inner padding accepts finite signed pixel values in slides and start screens", () => { + for (const padding of [-100, -12.5, 0, 12.5, 25, 100]) { + const slide = slideMetadataSchema.parse(parseFrontmatter(`---\ntitle: Map\nfit: cover\npadding: ${padding}\n---\nMap`)); + assert.equal(slide.padding, padding); + const config = slidesConfigSchema.parse({ slideshows: [{ id: "main", path: "chapters", start: { padding } }] }); + assert.equal(config.slideshows[0].start.padding, padding); + } + for (const padding of ['25', null, true, { top: 10 }, Infinity, -Infinity, NaN]) { + assert.equal(slideMetadataSchema.safeParse({ title: "Map", padding }).success, false); + assert.equal(slidesConfigSchema.safeParse({ slideshows: [{ id: "main", path: "chapters", start: { padding } }] }).success, false); + } +}); test("basemap dependencies are injected while visibility and inheritance stay shared", async () => { const config = getEffectiveBasemapStyleConfig({ theme: "dark", diff --git a/packages/slides/tests/package-smoke.mjs b/packages/slides/tests/package-smoke.mjs index 6d55227..93f223a 100644 --- a/packages/slides/tests/package-smoke.mjs +++ b/packages/slides/tests/package-smoke.mjs @@ -4,6 +4,7 @@ import { mkdtemp, mkdir, readFile, writeFile, readdir, rm, realpath, stat } from import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; import sharp from 'sharp'; const repository = fileURLToPath(new URL('../../../', import.meta.url)); const root = await mkdtemp(path.join(tmpdir(), 'slides-package-consumer-')); @@ -12,7 +13,7 @@ const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; const run = (args, cwd = root, env = {}) => execFileSync(pnpm, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env }, timeout: 240_000 }); async function checkDevServer(installed) { const child = spawn(process.execPath, [path.join(installed, 'bin/slides.js'), 'dev', '.', '--host', '127.0.0.1', '--port', '0'], - { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }); + { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, FORCE_COLOR: '1' } }); const closed = new Promise(resolve => child.once('close', resolve)); let log = ''; child.stdout.on('data', data => log += data); @@ -20,7 +21,8 @@ async function checkDevServer(installed) { try { const deadline = Date.now() + 60_000; while (Date.now() < deadline && child.exitCode === null) { - const origin = log.match(/http:\/\/127\.0\.0\.1:\d+/)?.[0]; + // CI colors the port separately; exercise that output even in local runs. + const origin = stripVTControlCharacters(log).match(/http:\/\/127\.0\.0\.1:\d+/)?.[0]; if (origin) { // Vite may still be compiling the app after it prints the server URL. const response = await fetch(`${origin}/story/`, { signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) }); @@ -29,6 +31,7 @@ async function checkDevServer(installed) { const info = await fetch(`${origin}/story/iiif/ship/info.json`); assert.equal(info.status, 200, log); assert.equal((await info.json()).id, `${origin}/story/iiif/ship`); + assert.doesNotMatch(log, /Cannot find base config file/, 'packaged dev startup must not read a missing base tsconfig'); return; } await new Promise(resolve => setTimeout(resolve, 100)); @@ -82,6 +85,17 @@ try { `Unpublished workspace import in ${filename}`); } await assert.rejects(() => stat(path.join(installed, 'src')), { code: 'ENOENT' }); + // Scaffold from the installed archive: init must not rely on checkout files. + const starter = path.join(root, 'starter'); + run(['exec', 'slides', 'init', starter, '--title', 'My new narrative map', '--yes']); + const starterPackage = JSON.parse(await readFile(path.join(starter, 'package.json'))); + assert.equal(starterPackage.devDependencies['@allmaps/slides'], pkg.version); + assert.doesNotMatch(await readFile(path.join(starter, 'slides.config.yml'), 'utf8'), /^map:/m); + run(['exec', 'slides', 'validate', starter]); + run(['exec', 'slides', 'build', starter]); + const starterHtml = await readFile(path.join(starter, 'dist/index.html'), 'utf8'); + assert.match(starterHtml, /My new narrative map/); + assert.match(starterHtml, /Queequeg was a native of Rokovoko/); // Authored content has no JavaScript entry point or exported content package. await mkdir(path.join(root, 'chapters')); await mkdir(path.join(root, 'assets/images'), { recursive: true }); diff --git a/packages/slides/tests/padding-previews.test.mjs b/packages/slides/tests/padding-previews.test.mjs new file mode 100644 index 0000000..ba10776 --- /dev/null +++ b/packages/slides/tests/padding-previews.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import sharp from 'sharp'; +import { generateAnnotation } from '@allmaps/annotation'; +import { loadSlidesConfig } from '../src/content/config.ts'; +import { loadContent } from '../src/content/index.ts'; +import { prepareThumbnails } from '../src/build/prepare.ts'; + +test('authored inner margins override slide and sharing preview defaults without changing layer cards', async t => { + const root = await mkdtemp(path.join(tmpdir(), 'slides-padding-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, 'chapters')); + await mkdir(path.join(root, 'assets/images'), { recursive: true }); + await mkdir(path.join(root, 'assets/annotations')); + await sharp({ create: { width: 64, height: 64, channels: 3, background: '#123456' } }) + .png().toFile(path.join(root, 'assets/images/map.png')); + await writeFile(path.join(root, 'assets/annotations/map.json'), JSON.stringify(generateAnnotation({ + '@context': 'https://schemas.allmaps.org/map/2/context.json', type: 'GeoreferencedMap', + resource: { id: 'https://example.org/iiif/map', type: 'ImageService3', width: 64, height: 64 }, + resourceMask: [[0, 0], [64, 0], [64, 64], [0, 64]], + gcps: [ + { resource: [0, 0], geo: [4.91, 52.38] }, { resource: [64, 0], geo: [4.92, 52.38] }, + { resource: [64, 64], geo: [4.92, 52.374] }, { resource: [0, 64], geo: [4.91, 52.374] }, + ], transformation: { type: 'polynomial', options: { order: 1 } }, + }))); + await writeFile(path.join(root, 'slides.config.json'), JSON.stringify({ + title: 'Padding', slideshows: [{ id: 'main', path: 'chapters' }], + })); + const cameras = []; + for (const padding of [undefined, 0, 40, -20]) { + const metadata = { title: 'Map', fit: 'cover', padding, warpedMaps: [{ path: 'assets/annotations/map.json' }] }; + await writeFile(path.join(root, 'chapters/01-map.md'), `---\n${JSON.stringify(metadata)}\n---\nMap`); + const content = await loadContent(await loadSlidesConfig({ content: root })); + const { plan, manifest } = await prepareThumbnails(content, { + assetRoot: root, cacheRoot: path.join(root, 'cache'), annotationsRoot: path.join(root, 'annotations'), offline: true, refresh: false, + }); + const camera = image => plan.jobs.find(job => job.id === image.path).camera; + cameras.push({ light: camera(manifest.slides['main:map'].light), dark: camera(manifest.slides['main:map'].dark), + social: camera(manifest.social.main), layer: camera(Object.values(manifest.layers)[0]) }); + } + const [defaults, zero, inset, cropped] = cameras; + for (const type of ['light', 'dark', 'social']) { + assert.ok(zero[type].zoom > defaults[type].zoom, `${type}: padding 0 must remove the default margin`); + assert.ok(inset[type].zoom < defaults[type].zoom, `${type}: padding 40 must increase the margin`); + assert.ok(cropped[type].zoom > zero[type].zoom, `${type}: padding -20 must zoom beyond the visible edges`); + assert.deepEqual(zero[type].center, defaults[type].center); + assert.deepEqual(inset[type].center, defaults[type].center); + assert.deepEqual(cropped[type].center, defaults[type].center); + } + assert.deepEqual(zero.layer, defaults.layer); + assert.deepEqual(inset.layer, defaults.layer); + assert.deepEqual(cropped.layer, defaults.layer); +}); diff --git a/packages/slides/tests/release.test.mjs b/packages/slides/tests/release.test.mjs new file mode 100644 index 0000000..d941c24 --- /dev/null +++ b/packages/slides/tests/release.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, writeFile, rm, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { checkRelease } from '../../../scripts/check-release.mjs'; +import { prepareNpmRelease, publishedBuildInfo } from '../../../scripts/prepare-npm-release.mjs'; + +const name = '@allmaps/slides'; +const version = '0.1.0-beta.2'; +const noNetwork = () => { throw new Error('Unexpected registry request'); }; + +async function fixture(t, nextVersion = version) { + const root = await realpath(await mkdtemp(path.join(tmpdir(), 'slides-release-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const git = (...args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + git('init', '-b', 'main'); + git('config', 'user.name', 'Release test'); + git('config', 'user.email', 'release@example.org'); + await mkdir(path.join(root, 'packages/slides'), { recursive: true }); + await mkdir(path.join(root, '.changeset')); + const writeVersion = async value => { + await writeFile(path.join(root, 'packages/slides/package.json'), JSON.stringify({ + name, version: value, repository: { url: 'https://github.com/allmaps/slides.git' }, + })); + await writeFile(path.join(root, 'packages/slides/CHANGELOG.md'), `# ${name}\n\n## ${value}\n\nRelease notes.\n`); + if (value.includes('-beta.')) await writeFile(path.join(root, '.changeset/pre.json'), JSON.stringify({ mode: 'pre', tag: 'beta' })); + else await rm(path.join(root, '.changeset/pre.json'), { force: true }); + }; + const commit = () => { git('add', '.'); git('commit', '-m', 'Fixture'); return git('rev-parse', 'HEAD'); }; + await writeVersion('0.1.0-beta.1'); + const before = commit(); + await writeVersion(nextVersion); + const revision = nextVersion === '0.1.0-beta.1' ? before : commit(); + const env = { GITHUB_REF: 'refs/heads/main', GITHUB_SHA: revision, GITHUB_EVENT_NAME: 'push', RELEASE_BEFORE: before }; + return { root, git, env, before, revision, commit }; +} + +async function publishedFixture(t, info, { corrupt = false, tarStatus = 200 } = {}) { + const root = await mkdtemp(path.join(tmpdir(), 'slides-published-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, 'package')); + await writeFile(path.join(root, 'package/build-info.json'), JSON.stringify(info)); + const tarball = execFileSync('tar', ['-czf', '-', '-C', root, 'package/build-info.json']); + const url = 'https://registry.npmjs.org/@allmaps/slides/-/slides.tgz'; + return async requested => { + if (String(requested) === url) return new Response(tarball, { status: tarStatus }); + assert.equal(String(requested), `https://registry.npmjs.org/${encodeURIComponent(info.name)}/${info.version}`); + return Response.json({ name: info.name, version: info.version, dist: { + tarball: url, integrity: corrupt ? 'sha512-wrong' : `sha512-${createHash('sha512').update(tarball).digest('base64')}`, + } }); + }; +} + +test('ordinary main pushes skip publication even with pending changesets', async t => { + const f = await fixture(t, '0.1.0-beta.1'); + await writeFile(path.join(f.root, '.changeset/feature.md'), 'Pending release'); + f.env.GITHUB_SHA = f.commit(); + assert.deepEqual(await prepareNpmRelease({ ...f, fetchFn: noNetwork }), { release: false }); +}); + +test('version bumps publish beta and stable versions to their respective channels', async t => { + for (const [nextVersion, npmTag] of [[version, 'beta'], ['0.1.0', 'latest']]) { + const f = await fixture(t, nextVersion); + assert.deepEqual(await prepareNpmRelease({ ...f, fetchFn: async () => new Response(null, { status: 404 }) }), { + release: true, published: false, tag: `${name}@${nextVersion}`, version: nextVersion, npmTag, + }); + } +}); + +test('publication requires main, the event commit, clean source, and consumed changesets', async t => { + const f = await fixture(t); + await assert.rejects(prepareNpmRelease({ ...f, env: { ...f.env, GITHUB_REF: 'refs/heads/develop' }, fetchFn: noNetwork }), /must run on main/); + await assert.rejects(prepareNpmRelease({ ...f, env: { ...f.env, GITHUB_SHA: f.before }, fetchFn: noNetwork }), /workflow commit/); + await writeFile(path.join(f.root, '.changeset/feature.md'), 'Pending release'); + await assert.rejects(prepareNpmRelease({ ...f, fetchFn: noNetwork }), /Commit all software changes/); + f.env.GITHUB_SHA = f.commit(); + await assert.rejects(prepareNpmRelease({ ...f, fetchFn: noNetwork }), /pending changes/); +}); + +test('manual retry recognizes the published build without needing npm gitHead metadata', async t => { + const f = await fixture(t); + const { info } = await checkRelease(f.root); + const fetchFn = await publishedFixture(t, info); + const result = await prepareNpmRelease({ ...f, env: { ...f.env, GITHUB_EVENT_NAME: 'workflow_dispatch' }, fetchFn }); + assert.equal(result.release, true); + assert.equal(result.published, true); + f.git('tag', `${name}@${version}`); + assert.equal((await prepareNpmRelease({ ...f, fetchFn })).published, true); +}); + +test('retries refuse an existing tag or npm version from a different commit', async t => { + const f = await fixture(t); + const { info } = await checkRelease(f.root); + const fetchFn = await publishedFixture(t, { ...info, revision: f.before }); + await assert.rejects(prepareNpmRelease({ ...f, fetchFn }), /different revision/); + f.git('tag', `${name}@${version}`, f.before); + await assert.rejects(prepareNpmRelease({ ...f, fetchFn: noNetwork }), /tag points to a different commit/); +}); + +test('registry errors, missing tarballs, and corrupt packages cannot be mistaken for unpublished versions', async t => { + await assert.rejects(publishedBuildInfo(name, version, async () => new Response(null, { status: 503 })), /HTTP 503/); + const f = await fixture(t); + const { info } = await checkRelease(f.root); + await assert.rejects(publishedBuildInfo(name, version, await publishedFixture(t, info, { corrupt: true })), /integrity mismatch/); + await assert.rejects(publishedBuildInfo(name, version, await publishedFixture(t, info, { tarStatus: 404 })), /tarball returned HTTP 404/); +}); diff --git a/packages/slides/tests/render-geojson.test.mjs b/packages/slides/tests/render-geojson.test.mjs index 5295f5d..546b1e9 100644 --- a/packages/slides/tests/render-geojson.test.mjs +++ b/packages/slides/tests/render-geojson.test.mjs @@ -26,7 +26,7 @@ test("content GeoJSON appears in slide and social scenes and respects per-slide await writeFile(path.join(root, "assets/geojson/track.geojson"), JSON.stringify(route)); await writeFile(path.join(root, "chapters/01-first.md"), "---\ntitle: First\nlocation:\n center: [4, 52]\n zoom: 4\n---\n"); await writeFile(path.join(root, "chapters/02-second.md"), "---\ntitle: Second\n---\n"); - await writeFile(path.join(root, "chapters/03-hidden.md"), "---\ntitle: Hidden\nlayers:\n - layer: user-expeditionTrack-line\n visibility: none\n---\n"); + await writeFile(path.join(root, "chapters/03-hidden.md"), "---\ntitle: Hidden\nlayers:\n - layer: expeditionTrack-line\n visibility: none\n---\n"); const content = await loadContent(await loadSlidesConfig({ content: root })); const { plan, manifest } = await prepareThumbnails(content, { assetRoot: root, cacheRoot: path.join(root, "cache"), @@ -49,3 +49,45 @@ test("content GeoJSON appears in slide and social scenes and respects per-slide for (const theme of ["light", "dark"]) assert(!scene(manifest.slides["main:hidden"][theme]).styles.upper.layers.some(layer => layer.type === "line")); }); + +test('thumbnail layers reset after overrides and include custom GeoJSON text without a basemap', async t => { + const root = await mkdtemp(path.join(tmpdir(), 'slides-labels-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, 'chapters')); + await mkdir(path.join(root, 'assets/geojson'), { recursive: true }); + const places = { type: 'FeatureCollection', features: [{ type: 'Feature', properties: { Naam: 'Oude Kerk 1306' }, + geometry: { type: 'Point', coordinates: [4.898, 52.374] } }] }; + await writeFile(path.join(root, 'assets/geojson/places.geojson'), JSON.stringify(places)); + await writeFile(path.join(root, 'slides.config.yml'), JSON.stringify({ + title: 'Labels', sources: { places: { type: 'geojson', path: 'assets/geojson/places.geojson' } }, + layers: [ + { layer: 'places-point-circle', visibility: 'none' }, + { id: 'labels', source: 'places', type: 'symbol', layout: { + visibility: 'none', 'text-field': ['get', 'Naam'], 'text-font': ['Noto Sans Regular'], + } }, + ], + slideshows: [{ id: 'main', path: 'chapters', start: { layers: [{ layer: 'labels', visibility: 'visible' }] } }], + })); + await writeFile(path.join(root, 'chapters/01-labels.md'), '---\ntitle: Labels\nlayers:\n - layer: labels\n visibility: visible\n opacity: 0.5\n---\n'); + await writeFile(path.join(root, 'chapters/02-defaults.md'), '---\ntitle: Defaults\n---\n'); + await writeFile(path.join(root, 'chapters/03-labels-again.md'), '---\ntitle: Labels again\nlayers:\n - layer: labels\n visibility: visible\n---\n'); + const content = await loadContent(await loadSlidesConfig({ content: root })); + const { plan, manifest } = await prepareThumbnails(content, { + assetRoot: root, cacheRoot: path.join(root, 'cache'), annotationsRoot: path.join(root, 'annotations'), offline: true, refresh: false, + }); + const scene = image => plan.jobs.find(job => job.id === image.path).styles.upper; + for (const theme of ['light', 'dark']) { + const first = scene(manifest.slides['main:labels'][theme]); + const reset = scene(manifest.slides['main:defaults'][theme]); + const again = scene(manifest.slides['main:labels-again'][theme]); + assert.deepEqual(first.sources.places.data, places); + assert.deepEqual(first.layers.find(layer => layer.id === 'user-labels').layout['text-field'], ['get', 'Naam']); + assert.equal(first.layers.find(layer => layer.id === 'user-labels').paint['text-opacity'], 0.5); + assert.ok(first.glyphs); + assert.equal(reset.layers.some(layer => layer.id === 'user-labels'), false); + assert.equal(reset.layers.some(layer => layer.type === 'circle'), false); + assert.equal(again.layers.find(layer => layer.id === 'user-labels').paint?.['text-opacity'], undefined); + assert.equal(again.layers.filter(layer => layer.id.startsWith('user-user-')).length, 0); + } + assert.equal(scene(manifest.social.main).layers.find(layer => layer.id === 'user-labels').paint['text-opacity'], 0.5); +}); diff --git a/packages/slides/tests/social-images.test.mjs b/packages/slides/tests/social-images.test.mjs index e7e1ac5..828ebd5 100644 --- a/packages/slides/tests/social-images.test.mjs +++ b/packages/slides/tests/social-images.test.mjs @@ -10,7 +10,6 @@ import { prepareThumbnails } from "../src/build/prepare.ts"; test("social scenes use overall short copy, subslideshow titles and a caller-supplied font", async t => { const root = await mkdtemp(path.join(tmpdir(), "slides-social-")); t.after(() => rm(root, { recursive: true, force: true })); - const style = { version: 8, sources: {}, layers: [] }; const font = await readFile(new URL('../../../apps/slides/static/fonts/SourceSans3-VariableFont_wght.ttf', import.meta.url)); await writeFile(path.join(root, 'custom.ttf'), font); const config = { @@ -22,7 +21,6 @@ test("social scenes use overall short copy, subslideshow titles and a caller-sup { id: 'history', path: 'history', title: 'The early years', description: 'Do not use this as the subtitle' }, ], socialImage: { font: { family: 'Source Sans 3', path: 'custom.ttf' } }, - map: { styles: { light: style, dark: style } }, }; for (const show of config.slideshows) { await mkdir(path.join(root, show.path)); @@ -34,6 +32,13 @@ test("social scenes use overall short copy, subslideshow titles and a caller-sup assetRoot: root, cacheRoot: path.join(root, 'cache'), annotationsRoot: path.join(root, 'annotations'), offline: true, refresh: false, }); const defaults = await prepare(); + for (const job of defaults.plan.jobs) { + for (const style of Object.values(job.styles)) { + assert.deepEqual(style.sources, {}, 'unconfigured basemaps must not request tiles'); + assert.equal(style.glyphs, undefined); + assert.equal(style.sprite, undefined); + } + } assert.ok(defaults.plan.jobs.every(job => !job.textOverlay), 'sharing images default to no text'); assert.equal(defaults.plan.fonts, undefined, 'a configured font alone must not enable overlays'); config.socialImage.textOverlay = true; diff --git a/packages/static-render/Dockerfile b/packages/static-render/Dockerfile index 954476c..ac0c9f4 100644 --- a/packages/static-render/Dockerfile +++ b/packages/static-render/Dockerfile @@ -24,7 +24,12 @@ CMD ["node", "/workspace/packages/static-render/bin/render.mjs", "--help"] # Optional Slides build image: content, caches and output are mounted at run time. FROM renderer AS slides-build +# Build-info and release tests create Git fixtures and read the release tooling. +RUN apt-get update && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* COPY apps ./apps +COPY scripts ./scripts +COPY .changeset ./.changeset RUN mkdir -p content && pnpm install --frozen-lockfile \ && pnpm --filter @allmaps/slides --filter @allmaps/static-render --filter @allmaps/iiif check \ && pnpm --filter @allmaps/slides --filter @allmaps/slides-app --filter @allmaps/svelte-canvas-panel test diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6dc9a6..b747532 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: '@sveltejs/adapter-static': specifier: ^3.0.10 version: 3.0.10(@sveltejs/kit@2.53.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)))(svelte@5.53.3)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0))) - '@turf/turf': - specifier: ^7.3.5 - version: 7.3.5 maplibre-gl: specifier: ^6.11.2 version: 6.11.2 @@ -48,9 +45,6 @@ importers: '@allmaps/annotation': specifier: 1.0.0-beta.38 version: 1.0.0-beta.38 - '@sveltejs/adapter-auto': - specifier: ^7.0.0 - version: 7.0.1(@sveltejs/kit@2.53.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)))(svelte@5.53.3)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0))) '@sveltejs/kit': specifier: ^2.50.2 version: 2.53.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)))(svelte@5.53.3)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)) @@ -85,12 +79,6 @@ importers: specifier: ^7.3.1 version: 7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0) - content/gravity-at-sea: {} - - content/kattenburg-atlas: {} - - content/reuzenarbeid: {} - packages/iiif: dependencies: '@iiif/builder': @@ -160,9 +148,6 @@ importers: '@tailwindcss/vite': specifier: ^4.2.0 version: 4.2.0(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)) - '@turf/turf': - specifier: ^7.3.5 - version: 7.3.5 '@types/node': specifier: ^25.9.1 version: 25.9.1 @@ -1283,11 +1268,6 @@ packages: peerDependencies: acorn: ^8.9.0 - '@sveltejs/adapter-auto@7.0.1': - resolution: {integrity: sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==} - peerDependencies: - '@sveltejs/kit': ^2.0.0 - '@sveltejs/adapter-static@3.0.10': resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} peerDependencies: @@ -1428,363 +1408,30 @@ packages: resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} engines: {node: '>= 10'} - '@turf/along@7.3.5': - resolution: {integrity: sha512-Ee4AHV9j2Bnlm/5nm4ChY7nkwpaaMffSez9nsJ9HcMbzG+GgTkt0F5pn9d02U7YvuWqckM5lfr3kBI+w2uTz+g==} - - '@turf/angle@7.3.5': - resolution: {integrity: sha512-+c3UZfkL1nNacfpLkj89rRreIlKM5UALeeWpcw7hCEK4MycXCBmITkyLLXOzoQaRBc/7ua4PV0Ov13Y6Ck9PzQ==} - - '@turf/area@7.3.5': - resolution: {integrity: sha512-sSn80wPT7XfBIDN3vurCPxhk9W4U8ozS/XImSqeLN8qveTICOxzZkhsGDMp0CuncaN+plWut4a2TdNM7mzZB6Q==} - - '@turf/bbox-clip@7.3.5': - resolution: {integrity: sha512-FuADTCBfRwdzJb2gFawDGnD1jKlUOfsWcu5Uv8iyEgu9JLuLAIYtI/l9xVF76BAoAvSkLgMfUvrPQ4SXCSUrVA==} - - '@turf/bbox-polygon@7.3.5': - resolution: {integrity: sha512-Cs9Laej8zfSY51kORM9UcbY4Uf/7X3OIZr/LydbrmmARFSpYH/FZsEQjAGi1S1Q0aYbibVqjEUReHN3ZVsV5eA==} - - '@turf/bbox@7.3.5': - resolution: {integrity: sha512-oG1ya/HtBjAIg4TimbWx+nOYPbY0bCvt82Bq8tm6sBw3qqtbOyRSfDz79Sq90TnH7DXJprJ1qnVGKNtZ6jemfw==} - - '@turf/bearing@7.3.5': - resolution: {integrity: sha512-/qabIt/IuPsGlE6RukJ0zOirc6afNxoK7fK1WBNVnHuJjeOpSkW+7q1QW5XQGXBMv3odcD0ZgRR+MBiAHduYSA==} - - '@turf/bezier-spline@7.3.5': - resolution: {integrity: sha512-54qnreNRvV9BeTGz2YXJyma+m8HMusuXWw3AkG0bIJGtqJMHqFKC/rWOcYxVj1VM2ScoTgglFxvq7GtSna+KMw==} - '@turf/boolean-clockwise@7.3.5': resolution: {integrity: sha512-VpDIpTKBjH4oPbzx1ns18TWcosi+qCRtgU1HIJHLj4NWyLNr7TX86DvomCvZRPfsFxkYYgkFlqHBG1a29dcyqQ==} - '@turf/boolean-concave@7.3.5': - resolution: {integrity: sha512-CHrmnEww43CAwEPszrKtLjM13hWDmsFZe0eCocxOtXLMspj+LCheB0bjMfcoBYfoTsvOPXxnoLTK9n4xuWBRSA==} - - '@turf/boolean-contains@7.3.5': - resolution: {integrity: sha512-P4JUAHgvJkD+8ybQ6d1OHp9TBsGsjJxF5lWeXJgp0k4+Hd/D0CVy4/mhLkZdNa6QdljVdwNcfU0CTqy1WsSQig==} - - '@turf/boolean-crosses@7.3.5': - resolution: {integrity: sha512-o4Gmb2gbPl4j7810ZrT9GZsGigY+HRwcOI9pkkKH6BJ4ewSlQCPzP4JFruZp0+mIXS6NnPv7+Lw3J8LN3kYw1g==} - - '@turf/boolean-disjoint@7.3.5': - resolution: {integrity: sha512-Pz1GGUC6iL6xGVqhyo+fYg35kl4j/HONMEoC1voN3DcBOCcVlzq+ljvMpvE+oQiR7Q38xLhIxZneLCqMp5YrQA==} - - '@turf/boolean-equal@7.3.5': - resolution: {integrity: sha512-xjSFi/BpxBY5tDDuSbixIRVq2AnDGcdLL8XOHzZtJWZjSa6tAi6afxSiMbQTGIhYfwwcB/sJcIUUffBaIQ2BiA==} - - '@turf/boolean-intersects@7.3.5': - resolution: {integrity: sha512-Z6GPYjozrmTuzWQD0x7o8RPm+4HC7hz9q23hdB3U1+Qahesv8Mtc+wo82tO4CG6/NRnJ9u79DlEhmR1DxUU4iQ==} - - '@turf/boolean-overlap@7.3.5': - resolution: {integrity: sha512-DUeiPVqFSTjW79erPbo780pRcKCRad59NcscVsTYkZD/92peF/4rQvHbvAUpUDPZaQCxe+mvfeDHfUFmfVKCGA==} - - '@turf/boolean-parallel@7.3.5': - resolution: {integrity: sha512-+shvGYFUx1vPQRPNeKiJcHtLAf9fl3mzAl9tBx2z+dU0IZDKz76/MAaDYSrMya/BYilFZ0E4RCEVq1X3s+AfkA==} - - '@turf/boolean-point-in-polygon@7.3.5': - resolution: {integrity: sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg==} - - '@turf/boolean-point-on-line@7.3.5': - resolution: {integrity: sha512-TuWfrAT63W43BDzgYc94UzQ5/PjF1aTnh4AIzmQwez1YnimShYcOTwo8OIHzDaB6gbbvFsfxYMuOA5JOp942Kg==} - - '@turf/boolean-touches@7.3.5': - resolution: {integrity: sha512-GwD0gmbV1p+EFHojBjaa1cwGMybayOZUDLj562RlAAoexC8ownrW7W6oMAlM2VCxk4gq6CLx3hss9pONuQcKjQ==} - - '@turf/boolean-valid@7.3.5': - resolution: {integrity: sha512-ZezJCgFJS0JRJfj6fVSI1idcifTaWFW70NYRCUur9926DHLvSkfbtEF5i63yPQt2Zxx00FTUJPcHRzdgOiwECA==} - - '@turf/boolean-within@7.3.5': - resolution: {integrity: sha512-FsZhkAoi9KU2F0D5dyOQ38A7y9Bk8XTmUfLKAJa12xHN0QtXZEYH86YdVlrI1XgfFB9gmUddJPFX+bJATy6rxw==} - - '@turf/buffer@7.3.5': - resolution: {integrity: sha512-TGls3nYtWzviKHT00XVBfHKa7Z2oIZKqiHN7R0xErGwMSAR7YhxVROhxq/iyIsWZjl1SlPwweZZIxWILQuxpZA==} - - '@turf/center-mean@7.3.5': - resolution: {integrity: sha512-8IpS7zUZg0fuUpN9ViqT4gR6YMjSR1R1kHysaDxhP1zMrTJ84U5lGG+VQC7HpHqybOnuwkXZrV970h9Ni78n6g==} - - '@turf/center-median@7.3.5': - resolution: {integrity: sha512-e+NeDKyZzIzSTA0qd9cwIuguCnDQSc6TLd1MjkHKTd37PCaPSc40TMEIBX1x+kJRwOxzmUgpubfHUG9zfjdtyA==} - - '@turf/center-of-mass@7.3.5': - resolution: {integrity: sha512-eEzx4YKxUP55Apdjt7XXuQ906KKx4uSQWLxJw5OHE0rKOSN/qYTSdu0s7nQBAmGgAqeSC2538vuN7wEqMfYMvQ==} - - '@turf/center@7.3.5': - resolution: {integrity: sha512-eub5/Kfdmn89ZqwCONHI7astmTDEtN5M6+JfOkgoSyhKKFhUJYNxUyH1F/vCtIP7j1K369Vs4L9TYiuGapvIKQ==} - - '@turf/centroid@7.3.5': - resolution: {integrity: sha512-hkWaqwGFdOn6Tf0EWfn2yn1XZ1FWE1h2C5ZWstDMu/FxYO5DB+YjlmOFPl4K6SmSOEgdV07eK2vDCyPeTHqKGA==} - - '@turf/circle@7.3.5': - resolution: {integrity: sha512-Tse7GJrx0rxaQ5BdY6pHZ9HFPrZwRMry2yaqq94UsUyonhy1m7U2icB3Zp4M8o4XjHIGTCq9i2BYcGJram51Sw==} - - '@turf/clean-coords@7.3.5': - resolution: {integrity: sha512-e0ZTqVWiQaCI/b8EFb+HAS8lCDomWafAKxQuIv0IYks0GwOlVTDWTwsY2RX2685j2Y+QssqOsyHsPZCtAjc3YQ==} - '@turf/clone@7.3.5': resolution: {integrity: sha512-qfIaHj3410QEcTpiCRnTzhq8YrUp2gWrUIPLBAEFykopNxJkq1du1VrRzvuAo36ap2UV7KppkS6wGNypbcxswQ==} - '@turf/clusters-dbscan@7.3.5': - resolution: {integrity: sha512-xdc0ccv2fyiUSWrJYJFE6RTluf8oVCB0/aCOdUD/ZvtG3eyqGIXMRMzAR1PujQHjBWtI+ndD4Eq+DqiGukGK+g==} - - '@turf/clusters-kmeans@7.3.5': - resolution: {integrity: sha512-MnKymdmL7vy4TR5CLkcNkNgsJP8ZBEiWkqnRYBJLq/hFoppTvXxKvkRzftWtLfkIWb5oQ2XMvBh8BgALN22d5A==} - - '@turf/clusters@7.3.5': - resolution: {integrity: sha512-ZYQSCxElarAaG4azNieZ0ylX1sietkHIVAZv6H5JfSz/EXbAekQIom/isoynfDl/jVyNonDT7UrDZdCIjKQ2Gg==} - - '@turf/collect@7.3.5': - resolution: {integrity: sha512-ddcBDdnM2Rwjfedxsbjvb7Zhoqo6uCTcnaHvu3ej/g+Z9iJ/K2wkRWhEUVAg/wppso5io6qEPmmPpNlefePt+w==} - - '@turf/combine@7.3.5': - resolution: {integrity: sha512-olQH6WmLl3XAalbpiz7RSRr4/mogan38gvgDlo37ypW1ckeBUi+2TX5HgJUZq4wxa2gMlny72QYkqY9zW4Jw+A==} - - '@turf/concave@7.3.5': - resolution: {integrity: sha512-T496U4K5mXsJXTKjnf7eW+4SYoDP0bXWhpcfpWuCAaDJy8n1Q8Dbslj7wqrWd2lqIhulVRF+3zWAh0bJS34L5g==} - - '@turf/convex@7.3.5': - resolution: {integrity: sha512-d/pq86he+/GB/2ObuBHapeT15QFsCPhhGab3uE4YjogLMbD8qtu0sRGF+cvvPRDNuCLycOBL/xgFLnf9SteYlA==} - - '@turf/destination@7.3.5': - resolution: {integrity: sha512-x6ylChhOlIbucRSw7wF6z2gSEqqQl+dE0nSH5AL/ojZkqqGYahiw+2P4A8ZMuDM6rzH+FIqRYDes0o4nSArZCw==} - - '@turf/difference@7.3.5': - resolution: {integrity: sha512-iDyWYCvgS3vgB8W1ai2cvfJaTq9bOt1QghiVkCNIyccF97CgtYJzenREVVUlp8qU9lJlbQ/ameyKvXPA1uOAlA==} - - '@turf/directional-mean@7.3.5': - resolution: {integrity: sha512-Fm3rVgX7xiCL8Ed68dZpUl5NEAgEpaUdkAaZLvhedfUmKcuXcfBwX8RebHwNWctxcvKVOhoAMSlogpV33JnKdQ==} - - '@turf/dissolve@7.3.5': - resolution: {integrity: sha512-6kZTc8rbGuBqxWW8iK1sJZC8r7vr5uWdv7nsMJ0eX8/g0mTKNgSGXo14hYqp2GN4bE7JzpAgpFeSX8Xu1psxlQ==} - - '@turf/distance-weight@7.3.5': - resolution: {integrity: sha512-GpV2h5ZkbWMdUFe5BWm9lfeLFMnYR8CRj6HiguZBLesarSub6I0UNO1u8UhkRGi26YLGnFlBh7c4KyaakNx1Gg==} - - '@turf/distance@7.3.5': - resolution: {integrity: sha512-uQAC63zg/l91KUxzfhqio7Ii3+UXTrPOVJScIdRj6EO6+9XHI4kC+AdyIS4cPAv14sZfJLIBxzMnzcGrss+kEA==} - - '@turf/ellipse@7.3.5': - resolution: {integrity: sha512-C478LrdvUkjwIVGN6PoYsFp27PuT+W2IH4ZOVt9sd4359hRPFhJ2m+f8jSPfS6rdamdvc/O+h7vNmOIBRP/TeA==} - - '@turf/envelope@7.3.5': - resolution: {integrity: sha512-0Sc2AVx0JZ3MaQ0k6+khplU3wO7bwc1qAQ6Fd+Q4RhIVPY2JKstBdq5ftU1T/BHNf8KK+9y4qbSV2u8hnw/PqA==} - - '@turf/explode@7.3.5': - resolution: {integrity: sha512-Qdd7ehX5AF0DdpJl+JJyxws7jEmsZBRV35wTm+Lyhapuc3yLHU7tN5EqJ+2lVV7RkUgBXEFQV+34pmPLPGQn+w==} - - '@turf/flatten@7.3.5': - resolution: {integrity: sha512-4dDAyoY8wf4UCIJ2lH3JbypmEeqyuRFExHEPu6eJeaOybdpOV9kn3LqB0lsWArizFjJWQNS+0qpv08jD85jSPg==} - - '@turf/flip@7.3.5': - resolution: {integrity: sha512-qDSBFqo5+8yE5gfBCsQZxgj0bGRx6abQg6TgYvg1wvYYHtUJL/0VS+QdDTmxZiYm8N46uNAv9pKvun9MGK+elA==} - - '@turf/geojson-rbush@7.3.5': - resolution: {integrity: sha512-30/hQqc+ErnlcavvDdxGfgm8VtsJDEzSYpf3mPqYxOyI976l49T6+1jCQD5xKswml6o8zZAaTSe6ZcSKF+SCNw==} - - '@turf/great-circle@7.3.5': - resolution: {integrity: sha512-VAio6hwPJIheSzrvsMkLDMHkCfHyOi4H4r1Y2ynb2oMiGiqZjkQ7jIFlRYBcTyO2RnwEOgwM/d3kwRImAwMVBA==} - '@turf/helpers@7.3.5': resolution: {integrity: sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==} - '@turf/hex-grid@7.3.5': - resolution: {integrity: sha512-sPtYXqbNvUxt8h4NzspcoFAVotd/75LgrLxxI84LGIVPCN+fsK1uWwr4a6MAlzfSbWbOYOq7OSWMSSXzHoQzJg==} - - '@turf/interpolate@7.3.5': - resolution: {integrity: sha512-rl5LwK85etpvbSW1VEXp/I85kzgiGviXInrvvhQxzEF9xGKvs1ed/6dc1uy1LsczSr4wUnbdGATzZTF9lFYjIw==} - - '@turf/intersect@7.3.5': - resolution: {integrity: sha512-v11Do9ySbsE08ffiwboQeFKvYByyxzvAz0ls837A9T3rSC+8vKMmK815S+C5v8CBMLNhuBCSgqnOIV3zonNICQ==} - '@turf/invariant@7.3.5': resolution: {integrity: sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg==} - '@turf/isobands@7.3.5': - resolution: {integrity: sha512-WX6vpPkM8O8SY7hsijOtj4owVXP24nU33Q0eMhWdZ+YiDmAHgEOQcDhPJLvp0mIbyYj81mU73hdnilf3q9tk/A==} - - '@turf/isolines@7.3.5': - resolution: {integrity: sha512-n5QUX1/Z/PuPVpTUrKhYcKF95L5+nKF8hWznYtG/GsiMXfRqMeAEjTCqgKIgF8T65H4LrvcpLyq8VPQSi7aISw==} - - '@turf/jsts@2.7.2': - resolution: {integrity: sha512-zAezGlwWHPyU0zxwcX2wQY3RkRpwuoBmhhNE9HY9kWhFDkCxZ3aWK5URKwa/SWKJbj9aztO+8vtdiBA28KVJFg==} - - '@turf/kinks@7.3.5': - resolution: {integrity: sha512-dPW8d4vs1v8WMobjyq/TVqajjPwkMsl94IF58yp1UYlmJDQrW4iNRUmI9fFzww+fl7epCKNwY+jZhXf1DRi93w==} - - '@turf/length@7.3.5': - resolution: {integrity: sha512-Bi+vEP54wt1ly3BRcCOP0nd2kGTYEhGk6haQxTpkrqr3XtmqDh8c3NowSgseN2cegIZRjwCOEC8eSsZ0JemJdA==} - - '@turf/line-arc@7.3.5': - resolution: {integrity: sha512-XrPicIoN7XCtiJ7e7ELje7GjMZrBw/nuJnz0mQoCwwHwmX8Oz9oRfb6uaiS8NeR4WBzUVdkmVR/ro0kW4lypog==} - - '@turf/line-chunk@7.3.5': - resolution: {integrity: sha512-z5/EBv79oyNXMYFpFIsA9gw/7TAKoJ9a/Ijo/jBeO47Fr1mV3JLtE3aB2i/nqLTYZWMU7K3Lnzwqt2Rn5Gri/Q==} - - '@turf/line-intersect@7.3.5': - resolution: {integrity: sha512-2Cl4oPsjaDdfIwz/5IRDdG2fNdfp3W6atICm81vnzl/GwURoVP+CLjXJ64QWWzpzIbgX2XprJQTmamByDt5MDw==} - - '@turf/line-offset@7.3.5': - resolution: {integrity: sha512-7tNI+4xKFOTQj720aJI7vuRMyTC+4hAqd7N8AnnZ0EpzH+sBfyrt8rNI/Vf3+d/iY0c9ZNbhU9Qhyb0ckJUdsA==} - - '@turf/line-overlap@7.3.5': - resolution: {integrity: sha512-7lZMWYHuzM6EMlL5pIIi/Nh7GLsM7ilW8/jVyHP1yGfQ0c0YAUoJd/Xy3J2+9v8OkYiZW/t2LdwVoTmCEJLWxg==} - - '@turf/line-segment@7.3.5': - resolution: {integrity: sha512-TM1aCu7utM6fllAEHO8PNqBJZ/uoFJVNp2A0YI7FyWN928hPbacsvNtLeVz/Kq1ZbeqQ1ZIKRxo9FdVjaj8hGg==} - - '@turf/line-slice-along@7.3.5': - resolution: {integrity: sha512-zLOU9mGFXJdbPvA3/zXpDsBmXY2paA7eD6/p0iYiP9OASYO0GDcarwY5K/E0uuEdLrMf8G8YA2cJDcEl9gRgFw==} - - '@turf/line-slice@7.3.5': - resolution: {integrity: sha512-BGw5N4UvjH691J/z1P2S+hWBgCcvbl2/HXaqGvKTijXw5i1vzsZ9m07wLl/qH+i38OJDRMJ/+FkNZnrKpsoXLw==} - - '@turf/line-split@7.3.5': - resolution: {integrity: sha512-GEuy+LdbbaqtYjHk/i1G8sK51wfCdPqTO8uH0dJZ6WlcIcZQfRcKKI4ksFm7NkVyfmw8gXWbpMJD8lO380GFBQ==} - - '@turf/line-to-polygon@7.3.5': - resolution: {integrity: sha512-PNWDN1B0nJRlgA4DAXeYEf53OZSWwsu/rtDB4wxe/1NwfM9zlDVElQ/mtgDPtZpwC2aDKV5+B0vTXfKR/MMIfg==} - - '@turf/mask@7.3.5': - resolution: {integrity: sha512-zs9bBtdANOsSkG7xiLFYforeI2nszXu0QwPv3vkaX747oEVdcyd0nSW81aQRmSWw/fvhXja++8duIVXOIYr4ng==} - '@turf/meta@7.3.5': resolution: {integrity: sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==} - '@turf/midpoint@7.3.5': - resolution: {integrity: sha512-y92O2YDDBkZp7jAUuUPgof/HiXHjJSkKjEtQRjWXZcjTlzHd/Fqg6/twarY2wNkENK2EuaaiV9MDy74FeRG2Ow==} - - '@turf/moran-index@7.3.5': - resolution: {integrity: sha512-l/FgZkgPzwAsdj7rtKYWv2rxeTy+Tv8NMk6qSEwKqB4eHqn1TYHcTASWRcZIyipH4LtOJJOl77n7FmKcaDBtSg==} - - '@turf/nearest-neighbor-analysis@7.3.5': - resolution: {integrity: sha512-6vnSqt7OH8zTPdvUxCYnnN86Ci8VS5G3q2G1UrAcrnjTH7DbJ4KvIkRQlv4y6hj53G+bm9cA6TjeJuyP2jAOcg==} - - '@turf/nearest-point-on-line@7.3.5': - resolution: {integrity: sha512-MZn6OkEFZpjS6BNUANfqiHMIbQSivu7TNji3a+OAIrnPJ71vp8cbz0N2aVEa5M7I8ipvxoxAPIV3eqg3h280Vg==} - - '@turf/nearest-point-to-line@7.3.5': - resolution: {integrity: sha512-O27A9dFAqDYBRPhhoLP82Da7Q6rd0fXRR/jwQcBiycjTGdsXJmzxywuR08aDvWiARbJAmohEzdYzk4f9/eWXhA==} - - '@turf/nearest-point@7.3.5': - resolution: {integrity: sha512-qcsbj9fo5CYhGeIDaJORoqiZyjNGu2+Te31MVaFoTTxqqkXypxp9e6HJGvUi2zPd1Zk3oAWXhvm1a+UXw2G56w==} - - '@turf/planepoint@7.3.5': - resolution: {integrity: sha512-+hjECX1hDbol8psuG6iYSwcZHQ+RPJqFIBh3pXKc/pa0cdjZyB/L6q1d8ahnFrQBEpuoO3XVvo2hHN49VOEPjw==} - - '@turf/point-grid@7.3.5': - resolution: {integrity: sha512-aEPnqj/4C9ejNz4uCJKgk6Flux6SxnqxjQHAYPIP5C7ooAB1xRAT1NMnCzxUjjUq1SMtgdZ6skoNfvKNK4Rzrw==} - - '@turf/point-on-feature@7.3.5': - resolution: {integrity: sha512-Y7W+JZJCmm9Qq93NHpk4dVhzAtAk2Uyau2Uk1ttCJ2Jsnuu4dwSjOurpmgDmMUe7yPmzihAUYhMFDumpAja8ZA==} - - '@turf/point-to-line-distance@7.3.5': - resolution: {integrity: sha512-mR1NAIX+JfpYAJQ9u3gpIV37QzYvBOefDP+/16uBCAOM8Fp12goT8l7WdenT0dvB4wPibbqM2+2kyEl5u9XJog==} - - '@turf/point-to-polygon-distance@7.3.5': - resolution: {integrity: sha512-SQPhAlfiuCkIIFos/OPCxtt8iR3BlZqGKzKXLYqt6z8iaICaf2SjMyn4Zsr1oFO+Gy2K1rqandR+kuLTIo8Eqg==} - - '@turf/points-within-polygon@7.3.5': - resolution: {integrity: sha512-TRyVY5Xlx6j72sNIyBaHZNgTbILs2iUDevX5lnCFTiThkzp25BIBBQ77tv2VPilYH+v7+9/0T/pLO37SaVhsQw==} - - '@turf/polygon-smooth@7.3.5': - resolution: {integrity: sha512-dc/dlYFqVBcel6d5HM5tBANh1HfWbJ89lSVLR7YhRX+Y/UABTvbtJCWwxBBiFxFBeRmW7B45nv3jytHUYxQUOA==} - - '@turf/polygon-tangents@7.3.5': - resolution: {integrity: sha512-3/TtCQlXc2Lrgzb+LjwqbtILWan7lRD6P9Nn3edm2xGAZw5WY17+yZfpeySJPfcZhOWten9dXkOwDvSZF5uyWQ==} - - '@turf/polygon-to-line@7.3.5': - resolution: {integrity: sha512-Mat5tvJcW3grpXCNFcMvjHL3d8hO4eoIgF3qYpXj25BHx/S7SJUOgyCV5x3arC0rCfM/cB71VmNDm9k57ec7bw==} - - '@turf/polygonize@7.3.5': - resolution: {integrity: sha512-pD3Zv/392sLlZVKRGUrJ4CKLN/Im+TO6wMCOfm/uiq4F9pEL5R+HPvXTcwEyxbhEPKYn3cylGKt21Wq0OfNQDQ==} - - '@turf/projection@7.3.5': - resolution: {integrity: sha512-G4bejYKT0vCQZryMhEoS9aLmP7ThDg6nb3zi3wPzELiTrGNOd2YgkWVheQDGCk4hcqEIWZc9fI2alaRSSkkLVw==} - - '@turf/quadrat-analysis@7.3.5': - resolution: {integrity: sha512-e5Am3cJuiP43f89Xv7n9cv3Z4P0I17zBvQPvhj6UowpRbiYoD4TKHfOr2PWHLYUkXjQ+vKY5CwiWvCYbfj3BIg==} - - '@turf/random@7.3.5': - resolution: {integrity: sha512-Fid43jfmQpvrpr/wrUaW9hr66ukPqfZPuwzEt3gfCx6mAVpFWbCPx2yRuVlLNiRkMgmKTphFfh6267UCPOSnCg==} - - '@turf/rectangle-grid@7.3.5': - resolution: {integrity: sha512-SVtXOJIz7FYaRUinxb0HfE/GNSJU6eU9tlKQaERDo/vG7wjPoTCDvnH8p4BdE5GRdXZMjvBUhtNdMj+M3rGRjQ==} - '@turf/rewind@7.3.5': resolution: {integrity: sha512-4AZdDh55JeCoKWLS5AC6MuXqAvoqSpj2WigtowtA3JIJ/1J4SclRrV6BGq/Xemwm5yKtRePHazBVUmG5uU75og==} - '@turf/rhumb-bearing@7.3.5': - resolution: {integrity: sha512-pYjBAuQTND0+6Y+v+zlQ7Y68SGjP4iG8qJ5QKjFRbB/yeorTY3m9yiXIN4lSyno3TPzEgBeNULuo26H6ygDyng==} - - '@turf/rhumb-destination@7.3.5': - resolution: {integrity: sha512-znICdPBtZq5EKh/Qu0TkiMyNhqiYfM2VUlFOWa7iegCWe1E+X7q/f6rlZ5TcmYVyLZ95XZ6eciDNmgVmpHVWaw==} - - '@turf/rhumb-distance@7.3.5': - resolution: {integrity: sha512-dBFxmKrjRaAdwc4SCmGyAS2BDPCH35IBNl++Ypd1RB8JR2D3khl3zrTvxrlJ5qoN1WDvyPbvDYCrt2UnTX+8Nw==} - - '@turf/sample@7.3.5': - resolution: {integrity: sha512-ayl/QlDAkqIDJjbzcBeAsMzFmIDanQP4xXdNKZmnYg83FhvH/Sgu7mMNNhrYwCVMWrmOicviDHu3xKuJ6jVnMA==} - - '@turf/sector@7.3.5': - resolution: {integrity: sha512-9mtBorGPZfbZI2MoI0nkN5ogmbCz/NLpHdEM0UwwWiOW430iPhwZ649DXH32lDGerfzREX10vpamX8v1P9YVrw==} - - '@turf/shortest-path@7.3.5': - resolution: {integrity: sha512-RmrfdDVTuBsHDNr88tGMRmIdJNFTdna3nQWRF8yFsgKR9LhZ4MWqebL24eQFn2hkyclL7O6lyPpOEJJtQxvcDw==} - - '@turf/simplify@7.3.5': - resolution: {integrity: sha512-9wzxlRA+0yNALeqyXNHFgj+8ebsg2aKdMrWRiSMS+ZqbiknZ/mCARiDYqM2Qz8TgTqdrNt53ze4o1vS6WeJrJw==} - - '@turf/square-grid@7.3.5': - resolution: {integrity: sha512-mcwefBumsO3nwRG4nPfmXsq7YqHOsa71Z3h4JwWQn/XOrhV/8l1/QX3IAIx1qUWC2RqRMOImt3et5mlc+g2SxQ==} - - '@turf/square@7.3.5': - resolution: {integrity: sha512-zUh9cxlZ4bPkSK2XynY26JSLDVy8oUss94mXACqSGrGjv308q3Voj4dzvfeh0E/Q3PQ3D8GJRZECXdCB/PU78Q==} - - '@turf/standard-deviational-ellipse@7.3.5': - resolution: {integrity: sha512-CIJaQ61bjmxxqi5CpRLWAwxnbeHcG6e7esK2IX2DXBIE/7p/F7Sk9F2GOAL6vt1o29GnmzU2APHZvTX/YKcLbw==} - - '@turf/tag@7.3.5': - resolution: {integrity: sha512-jwLOP7ZFfOcMPbK+0puT0SMOs0urSKYSYax7G4LDtZ9oPFAicVJtr2K0OB/rgmdoTK4VfUwb2nVZUdYAVnXtAw==} - - '@turf/tesselate@7.3.5': - resolution: {integrity: sha512-MI+pSkehJyZmbl2L5/43B9DlD6MEDcr63pW/LDSXYRDoRUXtSGY/NAAibIQ0gMAr8VxsimGCZKSuGMNUZdsuRQ==} - - '@turf/tin@7.3.5': - resolution: {integrity: sha512-70747SmLQttt+ywq+NmMqmkHdXsinO57SjHNFKX6G6qHuvxUu48vN7Kf3zjkqGAp2kQnu38OOqB4QPus6RdUqw==} - - '@turf/transform-rotate@7.3.5': - resolution: {integrity: sha512-WXkteI0DihwCukZesVXVVYpsoyftYoiBtq1b+u1Dz4HyATK25zfEeWChlgRtApbiePF6uqG7bSQS+K8vjC4c0A==} - - '@turf/transform-scale@7.3.5': - resolution: {integrity: sha512-eAmey/LtJVT6WlCMULsnd+sCAOwCezG6lVP67qN5HARZ8ft+b7yBiU4FC+IGXbVsrdRcCRLSzoQc6K0fROoo2Q==} - - '@turf/transform-translate@7.3.5': - resolution: {integrity: sha512-OMQLOFLIjqeDNARaa2kdh1AgvWKFgq41/I6k3CAaj1UcB4javpVFMlcjRJY+pL2oZtMRZRcUZxhO+zSNl6p83Q==} - - '@turf/triangle-grid@7.3.5': - resolution: {integrity: sha512-oiwtTm+yqZwuODOSEyLSQSFaZFyjC6kgftUFFW6kLGQtm+Fw1GjxwS7QjiY55GRWxgfgbg2l1iNZNgfsCiPQWA==} - - '@turf/truncate@7.3.5': - resolution: {integrity: sha512-Qx2iv3KIqKuDAUduMfaJ5fFegEWBeRve5zePalRevS16bMUqEX+jnKPK9fWGyUuPqT61qP1Kybz0PTWPbUbljQ==} - - '@turf/turf@7.3.5': - resolution: {integrity: sha512-l5Z1ZFEizN9p5GxX3mzUGf+i4t7AP3YpWcNdf9+kIzJcQD3eYuGBabj2hLrfrluqFJ+uxsuo4RgPtortQ9Dwpg==} - - '@turf/union@7.3.5': - resolution: {integrity: sha512-/FSKhl+LX4+M7L/Trmiln0CDPWS8vCneGnQktt1o5XbCY/zIpH1JdxHEBFXhFZg4beAyXCz0uuxRyW9N/DH+KA==} - - '@turf/unkink-polygon@7.3.5': - resolution: {integrity: sha512-7MwKCm7sPHVF4xBO/3s08/DtA/09UEBXaLNnm8/RUq3abS5zZ9PnakLsd36J18IHDscM6RmH7IZPLSwYh4TLcQ==} - - '@turf/voronoi@7.3.5': - resolution: {integrity: sha512-v51D9H+er/K5lP+rBs7jIBEXTFhl0FQOZn4mfhb7brN5sGGDmnfEFOaxIYOiJN4Qco1GtFD2Tq0NgZ8+dmJmEA==} - '@types/caseless@0.12.5': resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/d3-voronoi@1.1.12': - resolution: {integrity: sha512-DauBl25PKZZ0WVJr42a6CNvI6efsdzofl9sajqZr2Gf5Gu733WkDdUGiPkUHXiUvYGzNNlFQde2wdZdfQPG+yw==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1800,9 +1447,6 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} - '@types/kdbush@3.0.5': - resolution: {integrity: sha512-tdJz7jaWFu4nR+8b2B+CdPZ6811ighYylWsu2hpsivapzW058yP0KdfZuNY89IiRe5jbKvBGXN3LQdN2KPXVdQ==} - '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} @@ -1984,10 +1628,6 @@ packages: anynum@1.0.1: resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} - arc@0.2.0: - resolution: {integrity: sha512-8NFOo126uYKQJyXNSLY/jSklgfLQL+XWAcPXGo876JwEQ8nSOPXWNI3TV2jLZMN8QEw8uksJ1ZwS4npjBca8MA==} - engines: {node: '>=0.4.0'} - aria-query@5.3.1: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} @@ -2110,15 +1750,9 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} - commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concaveman@1.2.1: - resolution: {integrity: sha512-PwZYKaM/ckQSa8peP5JpVr7IMJ4Nn/MHIaWUjP4be+KoZ7Botgs8seAZGpmaOM+UZXawcdYRao/px9ycrCihHw==} - consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -2135,9 +1769,6 @@ packages: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} - d3-array@1.2.4: - resolution: {integrity: sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==} - d3-array@3.2.4: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} @@ -2150,9 +1781,6 @@ packages: resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} engines: {node: '>=12'} - d3-geo@1.7.1: - resolution: {integrity: sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw==} - d3-interpolate@3.0.1: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} @@ -2169,9 +1797,6 @@ packages: resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} engines: {node: '>=12'} - d3-voronoi@1.1.2: - resolution: {integrity: sha512-RhGS1u2vavcO7ay7ZNAPo4xeDh/VYeGof3x5ZLJBQgYhLegxr3s5IykvWmJ94FTU6mcbtp4sloqZ54mP6R4Utw==} - data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2245,9 +1870,6 @@ packages: duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} - earcut@2.2.4: - resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} - earcut@3.2.3: resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==} @@ -2318,9 +1940,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -2391,12 +2010,6 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} - geojson-equality-ts@1.0.2: - resolution: {integrity: sha512-h3Ryq+0mCSN/7yLs0eDgrZhvc9af23o/QuC4aTiuuzP/MRCtd6mf5rLsLRY44jX0RPUfM8c4GqERQmlUxPGPoQ==} - - geojson-polygon-self-intersections@1.2.2: - resolution: {integrity: sha512-6XRNF4CsRHYmR9z5YuIk5f/aOototnDf0dgMqYGcS7y1l57ttt6MAIAxl3rXyas6lq1HEbTuLMh4PgvO+OV42w==} - geotiff@2.1.3: resolution: {integrity: sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==} engines: {node: '>=10.19'} @@ -2663,10 +2276,6 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} - jsts@2.7.1: - resolution: {integrity: sha512-x2wSZHEBK20CY+Wy+BPE7MrFQHW6sIsdaGUMEqmGAio+3gFzQaBYPwLRonUfQf9Ak8pBieqj9tUofX1+WtAEIg==} - engines: {node: '>= 12'} - jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -2990,12 +2599,6 @@ packages: point-in-polygon-hao@1.2.4: resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==} - point-in-polygon@1.1.0: - resolution: {integrity: sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==} - - polyclip-ts@0.16.8: - resolution: {integrity: sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==} - possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3031,24 +2634,12 @@ packages: resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} engines: {node: '>=12'} - quickselect@1.1.1: - resolution: {integrity: sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==} - - quickselect@2.0.0: - resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} - quickselect@3.0.0: resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} ramda@0.29.0: resolution: {integrity: sha512-BBea6L67bYLtdbOqfp8f58fPMqEwx0doL+pAi8TZyp2YWz8R9G8z9x75CZI8W+ftqhFHCpEX2cRnUUXK130iKA==} - rbush@2.0.2: - resolution: {integrity: sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==} - - rbush@3.0.1: - resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==} - rbush@4.0.1: resolution: {integrity: sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==} @@ -3116,9 +2707,6 @@ packages: resolution: {integrity: sha512-UZLM57BW09Yi9J1R3OP8B1yCbbDK3NT8BDtihGZkGkGEs2b6EaV85rsfJ6yK4F+8UbxFFmfA+9xHT5ZWhN1gDQ==} engines: {node: '>=12'} - robust-predicates@2.0.4: - resolution: {integrity: sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==} - robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -3255,9 +2843,6 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - skmeans@0.9.7: - resolution: {integrity: sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3274,9 +2859,6 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} - splaytree-ts@1.0.2: - resolution: {integrity: sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==} - stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3348,9 +2930,6 @@ packages: svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} - sweepline-intersections@1.5.0: - resolution: {integrity: sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ==} - tailwindcss@4.2.0: resolution: {integrity: sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==} @@ -3378,20 +2957,9 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinyqueue@2.0.3: - resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==} - tinyqueue@3.0.0: resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} - topojson-client@3.1.0: - resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==} - hasBin: true - - topojson-server@3.0.1: - resolution: {integrity: sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw==} - hasBin: true - totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -4667,10 +4235,6 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-auto@7.0.1(@sveltejs/kit@2.53.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)))(svelte@5.53.3)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)))': - dependencies: - '@sveltejs/kit': 2.53.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)))(svelte@5.53.3)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)) - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)))(svelte@5.53.3)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)))': dependencies: '@sveltejs/kit': 2.53.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)))(svelte@5.53.3)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.9.0)) @@ -4795,1191 +4359,91 @@ snapshots: '@tootallnate/once@2.0.1': {} - '@turf/along@7.3.5': + '@turf/boolean-clockwise@7.3.5': dependencies: - '@turf/bearing': 7.3.5 - '@turf/destination': 7.3.5 - '@turf/distance': 7.3.5 '@turf/helpers': 7.3.5 '@turf/invariant': 7.3.5 '@types/geojson': 7946.0.16 tslib: 2.8.1 - '@turf/angle@7.3.5': + '@turf/clone@7.3.5': dependencies: - '@turf/bearing': 7.3.5 '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/rhumb-bearing': 7.3.5 '@types/geojson': 7946.0.16 tslib: 2.8.1 - '@turf/area@7.3.5': + '@turf/helpers@7.3.5': dependencies: - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 '@types/geojson': 7946.0.16 tslib: 2.8.1 - '@turf/bbox-clip@7.3.5': + '@turf/invariant@7.3.5': dependencies: '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 '@types/geojson': 7946.0.16 tslib: 2.8.1 - '@turf/bbox-polygon@7.3.5': + '@turf/meta@7.3.5': dependencies: '@turf/helpers': 7.3.5 '@types/geojson': 7946.0.16 tslib: 2.8.1 - '@turf/bbox@7.3.5': + '@turf/rewind@7.3.5': dependencies: + '@turf/boolean-clockwise': 7.3.5 + '@turf/clone': 7.3.5 '@turf/helpers': 7.3.5 + '@turf/invariant': 7.3.5 '@turf/meta': 7.3.5 '@types/geojson': 7946.0.16 tslib: 2.8.1 - '@turf/bearing@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 + '@types/caseless@0.12.5': {} - '@turf/bezier-spline@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 + '@types/cookie@0.6.0': {} - '@turf/boolean-clockwise@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 + '@types/estree@1.0.8': {} - '@turf/boolean-concave@7.3.5': + '@types/fs-extra@11.0.1': dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 + '@types/jsonfile': 6.1.4 + '@types/node': 25.9.1 - '@turf/boolean-contains@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/boolean-point-on-line': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/line-split': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 + '@types/geojson@7946.0.13': {} - '@turf/boolean-crosses@7.3.5': - dependencies: - '@turf/boolean-equal': 7.3.5 - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/line-intersect': 7.3.5 - '@turf/polygon-to-line': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 + '@types/geojson@7946.0.16': {} - '@turf/boolean-disjoint@7.3.5': + '@types/jsonfile@6.1.4': dependencies: - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/line-intersect': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/polygon-to-line': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 + '@types/node': 25.9.1 - '@turf/boolean-equal@7.3.5': + '@types/lodash-es@4.17.12': dependencies: - '@turf/clean-coords': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - geojson-equality-ts: 1.0.2 - tslib: 2.8.1 + '@types/lodash': 4.17.24 - '@turf/boolean-intersects@7.3.5': - dependencies: - '@turf/boolean-disjoint': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 + '@types/lodash@4.17.24': {} - '@turf/boolean-overlap@7.3.5': + '@types/mdast@4.0.4': dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/line-intersect': 7.3.5 - '@turf/line-overlap': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - geojson-equality-ts: 1.0.2 - tslib: 2.8.1 + '@types/unist': 2.0.11 - '@turf/boolean-parallel@7.3.5': + '@types/node@25.9.1': dependencies: - '@turf/clean-coords': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/line-segment': 7.3.5 - '@turf/rhumb-bearing': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 + undici-types: 7.24.6 - '@turf/boolean-point-in-polygon@7.3.5': + '@types/ramda@0.29.3': dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - point-in-polygon-hao: 1.2.4 - tslib: 2.8.1 + types-ramda: 0.29.10 - '@turf/boolean-point-on-line@7.3.5': + '@types/request@2.48.13': dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 + '@types/caseless': 0.12.5 + '@types/node': 25.9.1 + '@types/tough-cookie': 4.0.5 + form-data: 2.5.6 - '@turf/boolean-touches@7.3.5': - dependencies: - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/boolean-point-on-line': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/boolean-valid@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/boolean-crosses': 7.3.5 - '@turf/boolean-disjoint': 7.3.5 - '@turf/boolean-overlap': 7.3.5 - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/boolean-point-on-line': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/line-intersect': 7.3.5 - '@types/geojson': 7946.0.16 - geojson-polygon-self-intersections: 1.2.2 - tslib: 2.8.1 - - '@turf/boolean-within@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/boolean-point-on-line': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/line-split': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/buffer@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/center': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/jsts': 2.7.2 - '@turf/meta': 7.3.5 - '@turf/projection': 7.3.5 - '@types/geojson': 7946.0.16 - d3-geo: 1.7.1 - - '@turf/center-mean@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/center-median@7.3.5': - dependencies: - '@turf/center-mean': 7.3.5 - '@turf/centroid': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/center-of-mass@7.3.5': - dependencies: - '@turf/centroid': 7.3.5 - '@turf/convex': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/center@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/centroid@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/circle@7.3.5': - dependencies: - '@turf/destination': 7.3.5 - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/clean-coords@7.3.5': - dependencies: - '@turf/boolean-point-on-line': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/clone@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/clusters-dbscan@7.3.5': - dependencies: - '@turf/clone': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - rbush: 3.0.1 - tslib: 2.8.1 - - '@turf/clusters-kmeans@7.3.5': - dependencies: - '@turf/clone': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - skmeans: 0.9.7 - tslib: 2.8.1 - - '@turf/clusters@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/collect@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - rbush: 3.0.1 - tslib: 2.8.1 - - '@turf/combine@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/concave@7.3.5': - dependencies: - '@turf/clone': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/tin': 7.3.5 - '@types/geojson': 7946.0.16 - topojson-client: 3.1.0 - topojson-server: 3.0.1 - tslib: 2.8.1 - - '@turf/convex@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - concaveman: 1.2.1 - tslib: 2.8.1 - - '@turf/destination@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/difference@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - polyclip-ts: 0.16.8 - tslib: 2.8.1 - - '@turf/directional-mean@7.3.5': - dependencies: - '@turf/bearing': 7.3.5 - '@turf/centroid': 7.3.5 - '@turf/destination': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/length': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/dissolve@7.3.5': - dependencies: - '@turf/flatten': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - polyclip-ts: 0.16.8 - tslib: 2.8.1 - - '@turf/distance-weight@7.3.5': - dependencies: - '@turf/centroid': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/distance@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/ellipse@7.3.5': - dependencies: - '@turf/destination': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/transform-rotate': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/envelope@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/bbox-polygon': 7.3.5 - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/explode@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/flatten@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/flip@7.3.5': - dependencies: - '@turf/clone': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/geojson-rbush@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - rbush: 3.0.1 - tslib: 2.8.1 - - '@turf/great-circle@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - arc: 0.2.0 - tslib: 2.8.1 - - '@turf/helpers@7.3.5': - dependencies: - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/hex-grid@7.3.5': - dependencies: - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/intersect': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/interpolate@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/centroid': 7.3.5 - '@turf/clone': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/hex-grid': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/point-grid': 7.3.5 - '@turf/square-grid': 7.3.5 - '@turf/triangle-grid': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/intersect@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - polyclip-ts: 0.16.8 - tslib: 2.8.1 - - '@turf/invariant@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/isobands@7.3.5': - dependencies: - '@turf/area': 7.3.5 - '@turf/bbox': 7.3.5 - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/explode': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/isolines@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/jsts@2.7.2': - dependencies: - jsts: 2.7.1 - - '@turf/kinks@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/length@7.3.5': - dependencies: - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/line-arc@7.3.5': - dependencies: - '@turf/circle': 7.3.5 - '@turf/destination': 7.3.5 - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/line-chunk@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/length': 7.3.5 - '@turf/line-slice-along': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/line-intersect@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - sweepline-intersections: 1.5.0 - tslib: 2.8.1 - - '@turf/line-offset@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/line-overlap@7.3.5': - dependencies: - '@turf/boolean-point-on-line': 7.3.5 - '@turf/geojson-rbush': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/line-segment': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/nearest-point-on-line': 7.3.5 - '@types/geojson': 7946.0.16 - fast-deep-equal: 3.1.3 - tslib: 2.8.1 - - '@turf/line-segment@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/line-slice-along@7.3.5': - dependencies: - '@turf/bearing': 7.3.5 - '@turf/destination': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/line-slice@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/nearest-point-on-line': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/line-split@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/geojson-rbush': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/line-intersect': 7.3.5 - '@turf/line-segment': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/nearest-point-on-line': 7.3.5 - '@turf/truncate': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/line-to-polygon@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/clone': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/mask@7.3.5': - dependencies: - '@turf/clone': 7.3.5 - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - polyclip-ts: 0.16.8 - tslib: 2.8.1 - - '@turf/meta@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/midpoint@7.3.5': - dependencies: - '@turf/bearing': 7.3.5 - '@turf/destination': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/moran-index@7.3.5': - dependencies: - '@turf/distance-weight': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/nearest-neighbor-analysis@7.3.5': - dependencies: - '@turf/area': 7.3.5 - '@turf/bbox': 7.3.5 - '@turf/bbox-polygon': 7.3.5 - '@turf/centroid': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/nearest-point': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/nearest-point-on-line@7.3.5': - dependencies: - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/nearest-point-to-line@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/point-to-line-distance': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/nearest-point@7.3.5': - dependencies: - '@turf/clone': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/planepoint@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/point-grid@7.3.5': - dependencies: - '@turf/boolean-within': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/point-on-feature@7.3.5': - dependencies: - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/center': 7.3.5 - '@turf/explode': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/nearest-point': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/point-to-line-distance@7.3.5': - dependencies: - '@turf/bearing': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/nearest-point-on-line': 7.3.5 - '@turf/projection': 7.3.5 - '@turf/rhumb-bearing': 7.3.5 - '@turf/rhumb-distance': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/point-to-polygon-distance@7.3.5': - dependencies: - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/point-to-line-distance': 7.3.5 - '@turf/polygon-to-line': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/points-within-polygon@7.3.5': - dependencies: - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/polygon-smooth@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/polygon-tangents@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/boolean-within': 7.3.5 - '@turf/explode': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/nearest-point': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/polygon-to-line@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/polygonize@7.3.5': - dependencies: - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/envelope': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/projection@7.3.5': - dependencies: - '@turf/clone': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/quadrat-analysis@7.3.5': - dependencies: - '@turf/area': 7.3.5 - '@turf/bbox': 7.3.5 - '@turf/bbox-polygon': 7.3.5 - '@turf/centroid': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/point-grid': 7.3.5 - '@turf/random': 7.3.5 - '@turf/square-grid': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/random@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/rectangle-grid@7.3.5': - dependencies: - '@turf/boolean-intersects': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/rewind@7.3.5': - dependencies: - '@turf/boolean-clockwise': 7.3.5 - '@turf/clone': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/rhumb-bearing@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/rhumb-destination@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/rhumb-distance@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/sample@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/sector@7.3.5': - dependencies: - '@turf/circle': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/line-arc': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/shortest-path@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/bbox-polygon': 7.3.5 - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/clean-coords': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/transform-scale': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/simplify@7.3.5': - dependencies: - '@turf/clean-coords': 7.3.5 - '@turf/clone': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/square-grid@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/rectangle-grid': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/square@7.3.5': - dependencies: - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/standard-deviational-ellipse@7.3.5': - dependencies: - '@turf/center-mean': 7.3.5 - '@turf/ellipse': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/points-within-polygon': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/tag@7.3.5': - dependencies: - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/clone': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/tesselate@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - earcut: 2.2.4 - tslib: 2.8.1 - - '@turf/tin@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/transform-rotate@7.3.5': - dependencies: - '@turf/centroid': 7.3.5 - '@turf/clone': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/rhumb-bearing': 7.3.5 - '@turf/rhumb-destination': 7.3.5 - '@turf/rhumb-distance': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/transform-scale@7.3.5': - dependencies: - '@turf/bbox': 7.3.5 - '@turf/center': 7.3.5 - '@turf/centroid': 7.3.5 - '@turf/clone': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/rhumb-bearing': 7.3.5 - '@turf/rhumb-destination': 7.3.5 - '@turf/rhumb-distance': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/transform-translate@7.3.5': - dependencies: - '@turf/clone': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/rhumb-destination': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/triangle-grid@7.3.5': - dependencies: - '@turf/distance': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/intersect': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/truncate@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - tslib: 2.8.1 - - '@turf/turf@7.3.5': - dependencies: - '@turf/along': 7.3.5 - '@turf/angle': 7.3.5 - '@turf/area': 7.3.5 - '@turf/bbox': 7.3.5 - '@turf/bbox-clip': 7.3.5 - '@turf/bbox-polygon': 7.3.5 - '@turf/bearing': 7.3.5 - '@turf/bezier-spline': 7.3.5 - '@turf/boolean-clockwise': 7.3.5 - '@turf/boolean-concave': 7.3.5 - '@turf/boolean-contains': 7.3.5 - '@turf/boolean-crosses': 7.3.5 - '@turf/boolean-disjoint': 7.3.5 - '@turf/boolean-equal': 7.3.5 - '@turf/boolean-intersects': 7.3.5 - '@turf/boolean-overlap': 7.3.5 - '@turf/boolean-parallel': 7.3.5 - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/boolean-point-on-line': 7.3.5 - '@turf/boolean-touches': 7.3.5 - '@turf/boolean-valid': 7.3.5 - '@turf/boolean-within': 7.3.5 - '@turf/buffer': 7.3.5 - '@turf/center': 7.3.5 - '@turf/center-mean': 7.3.5 - '@turf/center-median': 7.3.5 - '@turf/center-of-mass': 7.3.5 - '@turf/centroid': 7.3.5 - '@turf/circle': 7.3.5 - '@turf/clean-coords': 7.3.5 - '@turf/clone': 7.3.5 - '@turf/clusters': 7.3.5 - '@turf/clusters-dbscan': 7.3.5 - '@turf/clusters-kmeans': 7.3.5 - '@turf/collect': 7.3.5 - '@turf/combine': 7.3.5 - '@turf/concave': 7.3.5 - '@turf/convex': 7.3.5 - '@turf/destination': 7.3.5 - '@turf/difference': 7.3.5 - '@turf/directional-mean': 7.3.5 - '@turf/dissolve': 7.3.5 - '@turf/distance': 7.3.5 - '@turf/distance-weight': 7.3.5 - '@turf/ellipse': 7.3.5 - '@turf/envelope': 7.3.5 - '@turf/explode': 7.3.5 - '@turf/flatten': 7.3.5 - '@turf/flip': 7.3.5 - '@turf/geojson-rbush': 7.3.5 - '@turf/great-circle': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/hex-grid': 7.3.5 - '@turf/interpolate': 7.3.5 - '@turf/intersect': 7.3.5 - '@turf/invariant': 7.3.5 - '@turf/isobands': 7.3.5 - '@turf/isolines': 7.3.5 - '@turf/kinks': 7.3.5 - '@turf/length': 7.3.5 - '@turf/line-arc': 7.3.5 - '@turf/line-chunk': 7.3.5 - '@turf/line-intersect': 7.3.5 - '@turf/line-offset': 7.3.5 - '@turf/line-overlap': 7.3.5 - '@turf/line-segment': 7.3.5 - '@turf/line-slice': 7.3.5 - '@turf/line-slice-along': 7.3.5 - '@turf/line-split': 7.3.5 - '@turf/line-to-polygon': 7.3.5 - '@turf/mask': 7.3.5 - '@turf/meta': 7.3.5 - '@turf/midpoint': 7.3.5 - '@turf/moran-index': 7.3.5 - '@turf/nearest-neighbor-analysis': 7.3.5 - '@turf/nearest-point': 7.3.5 - '@turf/nearest-point-on-line': 7.3.5 - '@turf/nearest-point-to-line': 7.3.5 - '@turf/planepoint': 7.3.5 - '@turf/point-grid': 7.3.5 - '@turf/point-on-feature': 7.3.5 - '@turf/point-to-line-distance': 7.3.5 - '@turf/point-to-polygon-distance': 7.3.5 - '@turf/points-within-polygon': 7.3.5 - '@turf/polygon-smooth': 7.3.5 - '@turf/polygon-tangents': 7.3.5 - '@turf/polygon-to-line': 7.3.5 - '@turf/polygonize': 7.3.5 - '@turf/projection': 7.3.5 - '@turf/quadrat-analysis': 7.3.5 - '@turf/random': 7.3.5 - '@turf/rectangle-grid': 7.3.5 - '@turf/rewind': 7.3.5 - '@turf/rhumb-bearing': 7.3.5 - '@turf/rhumb-destination': 7.3.5 - '@turf/rhumb-distance': 7.3.5 - '@turf/sample': 7.3.5 - '@turf/sector': 7.3.5 - '@turf/shortest-path': 7.3.5 - '@turf/simplify': 7.3.5 - '@turf/square': 7.3.5 - '@turf/square-grid': 7.3.5 - '@turf/standard-deviational-ellipse': 7.3.5 - '@turf/tag': 7.3.5 - '@turf/tesselate': 7.3.5 - '@turf/tin': 7.3.5 - '@turf/transform-rotate': 7.3.5 - '@turf/transform-scale': 7.3.5 - '@turf/transform-translate': 7.3.5 - '@turf/triangle-grid': 7.3.5 - '@turf/truncate': 7.3.5 - '@turf/union': 7.3.5 - '@turf/unkink-polygon': 7.3.5 - '@turf/voronoi': 7.3.5 - '@types/geojson': 7946.0.16 - '@types/kdbush': 3.0.5 - tslib: 2.8.1 - - '@turf/union@7.3.5': - dependencies: - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - polyclip-ts: 0.16.8 - tslib: 2.8.1 - - '@turf/unkink-polygon@7.3.5': - dependencies: - '@turf/area': 7.3.5 - '@turf/boolean-point-in-polygon': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/meta': 7.3.5 - '@types/geojson': 7946.0.16 - rbush: 3.0.1 - tslib: 2.8.1 - - '@turf/voronoi@7.3.5': - dependencies: - '@turf/clone': 7.3.5 - '@turf/helpers': 7.3.5 - '@turf/invariant': 7.3.5 - '@types/d3-voronoi': 1.1.12 - '@types/geojson': 7946.0.16 - d3-voronoi: 1.1.2 - tslib: 2.8.1 - - '@types/caseless@0.12.5': {} - - '@types/cookie@0.6.0': {} - - '@types/d3-voronoi@1.1.12': {} - - '@types/estree@1.0.8': {} - - '@types/fs-extra@11.0.1': - dependencies: - '@types/jsonfile': 6.1.4 - '@types/node': 25.9.1 - - '@types/geojson@7946.0.13': {} - - '@types/geojson@7946.0.16': {} - - '@types/jsonfile@6.1.4': - dependencies: - '@types/node': 25.9.1 - - '@types/kdbush@3.0.5': {} - - '@types/lodash-es@4.17.12': - dependencies: - '@types/lodash': 4.17.24 - - '@types/lodash@4.17.24': {} - - '@types/mdast@4.0.4': - dependencies: - '@types/unist': 2.0.11 - - '@types/node@25.9.1': - dependencies: - undici-types: 7.24.6 - - '@types/ramda@0.29.3': - dependencies: - types-ramda: 0.29.10 - - '@types/request@2.48.13': - dependencies: - '@types/caseless': 0.12.5 - '@types/node': 25.9.1 - '@types/tough-cookie': 4.0.5 - form-data: 2.5.6 - - '@types/tough-cookie@4.0.5': {} + '@types/tough-cookie@4.0.5': {} '@types/trusted-types@2.0.7': {} @@ -6084,8 +4548,6 @@ snapshots: anynum@1.0.1: {} - arc@0.2.0: {} - aria-query@5.3.1: {} array-buffer-byte-length@1.0.2: @@ -6218,17 +4680,8 @@ snapshots: commander@14.0.3: {} - commander@2.20.3: {} - concat-map@0.0.1: {} - concaveman@1.2.1: - dependencies: - point-in-polygon: 1.1.0 - rbush: 3.0.1 - robust-predicates: 2.0.4 - tinyqueue: 2.0.3 - consola@3.4.2: {} content-type@3.1.0: {} @@ -6243,8 +4696,6 @@ snapshots: shebang-command: 1.2.0 which: 1.3.1 - d3-array@1.2.4: {} - d3-array@3.2.4: dependencies: internmap: 2.0.3 @@ -6253,10 +4704,6 @@ snapshots: d3-format@3.1.2: {} - d3-geo@1.7.1: - dependencies: - d3-array: 1.2.4 - d3-interpolate@3.0.1: dependencies: d3-color: 3.1.0 @@ -6277,8 +4724,6 @@ snapshots: dependencies: d3-array: 3.2.4 - d3-voronoi@1.1.2: {} - data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -6346,8 +4791,6 @@ snapshots: readable-stream: 3.6.2 stream-shift: 1.0.3 - earcut@2.2.4: {} - earcut@3.2.3: {} ecdsa-sig-formatter@1.0.11: @@ -6498,8 +4941,6 @@ snapshots: extend@3.0.2: {} - fast-deep-equal@3.1.3: {} - fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -6601,14 +5042,6 @@ snapshots: generator-function@2.0.1: {} - geojson-equality-ts@1.0.2: - dependencies: - '@types/geojson': 7946.0.16 - - geojson-polygon-self-intersections@1.2.2: - dependencies: - rbush: 2.0.2 - geotiff@2.1.3: dependencies: '@petamoriken/float16': 3.9.3 @@ -6902,8 +5335,6 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsts@2.7.1: {} - jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -7207,13 +5638,6 @@ snapshots: dependencies: robust-predicates: 3.0.3 - point-in-polygon@1.1.0: {} - - polyclip-ts@0.16.8: - dependencies: - bignumber.js: 9.3.1 - splaytree-ts: 1.0.2 - possible-typed-array-names@1.1.0: {} postcss@8.5.6: @@ -7241,22 +5665,10 @@ snapshots: quick-lru@6.1.2: {} - quickselect@1.1.1: {} - - quickselect@2.0.0: {} - quickselect@3.0.0: {} ramda@0.29.0: {} - rbush@2.0.2: - dependencies: - quickselect: 1.1.1 - - rbush@3.0.1: - dependencies: - quickselect: 2.0.0 - rbush@4.0.1: dependencies: quickselect: 3.0.0 @@ -7334,8 +5746,6 @@ snapshots: rgb-hex@4.1.0: {} - robust-predicates@2.0.4: {} - robust-predicates@3.0.3: {} rolldown-plugin-dts@0.28.6(rolldown@1.2.12)(typescript@5.9.3): @@ -7542,8 +5952,6 @@ snapshots: sisteransi@1.0.5: {} - skmeans@0.9.7: {} - source-map-js@1.2.1: {} spdx-correct@3.2.0: @@ -7560,8 +5968,6 @@ snapshots: spdx-license-ids@3.0.23: {} - splaytree-ts@1.0.2: {} - stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -7665,10 +6071,6 @@ snapshots: svg-parser@2.0.4: {} - sweepline-intersections@1.5.0: - dependencies: - tinyqueue: 2.0.3 - tailwindcss@4.2.0: {} tapable@2.3.0: {} @@ -7704,18 +6106,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 - tinyqueue@2.0.3: {} - tinyqueue@3.0.0: {} - topojson-client@3.1.0: - dependencies: - commander: 2.20.3 - - topojson-server@3.0.1: - dependencies: - commander: 2.20.3 - totalist@3.0.1: {} tr46@0.0.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7818476..a0d0c98 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,5 @@ packages: - apps/* - - content/* - - '!content/tests' - packages/* allowBuilds: diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index 7ac5230..23eb897 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -1,24 +1,29 @@ import assert from 'node:assert/strict'; import { readFile, readdir } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import path from 'node:path'; import { captureBuildInfo } from '../packages/slides/src/build-info.ts'; -const root = fileURLToPath(new URL('../', import.meta.url)); -export const info = await captureBuildInfo(path.join(root, 'packages/slides'), false); -assert.equal(info.sourceState, 'clean', 'Commit all software changes before publishing; content changes are excluded.'); -assert.ok(info.sourceUrl, 'Publishing requires a Git revision and a public GitHub repository in package.json.'); -const pre = await readFile(path.join(root, '.changeset/pre.json'), 'utf8').then(JSON.parse).catch(error => { - if (error.code === 'ENOENT') return undefined; - throw error; -}); -const beta = /^\d+\.\d+\.\d+-beta\.\d+$/.test(info.version); -assert.ok(beta || /^\d+\.\d+\.\d+$/.test(info.version), 'Use a regular version or a beta prerelease.'); -if (beta) assert.ok(pre?.mode === 'pre' && pre.tag === 'beta', 'Beta publication requires Changesets beta mode.'); -else assert.equal(pre, undefined, 'Finish Changesets prerelease exit and versioning before publishing a regular release.'); -const pending = (await readdir(path.join(root, '.changeset'))).filter(name => name.endsWith('.md') && name !== 'README.md'); -assert.deepEqual(pending, [], 'Run pnpm release:version and commit the result before publishing pending changes.'); -const changelog = await readFile(path.join(root, 'packages/slides/CHANGELOG.md'), 'utf8'); -assert.ok(changelog.includes(`## ${info.version}\n`), 'The current version needs a changelog entry.'); -export const npmTag = beta ? 'beta' : 'latest'; -console.log(`${info.name}@${info.version}\nSource: ${info.sourceUrl}\nnpm tag: ${npmTag}`); +export async function checkRelease(root = fileURLToPath(new URL('../', import.meta.url))) { + const info = await captureBuildInfo(path.join(root, 'packages/slides'), false); + assert.equal(info.sourceState, 'clean', 'Commit all software changes before publishing; content changes are excluded.'); + assert.ok(info.sourceUrl, 'Publishing requires a Git revision and a public GitHub repository in package.json.'); + const pre = await readFile(path.join(root, '.changeset/pre.json'), 'utf8').then(JSON.parse).catch(error => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + const beta = /^\d+\.\d+\.\d+-beta\.\d+$/.test(info.version); + assert.ok(beta || /^\d+\.\d+\.\d+$/.test(info.version), 'Use a regular version or a beta prerelease.'); + if (beta) assert.ok(pre?.mode === 'pre' && pre.tag === 'beta', 'Beta publication requires Changesets beta mode.'); + else assert.equal(pre, undefined, 'Finish Changesets prerelease exit and versioning before publishing a regular release.'); + const pending = (await readdir(path.join(root, '.changeset'))).filter(name => name.endsWith('.md') && name !== 'README.md'); + assert.deepEqual(pending, [], 'Run pnpm release:version and commit the result before publishing pending changes.'); + const changelog = await readFile(path.join(root, 'packages/slides/CHANGELOG.md'), 'utf8'); + assert.ok(changelog.includes(`## ${info.version}\n`), 'The current version needs a changelog entry.'); + return { info, npmTag: beta ? 'beta' : 'latest' }; +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + const { info, npmTag } = await checkRelease(); + console.log(`${info.name}@${info.version}\nSource: ${info.sourceUrl}\nnpm tag: ${npmTag}`); +} diff --git a/scripts/prepare-npm-release.mjs b/scripts/prepare-npm-release.mjs new file mode 100644 index 0000000..f84ad53 --- /dev/null +++ b/scripts/prepare-npm-release.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { appendFile, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { checkRelease } from './check-release.mjs'; + +// The build stamp works for both local and CI publications; tarball publications +// do not necessarily have npm's optional gitHead metadata. +export async function publishedBuildInfo(name, version, fetchFn = fetch) { + const metadata = await fetchFn(`https://registry.npmjs.org/${encodeURIComponent(name)}/${version}`, { + signal: AbortSignal.timeout(30_000), + }); + if (metadata.status === 404) return undefined; + if (!metadata.ok) throw new Error(`npm registry returned HTTP ${metadata.status}`); + const pkg = await metadata.json(); + assert.equal(pkg.name, name); + assert.equal(pkg.version, version); + const url = new URL(pkg.dist.tarball); + assert.equal(url.origin, 'https://registry.npmjs.org'); + const response = await fetchFn(url, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error(`npm tarball returned HTTP ${response.status}`); + const bytes = Buffer.from(await response.arrayBuffer()); + assert.equal(pkg.dist.integrity, `sha512-${createHash('sha512').update(bytes).digest('base64')}`, 'Published tarball integrity mismatch'); + return JSON.parse(execFileSync('tar', ['-xzOf', '-', 'package/build-info.json'], { + input: bytes, encoding: 'utf8', maxBuffer: 1024 * 1024, + })); +} + +export async function prepareNpmRelease({ + root = fileURLToPath(new URL('../', import.meta.url)), + env = process.env, + fetchFn = fetch, +} = {}) { + assert.equal(env.GITHUB_REF, 'refs/heads/main', 'npm publication must run on main'); + const git = (...args) => execFileSync('git', ['-C', root, ...args], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + const pkg = JSON.parse(await readFile(path.join(root, 'packages/slides/package.json'), 'utf8')); + assert.equal(pkg.name, '@allmaps/slides'); + if (env.GITHUB_EVENT_NAME === 'push') { + assert.match(env.RELEASE_BEFORE ?? '', /^[a-f0-9]{40,64}$/); + const previous = JSON.parse(git('show', `${env.RELEASE_BEFORE}:packages/slides/package.json`)); + if (previous.version === pkg.version) return { release: false }; + } else { + assert.equal(env.GITHUB_EVENT_NAME, 'workflow_dispatch'); + } + const { info, npmTag } = await checkRelease(root); + assert.equal(info.revision, env.GITHUB_SHA, 'Checkout must match the workflow commit'); + const tag = `${info.name}@${info.version}`; + let taggedRevision; + try { + taggedRevision = git('rev-parse', '--verify', `refs/tags/${tag}^{commit}`); + } catch (error) { + if (error.status !== 128) throw error; + } + if (taggedRevision) assert.equal(taggedRevision, info.revision, 'Existing release tag points to a different commit'); + const published = await publishedBuildInfo(info.name, info.version, fetchFn); + if (published) { + for (const key of ['schemaVersion', 'name', 'version', 'repositoryUrl', 'revision', 'sourceState', 'development']) { + assert.equal(published[key], info[key], `Published package has different ${key}; rerun the original release commit`); + } + } + return { release: true, published: Boolean(published), tag, version: info.version, npmTag }; +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + const result = await prepareNpmRelease(); + if (process.env.GITHUB_OUTPUT) { + await appendFile(process.env.GITHUB_OUTPUT, Object.entries(result).map(([key, value]) => `${key}=${value}\n`).join('')); + } + const message = !result.release ? 'Package version unchanged; no release needed.' + : result.published ? `${result.tag} is already on npm from this commit; finishing the GitHub release.` + : `Publishing ${result.tag} to npm's ${result.npmTag} channel.`; + console.log(message); + if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, `${message}\n`); +} diff --git a/scripts/publish-slides.mjs b/scripts/publish-slides.mjs index e0e3c86..8c86d55 100644 --- a/scripts/publish-slides.mjs +++ b/scripts/publish-slides.mjs @@ -1,10 +1,11 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { info, npmTag } from './check-release.mjs'; +import { checkRelease } from './check-release.mjs'; const cwd = fileURLToPath(new URL('../', import.meta.url)); +const { info, npmTag } = await checkRelease(cwd); const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; // Changesets can prefer `latest` before a package has any stable releases. -// Specify the channel ourselves; the imported check verifies the software tree. +// Specify the channel ourselves; checkRelease verifies the software tree. execFileSync(pnpm, ['--filter', info.name, 'publish', '--access', 'public', '--tag', npmTag, '--no-git-checks'], { cwd, stdio: 'inherit' }); execFileSync(pnpm, ['exec', 'changeset', 'git-tag'], { cwd, stdio: 'inherit' });