diff --git a/.changeset/combined-layer-feature-source.md b/.changeset/combined-layer-feature-source.md new file mode 100644 index 00000000..8554b82f --- /dev/null +++ b/.changeset/combined-layer-feature-source.md @@ -0,0 +1,6 @@ +--- +"@mapsight/core": minor +"@mapsight/ui": minor +--- + +Add combined feature sources that merge features from multiple member sources, and a `combinedVisibleLayers` plugin that keeps a combined source in sync with whichever member map layers are currently visible. diff --git a/.changeset/composable-runtime-icons.md b/.changeset/composable-runtime-icons.md new file mode 100644 index 00000000..fcd8f9c7 --- /dev/null +++ b/.changeset/composable-runtime-icons.md @@ -0,0 +1,8 @@ +--- +"@mapsight/traffic-style": minor +"@mapsight/lib-ol": minor +"@mapsight/vector-style-compiler": minor +"@mapsight/ui": minor +--- + +Add composable runtime icons: pictogram-based templates and `mapsightIconId` parsing in traffic-style (with configurable default xsmall/small icon zoom levels), async rasterization with volatile style-cache invalidation in lib-ol and vector-style-compiler, plus `useMapsightIcon` and a runtime icon style plugin in ui. diff --git a/.changeset/config-schema-validation.md b/.changeset/config-schema-validation.md new file mode 100644 index 00000000..78318d83 --- /dev/null +++ b/.changeset/config-schema-validation.md @@ -0,0 +1,6 @@ +--- +"@mapsight/core": minor +"@mapsight/ui": minor +--- + +Add Zod-based Mapsight config validation: core exports `createMapsightConfigSchema`, `validateConfig`, and per-domain schemas for map, layers, feature sources, and filters; ui validates config on startup (warn in development, optional strict mode in production). diff --git a/.changeset/embed-exports.md b/.changeset/embed-exports.md new file mode 100644 index 00000000..cde6cef9 --- /dev/null +++ b/.changeset/embed-exports.md @@ -0,0 +1,5 @@ +--- +"@mapsight/ui": patch +--- + +Add `@mapsight/ui/embed/*` package exports for embed entry points. diff --git a/.changeset/empty-years-sleep.md b/.changeset/empty-years-sleep.md new file mode 100644 index 00000000..c21777a0 --- /dev/null +++ b/.changeset/empty-years-sleep.md @@ -0,0 +1,8 @@ +--- +"@mapsight/vector-editor": minor +"@mapsight/lib-redux": minor +"@mapsight/core": minor +"@mapsight/ui": minor +--- + +Migrate to Redux Toolkit 2, Redux 5, react-redux 9, and reselect 5; replace `createStructuredSelector` usage, prefer `@reduxjs/toolkit` exports, and update the vector-editor browser renderer to React 18 `createRoot`. diff --git a/.changeset/feature-interaction-fixes.md b/.changeset/feature-interaction-fixes.md new file mode 100644 index 00000000..daac29fe --- /dev/null +++ b/.changeset/feature-interaction-fixes.md @@ -0,0 +1,5 @@ +--- +"@mapsight/core": patch +--- + +Fix feature highlight race conditions, export `FeatureInteractionNames`, add optional `compare` to `BaseController.getAndObserveUncontrolled`, and mark noisy OpenLayers feedback dispatches with `quiet()`. diff --git a/.changeset/shallow-equal-records.md b/.changeset/shallow-equal-records.md new file mode 100644 index 00000000..b5ed1ac5 --- /dev/null +++ b/.changeset/shallow-equal-records.md @@ -0,0 +1,5 @@ +--- +"@mapsight/lib-js": patch +--- + +Add `shallowEqualRecords` utility for shallow comparison of string-keyed records. diff --git a/.changeset/vsc-compiler-fixes.md b/.changeset/vsc-compiler-fixes.md new file mode 100644 index 00000000..cc011b06 --- /dev/null +++ b/.changeset/vsc-compiler-fixes.md @@ -0,0 +1,5 @@ +--- +"@mapsight/vector-style-compiler": patch +--- + +Fix nested function codegen and style-tree state assignment in compiled output. diff --git a/.gitignore b/.gitignore index b468d7f2..91fbac8e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules/ /apps/*/tmp/ /packages/*/tmp/ .turbo/ +tsconfig.node.tsbuildinfo # built files that get distributed /apps/*/dist/ @@ -36,6 +37,11 @@ node_modules/ # local files *.local +# Test runners (Playwright, etc.) +test-results/ +playwright-report/ +blob-report/ + # Logs logs *.log diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 00000000..715e9d45 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,44 @@ +#!/usr/bin/env sh + +remote="$1" + +# Block pushing private/* branches to any remote except "private". +if [ "$remote" != "private" ]; then + blocked="" + + while read -r local_ref local_sha remote_ref remote_sha; do + [ -z "$local_ref" ] && continue + + # Allow deleting private branches from non-private remotes (cleanup after accidents). + if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then + continue + fi + + local_branch="${local_ref#refs/heads/}" + remote_branch="${remote_ref#refs/heads/}" + + case "$local_branch" in + private/*) + blocked="${blocked} + - ${local_branch}" + ;; + esac + + case "$remote_branch" in + private/*) + if ! printf '%s' "$blocked" | grep -Fq " - ${remote_branch}"; then + blocked="${blocked} + - ${remote_branch}" + fi + ;; + esac + done + + if [ -n "$blocked" ]; then + echo "error: refusing to push private branches to remote '$remote'." + echo "Use the private remote instead, for example:" + echo " git push private " + printf 'Blocked branch(es):%s\n' "$blocked" + exit 1 + fi +fi diff --git a/README.md b/README.md index b8add9e9..5a5cc01d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Mapsight is a framework for building web applications with OpenLayers and React. | Application | Description | | :-------------------------------------------------------- | :-------------------------------------------------------------------------- | | 🧑‍🎨 [**`vector-editor`**](apps/vector-editor) | Vector editor for creating and editing vector features exported as GeoJSON. | -| 💡 [**`demo-vite`**](apps/demo-vite) | Simple demo app of Mapsight UI built with Vite. | +| 💡 [**`showcase`**](apps/showcase) | Mapsight ecosystem showcase — UI demo, icon catalog, and runtime icons. | | 💡 [**`demo-next`**](apps/demo-next) | Simple demo app of Mapsight UI built with Next.js. | ## Development diff --git a/apps/demo-next/package.json b/apps/demo-next/package.json index 6c16ff24..5cc96b54 100644 --- a/apps/demo-next/package.json +++ b/apps/demo-next/package.json @@ -24,7 +24,7 @@ "babel-plugin-react-compiler": "1.0.0", "eslint-config-next": "16.2.6", "postcss": "^8.5.14", - "sass": "^1.99.0", + "sass": "^1.100.0", "tailwindcss": "catalog:" }, "license": "UNLICENSED", diff --git a/apps/demo-vite/src/custom.html b/apps/demo-vite/src/custom.html deleted file mode 100644 index d21fb4c4..00000000 --- a/apps/demo-vite/src/custom.html +++ /dev/null @@ -1,12 +0,0 @@ - -Mapsight UI demo - - - -← Index - -

Mapsight UI Full demo

- -
- - diff --git a/apps/demo-vite/src/full/client.tsx b/apps/demo-vite/src/full/client.tsx deleted file mode 100644 index 7194d9e7..00000000 --- a/apps/demo-vite/src/full/client.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import {createRoot} from "react-dom/client"; - -import App from "@mapsight/ui/components/app"; -import Instance from "@mapsight/ui/components/instance"; -import createDefaultPlugins from "@mapsight/ui/plugins/browser-defaults"; - -import {baseMapsightConfig, createOptions, styleFunction} from "./shared"; - -createOptions.plugins = [ - ...createDefaultPlugins(), - ...(createOptions.plugins || []), -]; - -const root = createRoot(document.querySelector("#root")!); -root.render( - - - , -); diff --git a/apps/demo-vite/src/full/index.html b/apps/demo-vite/src/full/index.html deleted file mode 100644 index 71353171..00000000 --- a/apps/demo-vite/src/full/index.html +++ /dev/null @@ -1,12 +0,0 @@ - -Mapsight UI demo - - - -← Index - -

Mapsight UI Full demo

- -
- - diff --git a/apps/demo-vite/src/global.css b/apps/demo-vite/src/global.css deleted file mode 100644 index f1d8c73c..00000000 --- a/apps/demo-vite/src/global.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss"; diff --git a/apps/demo-vite/src/index.html b/apps/demo-vite/src/index.html deleted file mode 100644 index 01fb55a4..00000000 --- a/apps/demo-vite/src/index.html +++ /dev/null @@ -1,13 +0,0 @@ - -Mapsight UI demo - - - -

Mapsight UI Index

- - diff --git a/apps/demo-vite/src/router/components/RoutedApp.tsx b/apps/demo-vite/src/router/components/RoutedApp.tsx deleted file mode 100644 index 88b13e73..00000000 --- a/apps/demo-vite/src/router/components/RoutedApp.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import {useLocation} from "react-router"; - -import App from "@mapsight/ui/components/app"; - -interface NavPosition { - error404: boolean; - reduced: boolean; - area: string; - content: object; -} - -function calcNavPosition(_pathName: string): NavPosition { - // TODO actually calc something - - return { - error404: false, - reduced: true, - area: "", - content: {}, - }; -} - -function RoutedApp() { - const location = useLocation(); - const navPosition = calcNavPosition(location.pathname); - - console.log("navPosition", navPosition); - - return ; -} - -export default RoutedApp; diff --git a/apps/demo-vite/src/router/index.html b/apps/demo-vite/src/router/index.html deleted file mode 100644 index 18562b74..00000000 --- a/apps/demo-vite/src/router/index.html +++ /dev/null @@ -1,12 +0,0 @@ - -Mapsight UI demo - - - -← Index - -

Mapsight UI with Router demo

- -
- - diff --git a/apps/demo-vite/src/simple-map.html b/apps/demo-vite/src/simple-map.html deleted file mode 100644 index 82d5ed9e..00000000 --- a/apps/demo-vite/src/simple-map.html +++ /dev/null @@ -1,18 +0,0 @@ - -Mapsight UI demo - - - - -← Index - -

Mapsight UI Simple map

- -
- - diff --git a/apps/demo-vite/src/simple-map.tsx b/apps/demo-vite/src/simple-map.tsx deleted file mode 100644 index 73a9c88b..00000000 --- a/apps/demo-vite/src/simple-map.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import {createRoot} from "react-dom/client"; - -import Instance from "@mapsight/ui/components/instance"; -import Map from "@mapsight/ui/components/map"; -import MapWrapper from "@mapsight/ui/components/map-wrapper"; -import {map, mapView, mapViewCenter, mapViewExtent} from "@mapsight/ui/config"; -import {metaData, osm} from "@mapsight/ui/config/map/layers"; -import createDefaultPlugins from "@mapsight/ui/plugins/browser-defaults"; - -const bmc = { - ...map( - { - osm: osm( - "https://tile.openstreetmap.org/{z}/{x}/{y}.png", - true, - metaData( - "OSM", - '© OpenStreetMap-Mitwirkende-Mitwirkende', - true, - false, - true, - "Karte", - ), - ), - }, - mapView( - mapViewCenter(1171479, 6848253), // x, y ^= 52.2653825, 10.523575 (lat, lon) - mapViewExtent(1097392, 6789091, 1240635, 6895797), // minx, miny, maxx, maxy http://bboxfinder.com/#51.938934,9.858041,52.526000,11.144814 - 14, // z - 10, // minZoom - 18, // maxZoom - ), - ), -}; - -const createOptions = { - plugins: createDefaultPlugins(), - uiState: { - map: { - show: true, - }, - }, -}; - -const noopStyleFunction = () => []; - -const root = createRoot(document.querySelector("#root")!); -root.render( - - - - - , -); diff --git a/apps/demo-vite/.gitignore b/apps/showcase/.gitignore similarity index 100% rename from apps/demo-vite/.gitignore rename to apps/showcase/.gitignore diff --git a/apps/demo-vite/eslint.config.mts b/apps/showcase/eslint.config.mts similarity index 91% rename from apps/demo-vite/eslint.config.mts rename to apps/showcase/eslint.config.mts index 150b5ff0..a8506303 100644 --- a/apps/demo-vite/eslint.config.mts +++ b/apps/showcase/eslint.config.mts @@ -5,7 +5,7 @@ import baseConfig from "../../configs/eslint-config-base-app.mts"; export default defineConfig([ baseConfig, { - ignores: ["src/generated/**/*"], + ignores: ["src/generated/**/*", "vite.config.ts"], }, { name: "todos", diff --git a/apps/demo-vite/package.json b/apps/showcase/package.json similarity index 61% rename from apps/demo-vite/package.json rename to apps/showcase/package.json index caa25890..4d9ad11d 100644 --- a/apps/demo-vite/package.json +++ b/apps/showcase/package.json @@ -1,8 +1,9 @@ { - "name": "@mapsight/demo-vite", - "description": "Mapsight Demo Vite", - "version": "0.0.6", + "name": "@mapsight/showcase", + "description": "Mapsight ecosystem showcase — UI demo, icon catalog, and package overview", + "version": "0.1.0", "private": true, + "type": "module", "dependencies": { "@mapsight/core": "workspace:^", "@mapsight/lib-js": "workspace:^", @@ -16,7 +17,6 @@ "react": "catalog:", "react-dom": "catalog:", "react-redux": "catalog:", - "react-router": "^7.15.0", "react-router-dom": "^7.15.0", "reselect": "catalog:", "tailwindcss": "catalog:" @@ -27,8 +27,12 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "mkdirp": "^3.0.1", + "npm-run-all": "^4.1.5", + "rimraf": "^6.1.3", "terser": "^5.47.1", - "vite": "^8.0.13" + "typescript": "catalog:", + "vite": "^8.0.14" }, "license": "UNLICENSED", "repository": { @@ -37,7 +41,9 @@ "scripts": { "build": "npm-run-all --parallel build:mapsightStyle copy --sequential build:app", "build:app": "vite build --mode production --minify terser", - "build:mapsightStyle": "vector-style-compiler src/vector-styles/demo.scss --output src/generated/mapsight-vector-styles --name demo", + "build:mapsightStyle": "npm-run-all build:mapsightStyle:*", + "build:mapsightStyle:demo": "vector-style-compiler src/vector-styles/demo.scss --output src/generated/mapsight-vector-styles --name demo", + "build:mapsightStyle:icons": "vector-style-compiler src/vector-styles/icon-demo.scss --output src/generated/mapsight-vector-styles --name icon-demo", "clean": "rimraf dist node_modules/.vite src/generated public/img/mapsight*", "clean-build": "run-s clean build", "copy": "run-p copy:*", @@ -45,8 +51,10 @@ "copy:ui": "mkdirp public/img && cp -R node_modules/@mapsight/ui/dist/img/* public/img/", "dev": "npm-run-all --parallel build:mapsightStyle copy --sequential dev:app", "dev:app": "vite", + "dev:linked": "npm-run-all --parallel dev:packages dev", + "dev:packages": "turbo watch watch --filter=@mapsight/core --filter=@mapsight/ui --filter=@mapsight/lib-js --filter=@mapsight/lib-ol --filter=@mapsight/lib-redux", "lint": "eslint", "start": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json" } } diff --git a/apps/demo-vite/public/img/.keep b/apps/showcase/public/img/.keep similarity index 100% rename from apps/demo-vite/public/img/.keep rename to apps/showcase/public/img/.keep diff --git a/apps/showcase/src/app.tsx b/apps/showcase/src/app.tsx new file mode 100644 index 00000000..6b0168cc --- /dev/null +++ b/apps/showcase/src/app.tsx @@ -0,0 +1,38 @@ +import {BrowserRouter, Navigate, Route, Routes} from "react-router-dom"; + +import {CatalogPage} from "./icons/catalog-page.tsx"; +import {EditorPage} from "./icons/editor-page.tsx"; +import {ShowcaseLayout} from "./layout/showcase-layout.tsx"; +import {LandingPage} from "./pages/landing-page.tsx"; +import {CombinedListPage} from "./pages/ui-demos/combined-list-page.tsx"; +import {CustomPage} from "./pages/ui-demos/custom-page.tsx"; +import {FullPage} from "./pages/ui-demos/full-page.tsx"; +import {RouterPage} from "./pages/ui-demos/router-page.tsx"; +import {SimpleMapPage} from "./pages/ui-demos/simple-map-page.tsx"; + +export function App() { + return ( + + + }> + } /> + } + /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + ); +} diff --git a/apps/showcase/src/combined-list/create-combined-list-demo.ts b/apps/showcase/src/combined-list/create-combined-list-demo.ts new file mode 100644 index 00000000..74c1e8d0 --- /dev/null +++ b/apps/showcase/src/combined-list/create-combined-list-demo.ts @@ -0,0 +1,152 @@ +import * as config from "@mapsight/ui/config"; +import { + TAG_FILTER, + combinedFeatureSource, + withFilter, + xhrJson, +} from "@mapsight/ui/config/feature/sources"; +import { + interactiveFeatures, + metaData, + osm, +} from "@mapsight/ui/config/map/layers"; +import createCombinedVisibleLayersPlugin from "@mapsight/ui/plugins/common/combined-visible-layers"; +import type {CreateOptions} from "@mapsight/ui/types"; + +/** Feature source and layer id for the parks demo layer. */ +export const PARKS_LAYER_ID = "parks"; + +/** Feature source and layer id for the cafes demo layer. */ +export const CAFES_LAYER_ID = "cafes"; + +/** Combined list feature source id (aggregates visible member layers). */ +export const LIST_COMBINED_FEATURE_SOURCE_ID = "listCombined"; + +/** Member layers that feed the combined list when visible. */ +export const LIST_MEMBER_LAYER_IDS = [PARKS_LAYER_ID, CAFES_LAYER_ID] as const; + +export type CombinedListDemoUrls = { + parks: string; + cafes: string; +}; + +const DEMO_MAP_CENTER = config.mapViewCenter(1171479, 6848253); +const DEMO_MAP_EXTENT = config.mapViewExtent( + 1097392, + 6789091, + 1240635, + 6895797, +); + +/** + * Demo config: two feature layers with a list backed by a combined feature + * source. The combinedVisibleLayers plugin keeps the list in sync with which + * member layers are currently visible in the layer switcher. + */ +export function createCombinedListDemo(urls: CombinedListDemoUrls): { + baseMapsightConfig: object; + createOptions: CreateOptions; +} { + const baseMapsightConfig = { + ...config.map( + { + osm: osm( + "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + true, + metaData( + "OSM", + '© OpenStreetMap contributors', + true, + false, + true, + "Base map", + ), + ), + [PARKS_LAYER_ID]: interactiveFeatures( + PARKS_LAYER_ID, + true, + metaData( + "Parks", + null, + true, + true, + false, + "Points of interest", + ), + { + style: "features", + mapsightIconId: "sportanlage", + }, + ), + [CAFES_LAYER_ID]: interactiveFeatures( + CAFES_LAYER_ID, + true, + metaData( + "Cafes", + null, + true, + true, + false, + "Points of interest", + ), + { + style: "features", + mapsightIconId: "marker", + }, + ), + }, + config.mapView(DEMO_MAP_CENTER, DEMO_MAP_EXTENT, 14, 10, 18), + ), + ...config.features({ + [PARKS_LAYER_ID]: xhrJson(urls.parks), + [CAFES_LAYER_ID]: xhrJson(urls.cafes), + [LIST_COMBINED_FEATURE_SOURCE_ID]: withFilter( + combinedFeatureSource(), + TAG_FILTER, + ), + }), + ...config.featureList(LIST_COMBINED_FEATURE_SOURCE_ID, true), + }; + + const createOptions: CreateOptions = { + plugins: [ + [ + "combinedVisibleLayers", + createCombinedVisibleLayersPlugin({ + combinedFeatureSourceId: LIST_COMBINED_FEATURE_SOURCE_ID, + members: [...LIST_MEMBER_LAYER_IDS], + }), + ], + ], + uiState: { + map: { + show: true, + }, + list: { + show: true, + selectOnClick: "mainAndIcon", + selectionBehavior: { + desktop: "scrollToMap", + mobile: "showInMapOnlyView", + }, + filterControl: true, + sortControl: true, + }, + layerSwitcher: { + show: { + internal: true, + external: true, + }, + internal: { + grouped: true, + }, + external: { + grouped: true, + setFeatureSourceId: true, + }, + }, + }, + }; + + return {baseMapsightConfig, createOptions}; +} diff --git a/apps/showcase/src/combined-list/shared.tsx b/apps/showcase/src/combined-list/shared.tsx new file mode 100644 index 00000000..74694483 --- /dev/null +++ b/apps/showcase/src/combined-list/shared.tsx @@ -0,0 +1,11 @@ +import {createCombinedListDemo} from "./create-combined-list-demo.ts"; + +export {default as styleFunction} from "../generated/mapsight-vector-styles/demo"; + +const parksUrl = new URL("../data/parks.geojson", import.meta.url).toString(); +const cafesUrl = new URL("../data/cafes.geojson", import.meta.url).toString(); + +export const {baseMapsightConfig, createOptions} = createCombinedListDemo({ + parks: parksUrl, + cafes: cafesUrl, +}); diff --git a/apps/showcase/src/data/cafes.geojson b/apps/showcase/src/data/cafes.geojson new file mode 100644 index 00000000..0be69d0b --- /dev/null +++ b/apps/showcase/src/data/cafes.geojson @@ -0,0 +1,47 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "demo-cafe-corner", + "geometry": { + "type": "Point", + "coordinates": [10.5214, 52.2661] + }, + "properties": { + "id": "demo-cafe-corner", + "name": "Corner Cafe", + "listInformation": "Coffee and pastries", + "description": "

Cozy neighborhood cafe with outdoor seating.

" + } + }, + { + "type": "Feature", + "id": "demo-cafe-market", + "geometry": { + "type": "Point", + "coordinates": [10.5278, 52.2635] + }, + "properties": { + "id": "demo-cafe-market", + "name": "Market Square Bistro", + "listInformation": "Lunch menu and espresso bar", + "description": "

Busy spot next to the weekly market.

" + } + }, + { + "type": "Feature", + "id": "demo-cafe-garden", + "geometry": { + "type": "Point", + "coordinates": [10.5159, 52.2697] + }, + "properties": { + "id": "demo-cafe-garden", + "name": "Garden Terrace", + "listInformation": "Brunch and herbal tea", + "description": "

Relaxed cafe behind a courtyard garden.

" + } + } + ] +} diff --git a/apps/demo-vite/src/data.geojson b/apps/showcase/src/data/demo.geojson similarity index 100% rename from apps/demo-vite/src/data.geojson rename to apps/showcase/src/data/demo.geojson diff --git a/apps/showcase/src/data/parks.geojson b/apps/showcase/src/data/parks.geojson new file mode 100644 index 00000000..93a99dd0 --- /dev/null +++ b/apps/showcase/src/data/parks.geojson @@ -0,0 +1,47 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "demo-park-riverside", + "geometry": { + "type": "Point", + "coordinates": [10.5182, 52.2684] + }, + "properties": { + "id": "demo-park-riverside", + "name": "Riverside Park", + "listInformation": "Green space along the river", + "description": "

Quiet park with walking paths and picnic areas.

" + } + }, + { + "type": "Feature", + "id": "demo-park-central", + "geometry": { + "type": "Point", + "coordinates": [10.5251, 52.2648] + }, + "properties": { + "id": "demo-park-central", + "name": "Central Gardens", + "listInformation": "Formal gardens in the city center", + "description": "

Shaded benches and seasonal flower beds.

" + } + }, + { + "type": "Feature", + "id": "demo-park-hilltop", + "geometry": { + "type": "Point", + "coordinates": [10.5316, 52.2612] + }, + "properties": { + "id": "demo-park-hilltop", + "name": "Hilltop Lookout", + "listInformation": "Small park with a viewpoint", + "description": "

Panoramic views over the old town.

" + } + } + ] +} diff --git a/apps/demo-vite/src/features/native-fullscreen/plugin.ts b/apps/showcase/src/features/native-fullscreen/plugin.ts similarity index 100% rename from apps/demo-vite/src/features/native-fullscreen/plugin.ts rename to apps/showcase/src/features/native-fullscreen/plugin.ts diff --git a/apps/showcase/src/global.css b/apps/showcase/src/global.css new file mode 100644 index 00000000..d049b7d3 --- /dev/null +++ b/apps/showcase/src/global.css @@ -0,0 +1,264 @@ +@import "tailwindcss"; + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + background: #fff; + color: #15202b; + font-family: + Inter, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + line-height: 1.5; +} + +.showcase { + display: flex; + flex-direction: column; + height: 100%; + background: #fff; +} + +.showcase__header { + border-bottom: 1px solid #d8dee6; + background: #fff; +} + +.showcase__header-inner, +.showcase__subnav-inner { + padding-left: 1.25rem; + padding-right: 1.25rem; +} + +.showcase__header-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-top: 0.65rem; + padding-bottom: 0.65rem; +} + +.showcase__brand { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.showcase__tagline { + font-size: 0.85rem; + color: #64748b; +} + +.showcase__nav ul { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +.showcase__nav-link { + display: inline-block; + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.9rem; + font-weight: 600; + color: #334155; + text-decoration: none; +} + +.showcase__nav-link:hover { + background: #f1f5f9; +} + +.showcase__nav-link.is-active { + background: #dbeafe; + color: #1d4ed8; +} + +.showcase__main { + flex: 1; + min-height: 0; + background: #fff; +} + +.showcase__main--embed { + overflow: auto; +} + +.showcase__main--icon-editor { + overflow: hidden; +} + +.showcase__subnav { + border-bottom: 1px solid #d8dee6; + background: #fff; +} + +.showcase__subnav-inner { + padding-top: 0.45rem; + padding-bottom: 0.45rem; +} + +.showcase__subnav ul { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0; + padding: 0; + list-style: none; +} + +.showcase__subnav-link { + display: inline-block; + padding: 0.3rem 0.65rem; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 600; + color: #475569; + text-decoration: none; +} + +.showcase__subnav-link:hover { + background: #f1f5f9; +} + +.showcase__subnav-link.is-active { + background: #eff6ff; + color: #1d4ed8; + box-shadow: inset 0 0 0 1px #bfdbfe; +} + +.ui-demo-embed { + height: 100%; + padding: 1.25rem; + overflow: auto; + box-sizing: border-box; +} + +.ui-demo-embed__frame { + width: 100%; + max-width: 880px; + height: 100%; + min-height: 520px; + margin: 0 auto; +} + +.ui-demo { + height: 100%; + min-height: 0; +} + +.ui-demo-router-nav { + border-bottom: 1px solid #d8dee6; + background: #fff; +} + +.ui-demo-router-nav ul { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin: 0; + padding: 0.5rem 0.75rem; + list-style: none; +} + +.ui-demo-router-nav__link { + display: inline-block; + padding: 0.25rem 0.65rem; + border-radius: 6px; + font-size: 0.9rem; + color: #334155; + text-decoration: none; +} + +.ui-demo-router-nav__link:hover { + background: #f1f5f9; +} + +.ui-demo-router-content { + padding: 0.5rem 0.75rem; + background: #fff; + border-bottom: 1px solid #e2e8f0; + font-size: 0.95rem; +} + +.landing { + height: 100%; + overflow: auto; +} + +.landing__inner { + width: 100%; + max-width: 72rem; + margin: 0 auto; + padding: 2rem 1.25rem 3rem; +} + +.landing__hero { + margin-bottom: 2rem; +} + +.landing__hero h1 { + margin: 0 0 0.5rem; + font-size: 2rem; +} + +.landing__hero p { + margin: 0; + color: #5b6773; + font-size: 1.05rem; + max-width: 42rem; +} + +.landing__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 1rem; +} + +.landing__card { + display: grid; + gap: 0.5rem; + padding: 1.25rem; + border: 1px solid #d8dee6; + border-radius: 12px; + background: #fff; + color: inherit; + text-decoration: none; + transition: + border-color 0.15s, + box-shadow 0.15s; +} + +.landing__card:hover { + border-color: #93c5fd; + box-shadow: 0 4px 16px rgb(37 99 235 / 8%); +} + +.landing__card h2 { + margin: 0; + font-size: 1.1rem; +} + +.landing__card p { + margin: 0; + color: #5b6773; + font-size: 0.95rem; +} + +.landing__card-link { + font-size: 0.9rem; + font-weight: 600; + color: #2563eb; +} diff --git a/apps/showcase/src/icons/catalog-grid.tsx b/apps/showcase/src/icons/catalog-grid.tsx new file mode 100644 index 00000000..9e1f38db --- /dev/null +++ b/apps/showcase/src/icons/catalog-grid.tsx @@ -0,0 +1,149 @@ +import type {ReactNode} from "react"; + +import {useMapsightIcon} from "@mapsight/ui/hooks/useMapsightIcon"; + +import type {CatalogSectionKind} from "./catalog-sections.ts"; + +function prebuiltIconSrc(id: string): string { + return `/img/mapsight-icons-svg/${id}-default.svg`; +} + +export function CatalogGrid({ + ids, + kind, + activeId = "", + onSelect, + showLabels = false, +}: { + ids: string[]; + kind: CatalogSectionKind; + activeId?: string; + onSelect?: (id: string) => void; + showLabels?: boolean; +}) { + return ( +
+ {ids.map((id) => ( + + ))} +
+ ); +} + +function CatalogGridItem({ + id, + kind, + active, + onSelect, + interactive, +}: { + id: string; + kind: CatalogSectionKind; + active: boolean; + onSelect?: (id: string) => void; + interactive: boolean; +}) { + if (kind === "prebuilt") { + return ( + + + + ); + } + + return ( + + ); +} + +function RuntimeCatalogGridItem({ + id, + active, + onSelect, + interactive, +}: { + id: string; + active: boolean; + onSelect?: (id: string) => void; + interactive: boolean; +}) { + const {src, bitmap, loading} = useMapsightIcon(id); + + return ( + + {src ? ( + + ) : ( + + {loading ? "…" : ""} + + )} + + ); +} + +function CatalogGridItemShell({ + id, + active, + onSelect, + interactive, + children, +}: { + id: string; + active: boolean; + onSelect?: (id: string) => void; + interactive: boolean; + children: ReactNode; +}) { + const className = `catalog-grid-item${active ? " is-active" : ""}`; + + if (!interactive) { + return ( +
+ {children} + {id} +
+ ); + } + + return ( + + ); +} diff --git a/apps/showcase/src/icons/catalog-page.tsx b/apps/showcase/src/icons/catalog-page.tsx new file mode 100644 index 00000000..d2b346fd --- /dev/null +++ b/apps/showcase/src/icons/catalog-page.tsx @@ -0,0 +1,36 @@ +import {CatalogGrid} from "./catalog-grid.tsx"; +import {catalogIconCount, catalogSections} from "./catalog-sections.ts"; + +export function CatalogPage() { + return ( +
+
+
+

Icon catalog

+

+ {catalogIconCount} icons across prebuilt traffic + sprites, traffic-style pictograms, and Font Awesome + pictograms. +

+
+
+ + {catalogSections.map((section) => ( +
+

+ {section.title}{" "} + + ({section.ids.length}) + +

+

{section.description}

+ +
+ ))} +
+ ); +} diff --git a/apps/showcase/src/icons/catalog-sections.ts b/apps/showcase/src/icons/catalog-sections.ts new file mode 100644 index 00000000..08f9517a --- /dev/null +++ b/apps/showcase/src/icons/catalog-sections.ts @@ -0,0 +1,48 @@ +import {listSpriteIconIds} from "@mapsight/traffic-style/icon-meta"; +import {listPictogramIdsBySource} from "@mapsight/traffic-style/runtime-dev"; + +export type CatalogSectionKind = "prebuilt" | "runtime"; + +export type CatalogSection = { + id: string; + title: string; + description: string; + kind: CatalogSectionKind; + ids: string[]; +}; + +const prebuiltIds = listSpriteIconIds(); +const trafficStyleIds = listPictogramIdsBySource("traffic-style"); +const fontAwesomeIds = listPictogramIdsBySource("fontawesome"); + +export const catalogSections: CatalogSection[] = [ + { + id: "prebuilt", + title: "Prebuilt", + description: + "Pixel-perfect traffic icons from the sprite sheet — not composed at runtime.", + kind: "prebuilt", + ids: prebuiltIds, + }, + { + id: "traffic-style", + title: "Traffic-style pictograms", + description: + "Glyph-only POI pictograms composed at runtime with backgrounds and variants.", + kind: "runtime", + ids: trafficStyleIds, + }, + { + id: "fontawesome", + title: "Font Awesome pictograms", + description: + "Imported Font Awesome glyphs composed at runtime with the same POI templates.", + kind: "runtime", + ids: fontAwesomeIds, + }, +]; + +export const catalogIconCount = catalogSections.reduce( + (total, section) => total + section.ids.length, + 0, +); diff --git a/apps/showcase/src/icons/demo-features.ts b/apps/showcase/src/icons/demo-features.ts new file mode 100644 index 00000000..bfed4287 --- /dev/null +++ b/apps/showcase/src/icons/demo-features.ts @@ -0,0 +1,85 @@ +import {formatMapsightIcon} from "@mapsight/traffic-style/runtime-dev"; + +export type DemoIconKind = "sprite" | "runtime-default" | "runtime-colored"; + +export type DemoMapFeature = { + id: string; + title: string; + kind: DemoIconKind; + coordinates: [number, number]; + mapsightIconId: string; +}; + +export const demoMapFeatures: DemoMapFeature[] = [ + { + id: "stau", + title: "Stau", + kind: "sprite", + coordinates: [10.512, 52.268], + mapsightIconId: "stau", + }, + { + id: "ampel", + title: "Ampel", + kind: "sprite", + coordinates: [10.518, 52.266], + mapsightIconId: "ampel", + }, + { + id: "baustelle", + title: "Baustelle", + kind: "sprite", + coordinates: [10.524, 52.264], + mapsightIconId: "baustelle", + }, + { + id: "museum-default-id", + title: "Museum", + kind: "runtime-default", + coordinates: [10.53, 52.269], + mapsightIconId: "museum", + }, + { + id: "charging-default", + title: "Charging station", + kind: "runtime-default", + coordinates: [10.536, 52.267], + mapsightIconId: "charging-station", + }, + { + id: "library-default", + title: "Library", + kind: "runtime-default", + coordinates: [10.528, 52.262], + mapsightIconId: "bilbiothek_archiv", + }, + { + id: "museum-colored", + title: "Museum", + kind: "runtime-colored", + coordinates: [10.522, 52.271], + mapsightIconId: "museum/#be123c", + }, + { + id: "charging-colored", + title: "Charging station", + kind: "runtime-colored", + coordinates: [10.534, 52.263], + mapsightIconId: "charging-station/#059669/#ffffff", + }, + { + id: "school-colored", + title: "School", + kind: "runtime-colored", + coordinates: [10.526, 52.259], + mapsightIconId: "fa-school/#1d4ed8/#ffffff", + }, +]; + +export const demoIconKindLabels: Record = { + sprite: "Sprite sheet", + "runtime-default": "Runtime (default colors)", + "runtime-colored": "Runtime (custom colors)", +}; + +export {formatMapsightIcon}; diff --git a/apps/showcase/src/icons/editor-page.tsx b/apps/showcase/src/icons/editor-page.tsx new file mode 100644 index 00000000..b33f5ce1 --- /dev/null +++ b/apps/showcase/src/icons/editor-page.tsx @@ -0,0 +1,284 @@ +import {useEffect, useMemo, useState} from "react"; +import {useDispatch} from "react-redux"; +import {useSearchParams} from "react-router-dom"; + +import { + buildCatalogTargets, + defaultIconCache, + listPictogramIds, + parseMapsightIcon, + pickContrastForeground, + prewarmCatalog, +} from "@mapsight/traffic-style/runtime-dev"; +import Instance from "@mapsight/ui/components/instance"; +import Map from "@mapsight/ui/components/map"; +import MapWrapper from "@mapsight/ui/components/map-wrapper"; +import {FEATURE_SOURCES} from "@mapsight/ui/config/constants/controllers"; + +import {updateFeatureProperty} from "@mapsight/core/lib/feature-sources/actions"; + +import { + ICON_BACKGROUND_PALETTE, + ICON_FOREGROUND_PALETTE, +} from "./icon-color-palette.ts"; +import {IconColorPicker} from "./icon-color-picker.tsx"; +import {IconPreview} from "./icon-preview.tsx"; +import { + DEFAULT_ICON_BACKGROUND, + EDITOR_PREVIEW_FEATURE_ID, + buildMapsightIcon, + createBaseMapsightConfig, + createOptions, + formatEditorPreviewFeatureJson, + styleFunction, +} from "./map-config.ts"; + +const pictogramIds = listPictogramIds(); + +type IconContentMode = "pictogram" | "label"; + +export function EditorPage() { + const [searchParams] = useSearchParams(); + const plainMap = searchParams.get("plainMap") === "1"; + const baseMapsightConfig = useMemo( + () => createBaseMapsightConfig({plainMap}), + [plainMap], + ); + + return ( + + + + ); +} + +function EditorPageContent({plainMap}: {plainMap: boolean}) { + const dispatch = useDispatch(); + const [contentMode, setContentMode] = + useState("pictogram"); + const [pictogram, setPictogram] = useState(pictogramIds[0] ?? "fa-marker"); + const [label, setLabel] = useState(""); + const [background, setBackground] = useState(DEFAULT_ICON_BACKGROUND); + const [foregroundOverride, setForegroundOverride] = useState( + null, + ); + const [stats, setStats] = useState(defaultIconCache.getStats()); + const [prewarmed, setPrewarmed] = useState(null); + + const foregroundIsAuto = foregroundOverride === null; + const resolvedForeground = + foregroundOverride ?? pickContrastForeground(background); + + const mapsightIconId = useMemo( + () => + buildMapsightIcon({ + pictogram: contentMode === "pictogram" ? pictogram : "", + label: contentMode === "label" ? label : "", + background, + foregroundOverride, + }), + [contentMode, pictogram, label, background, foregroundOverride], + ); + + const previewSpec = useMemo( + () => parseMapsightIcon(mapsightIconId), + [mapsightIconId], + ); + + const editorFeatureJson = useMemo( + () => formatEditorPreviewFeatureJson(mapsightIconId), + [mapsightIconId], + ); + + useEffect(() => { + dispatch( + updateFeatureProperty( + FEATURE_SOURCES, + "data", + EDITOR_PREVIEW_FEATURE_ID, + "mapsightIconId", + mapsightIconId, + undefined, + ), + ); + }, [dispatch, mapsightIconId]); + + useEffect(() => { + const timer = window.setInterval(() => { + setStats(defaultIconCache.getStats()); + }, 400); + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + const idle = window.requestIdleCallback?.( + () => { + void prewarmCatalog( + defaultIconCache, + buildCatalogTargets({variants: ["default", "plain"]}), + ).then((count) => setPrewarmed(count)); + }, + {timeout: 2000}, + ); + return () => { + if (idle) { + window.cancelIdleCallback(idle); + } + }; + }, []); + + return ( +
+ + +
+ + + +
+
+ ); +} diff --git a/apps/showcase/src/icons/icon-color-palette.ts b/apps/showcase/src/icons/icon-color-palette.ts new file mode 100644 index 00000000..42de7599 --- /dev/null +++ b/apps/showcase/src/icons/icon-color-palette.ts @@ -0,0 +1,23 @@ +export const ICON_BACKGROUND_PALETTE = [ + "#ffffff", + "#e2e8f0", + "#fecdd3", + "#ddd6fe", + "#bfdbfe", + "#99f6e4", + "#bbf7d0", + "#fef08a", + "#be123c", + "#059669", + "#1d4ed8", + "#035799", +] as const; + +export const ICON_FOREGROUND_PALETTE = [ + "#000000", + "#333333", + "#666666", + "#999999", + "#cccccc", + "#ffffff", +] as const; diff --git a/apps/showcase/src/icons/icon-color-picker.tsx b/apps/showcase/src/icons/icon-color-picker.tsx new file mode 100644 index 00000000..12bb64b7 --- /dev/null +++ b/apps/showcase/src/icons/icon-color-picker.tsx @@ -0,0 +1,162 @@ +import {type ReactNode, useId, useState} from "react"; + +function normalizeHex(value: string): string | null { + const trimmed = value.trim().toLowerCase(); + + if (/^#[0-9a-f]{6}$/.test(trimmed)) { + return trimmed; + } + + const shortMatch = trimmed.match(/^#?([0-9a-f])([0-9a-f])([0-9a-f])$/); + if (shortMatch) { + const [, red, green, blue] = shortMatch; + return `#${red}${red}${green}${green}${blue}${blue}`; + } + + const bareMatch = trimmed.match(/^([0-9a-f]{6})$/); + if (bareMatch) { + return `#${bareMatch[1]}`; + } + + return null; +} + +function colorsMatch(left: string, right: string): boolean { + return normalizeHex(left) === normalizeHex(right); +} + +function HexColorInput({ + id, + labelText, + value, + onChange, +}: { + id: string; + labelText: string; + value: string; + onChange: (color: string) => void; +}) { + const [hexInput, setHexInput] = useState(value); + + const commitHexInput = (rawValue: string) => { + setHexInput(rawValue); + const normalized = normalizeHex(rawValue); + if (normalized) { + onChange(normalized); + } + }; + + return ( + commitHexInput(event.target.value)} + onBlur={(event) => { + const normalized = normalizeHex(event.target.value); + setHexInput(normalized ?? value); + }} + spellCheck={false} + aria-label={`${labelText} hex value`} + /> + ); +} + +export function IconColorPicker({ + id, + label, + value, + palette, + onChange, + onReset, + resetDisabled = false, + note, +}: { + id: string; + label: ReactNode; + value: string; + palette: readonly string[]; + onChange: (color: string) => void; + onReset?: () => void; + resetDisabled?: boolean; + note?: ReactNode; +}) { + const paletteId = useId(); + const labelText = typeof label === "string" ? label : id; + + return ( +
+
+ + {onReset ? ( + + ) : null} +
+ +
+
+ + +
+ +
+ {palette.map((color) => { + const selected = colorsMatch(color, value); + + return ( + + ); + })} +
+
+ + {note ? {note} : null} +
+ ); +} diff --git a/apps/showcase/src/icons/icon-preview.tsx b/apps/showcase/src/icons/icon-preview.tsx new file mode 100644 index 00000000..1adca4ea --- /dev/null +++ b/apps/showcase/src/icons/icon-preview.tsx @@ -0,0 +1,51 @@ +import { + type IconSpec, + type IconVariant, + formatMapsightIcon, +} from "@mapsight/traffic-style/runtime-dev"; +import {useMapsightIcon} from "@mapsight/ui/hooks/useMapsightIcon"; + +const VARIANTS: IconVariant[] = ["default", "small", "xsmall", "plain"]; + +export function IconPreview({spec}: {spec: IconSpec}) { + return ( +
+ {VARIANTS.map((variant) => ( + + ))} +
+ ); +} + +function VariantPreview({ + mapsightIconId, + variant, + label, +}: { + mapsightIconId: string; + variant: IconVariant; + label: string; +}) { + const {src, bitmap, loading} = useMapsightIcon(mapsightIconId, variant); + + return ( +
+ {src ? ( + + ) : ( +
{loading ? "…" : ""}
+ )} + {label} +
+ ); +} diff --git a/apps/showcase/src/icons/map-config.ts b/apps/showcase/src/icons/map-config.ts new file mode 100644 index 00000000..3da157ea --- /dev/null +++ b/apps/showcase/src/icons/map-config.ts @@ -0,0 +1,176 @@ +import * as config from "@mapsight/ui/config"; +import {FEATURE_SOURCES} from "@mapsight/ui/config/constants/controllers"; +import {plain} from "@mapsight/ui/config/feature/sources"; +import { + interactiveFeatures, + metaData, + osm, +} from "@mapsight/ui/config/map/layers"; +import createDefaultPlugins from "@mapsight/ui/plugins/browser-defaults"; +import type {CreateOptions, PluginDefinition} from "@mapsight/ui/types"; + +import {load} from "@mapsight/core/lib/feature-sources/actions"; + +import {demoMapFeatures, formatMapsightIcon} from "./demo-features.ts"; + +export {default as styleFunction} from "../generated/mapsight-vector-styles/icon-demo"; + +const MAP_CENTER_PROJECTED = [1171479, 6848253] as const; +const MAP_CENTER_GEOJSON: [number, number] = [10.523575, 52.2653825]; + +const center = config.mapViewCenter( + MAP_CENTER_PROJECTED[0], + MAP_CENTER_PROJECTED[1], +); +const extent = config.mapViewExtent(1097392, 6789091, 1240635, 6895797); + +export const EDITOR_PREVIEW_FEATURE_ID = "editor-preview"; +export const EDITOR_PREVIEW_COORDINATES = MAP_CENTER_GEOJSON; + +export function buildEditorPreviewFeature(mapsightIconId: string) { + return { + type: "Feature" as const, + id: EDITOR_PREVIEW_FEATURE_ID, + properties: { + title: "Editor preview", + mapsightIconId, + }, + geometry: { + type: "Point" as const, + coordinates: EDITOR_PREVIEW_COORDINATES, + }, + }; +} + +export function formatEditorPreviewFeatureJson(mapsightIconId: string): string { + const [lon, lat] = EDITOR_PREVIEW_COORDINATES; + + return `{ + "type": "Feature", + "properties": { + "mapsightIconId": ${JSON.stringify(mapsightIconId)} + }, + "geometry": { + "type": "Point", + "coordinates": [${lon}, ${lat}] + } +}`; +} + +function buildDemoFeatures() { + return demoMapFeatures.map((item) => ({ + type: "Feature" as const, + id: item.id, + properties: { + title: item.title, + mapsightIconId: item.mapsightIconId, + }, + geometry: { + type: "Point" as const, + coordinates: item.coordinates, + }, + })); +} + +export function createBaseMapsightConfig(options: {plainMap?: boolean} = {}) { + const features = [...buildDemoFeatures(), buildEditorPreviewFeature("")]; + const dataLayer = interactiveFeatures( + "data", + true, + metaData("Runtime icons", null, true, true, false, "Demo"), + "features", + ); + const mapView = config.mapView(center, extent, 14, 10, 18); + const featureSources = config.features({ + data: { + ...plain(), + data: { + type: "FeatureCollection", + features, + }, + lastUpdate: Date.now(), + lastActionType: null, + }, + }); + + if (options.plainMap) { + return { + ...config.map({data: dataLayer}, mapView), + ...featureSources, + }; + } + + return { + ...config.map( + { + osm: osm( + "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + true, + metaData( + "OSM", + '© OpenStreetMap', + true, + false, + true, + "Karte", + ), + ), + data: dataLayer, + }, + mapView, + ), + ...featureSources, + }; +} + +const loadDemoFeaturesPlugin: PluginDefinition = [ + "loadDemoFeatures", + { + afterInit(context) { + context.store?.dispatch(load(FEATURE_SOURCES, "data")); + }, + }, +]; + +export const createOptions: CreateOptions = { + plugins: [...createDefaultPlugins(), loadDemoFeaturesPlugin], + uiState: { + map: { + show: true, + }, + }, +}; + +export const DEFAULT_ICON_BACKGROUND = "#ffffff"; + +export function buildMapsightIcon(input: { + pictogram: string; + label: string; + background: string; + foregroundOverride: string | null; +}): string { + const hasPictogram = Boolean(input.pictogram); + const hasLabel = Boolean(input.label.trim()); + const usesDefaultBackground = input.background === DEFAULT_ICON_BACKGROUND; + const hasExplicitForeground = input.foregroundOverride !== null; + + if (usesDefaultBackground && !hasExplicitForeground) { + return formatMapsightIcon({ + pictogram: hasPictogram ? input.pictogram : undefined, + label: hasLabel ? input.label : undefined, + }); + } + + const colors: {background: string; foreground?: string} = { + background: input.background, + }; + if (input.foregroundOverride !== null) { + colors.foreground = input.foregroundOverride; + } + + return formatMapsightIcon({ + pictogram: hasPictogram ? input.pictogram : undefined, + label: hasLabel ? input.label : undefined, + colors, + }); +} diff --git a/apps/showcase/src/icons/styles.css b/apps/showcase/src/icons/styles.css new file mode 100644 index 00000000..2648a34e --- /dev/null +++ b/apps/showcase/src/icons/styles.css @@ -0,0 +1,537 @@ +:root { + color-scheme: light; + font-family: + Inter, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + line-height: 1.5; +} + +* { + box-sizing: border-box; +} + +.icon-route { + height: 100%; + overflow: hidden; +} + +.icon-route--editor { + min-height: 0; +} + +.icon-route--editor.app { + height: 100%; +} + +.icon-route--catalog { + overflow: auto; +} + +button, +input, +select { + font: inherit; +} + +.app { + display: grid; + grid-template-columns: 400px 1fr; + height: 100%; + overflow: hidden; +} + +.panel { + height: 100%; + padding: 1rem 1.25rem 2rem; + background: #fff; + border-right: 1px solid #d8dee6; + overflow-y: auto; + overscroll-behavior: contain; +} + +.panel h1 { + margin: 0 0 0.25rem; + font-size: 1.35rem; +} + +.panel h2 { + margin: 1.5rem 0 0; + font-size: 1rem; +} + +.panel p { + margin: 0 0 1rem; + color: #5b6773; +} + +.field { + display: grid; + gap: 0.35rem; + margin-bottom: 0.85rem; +} + +.field label, +.field__label { + font-size: 0.85rem; + font-weight: 600; +} + +.field__hint { + font-weight: 500; + color: #64748b; +} + +.field__note { + font-size: 0.75rem; + color: #64748b; +} + +.mode-toggle { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 0.2rem; + border: 1px solid #c7d0db; + border-radius: 8px; + background: #f8fafc; +} + +.mode-toggle__btn { + padding: 0.45rem 0.55rem; + border: 0; + border-radius: 6px; + background: transparent; + font-size: 0.85rem; + font-weight: 600; + color: #5b6773; + cursor: pointer; + transition: + background-color 0.15s, + color 0.15s, + box-shadow 0.15s; +} + +.mode-toggle__btn:hover:not(.is-active) { + color: #1f2937; +} + +.mode-toggle__btn.is-active { + background: #fff; + color: #111827; + box-shadow: 0 1px 2px rgb(15 23 42 / 0.08); +} + +.field input, +.field select { + padding: 0.45rem 0.55rem; + border: 1px solid #c7d0db; + border-radius: 8px; + background: #fff; +} + +.color-row { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.85rem; +} + +.color-picker-field { + margin-bottom: 0; + min-width: 0; +} + +.color-picker__control { + display: grid; + gap: 0.45rem; + min-width: 0; +} + +.color-picker__value-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 0.45rem; + min-width: 0; +} + +.color-picker__preview-wrap { + position: relative; + display: block; + width: 2rem; + height: 2rem; + flex-shrink: 0; + cursor: pointer; +} + +.color-picker__preview { + display: block; + width: 100%; + height: 100%; + border: 1px solid #c7d0db; + border-radius: 7px; + pointer-events: none; +} + +.color-picker__native { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + border: 0; + opacity: 0; + cursor: pointer; +} + +.color-picker__hex { + min-width: 0; + width: 100%; + padding: 0.35rem 0.5rem; + border: 1px solid #c7d0db; + border-radius: 8px; + background: #fff; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + font-size: 0.78rem; + color: #1e293b; +} + +.color-picker__palette { + display: flex; + align-items: center; + gap: 0.3rem; + min-width: 0; + padding: 0.3rem 0.4rem; + border: 1px solid #c7d0db; + border-radius: 8px; + background: #fff; +} + +.color-picker__swatch { + position: relative; + flex: 1 1 0; + min-width: 0; + height: 1.35rem; + padding: 0; + border: 1px solid rgb(15 23 42 / 0.1); + border-radius: 5px; + cursor: pointer; +} + +.color-picker__swatch:hover { + border-color: rgb(15 23 42 / 0.22); +} + +.color-picker__swatch.is-selected { + border-color: #2563eb; + box-shadow: 0 0 0 1px #2563eb; +} + +.color-picker__swatch-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.field__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.reset-btn { + padding: 0; + border: 0; + background: none; + font-size: 0.75rem; + font-weight: 600; + color: #2563eb; + cursor: pointer; +} + +.reset-btn:hover:not(:disabled) { + text-decoration: underline; +} + +.reset-btn:disabled { + color: #94a3b8; + cursor: default; +} + +.output-card { + margin-top: 1rem; + padding: 0.7rem 0.75rem; + border: 1px solid #bfdbfe; + border-radius: 10px; + background: #f8fbff; + font-size: 0.8rem; +} + +.output-card__header, +.output-card__subheader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.output-card__subheader { + margin-top: 0.7rem; +} + +.output-card strong { + font-size: 0.8rem; +} + +.output-card__hint { + font-size: 0.68rem; + font-weight: 500; + color: #64748b; +} + +.id-output { + margin-top: 0.4rem; + padding: 0.5rem 0.6rem; + border: 1px solid #93c5fd; + border-radius: 8px; + background: #fff; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + font-size: 0.82rem; + font-weight: 700; + line-height: 1.3; + color: #0f172a; + word-break: break-all; +} + +.id-output__empty { + font-size: 0.75rem; + font-weight: 500; + color: #64748b; +} + +.geojson-output { + margin: 0.4rem 0 0; + padding: 0.5rem 0.6rem; + overflow: auto; + border: 1px solid #d8dee6; + border-radius: 8px; + background: #fff; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + font-size: 0.68rem; + line-height: 1.35; + color: #1e293b; + white-space: pre; +} + +.preview-card { + margin-top: 1rem; + padding: 1rem; + border: 1px solid #d8dee6; + border-radius: 12px; + background: #fff; +} + +.preview-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; + margin-top: 0.75rem; +} + +.preview-item { + display: grid; + justify-items: center; + gap: 0.35rem; + padding: 0.5rem; + border-radius: 8px; + background: #fff; + border: 1px solid #e5ebf1; +} + +.preview-item span { + font-size: 0.7rem; + color: #5b6773; +} + +.stats { + margin-top: 1rem; + padding: 0.75rem 0.9rem; + border-radius: 8px; + border: 1px solid #dbeafe; + background: #fff; + font-size: 0.85rem; +} + +.catalog-page { + height: 100%; + overflow-y: auto; + padding: 1.25rem 1.25rem 2rem; +} + +.catalog-page__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.catalog-page__header h1 { + margin: 0 0 0.35rem; + font-size: 1.5rem; +} + +.catalog-page__header p { + margin: 0; + color: #5b6773; +} + +.catalog-section { + margin-top: 2rem; +} + +.catalog-section h2 { + margin: 0 0 0.35rem; + font-size: 1.1rem; +} + +.catalog-section__count { + font-weight: 500; + color: #64748b; +} + +.catalog-section p { + margin: 0; + color: #5b6773; + font-size: 0.9rem; +} + +.catalog-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); + gap: 0.6rem; + margin-top: 0.75rem; +} + +.catalog-grid--page { + grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); + gap: 0.75rem; +} + +.catalog-grid--page .catalog-grid-item { + padding: 0.9rem 0.45rem 0.7rem; +} + +.catalog-grid--page .catalog-grid-item img, +.catalog-grid--page .catalog-grid-item__placeholder { + width: 48px; + height: 48px; +} + +.catalog-grid-item { + display: grid; + justify-items: center; + gap: 0.35rem; + padding: 0.7rem 0.35rem 0.55rem; + border-radius: 10px; + background: #fff; + border: 1px solid #e5ebf1; + cursor: pointer; + transition: + border-color 0.15s, + box-shadow 0.15s; +} + +.catalog-grid-item:hover, +.catalog-grid-item.is-active { + border-color: #3b82f6; + box-shadow: 0 0 0 1px #3b82f6; + background: #fff; +} + +.catalog-grid-item span { + font-size: 0.68rem; + text-align: center; + color: #5b6773; + word-break: break-word; + line-height: 1.2; +} + +.catalog-grid-item__placeholder { + display: grid; + place-items: center; + width: 40px; + height: 40px; + color: #94a3b8; +} + +.map-pane { + position: relative; + height: 100%; + overflow: hidden; +} + +.icon-route--editor .map-pane { + display: flex; + flex-direction: column; + min-height: 0; +} + +.icon-route--editor .ms3-map-wrapper { + flex: 1; + height: 100%; + max-height: none; + min-height: 0; +} + +.icon-route--plain-map .ms3-map-wrapper, +.icon-route--plain-map .ol-viewport { + background: #d4d4d4; +} + +.map-loading { + display: grid; + place-items: center; + height: 100%; + color: #5b6773; +} + +@media (max-width: 960px) { + html, + body, + #root { + overflow: auto; + } + + .app { + grid-template-columns: 1fr; + height: auto; + overflow: visible; + } + + .panel { + height: auto; + max-height: none; + overflow: visible; + } + + .map-pane { + height: 60vh; + min-height: 360px; + } +} diff --git a/apps/showcase/src/index.html b/apps/showcase/src/index.html new file mode 100644 index 00000000..66f9dbaf --- /dev/null +++ b/apps/showcase/src/index.html @@ -0,0 +1,15 @@ + + + + + + Mapsight Showcase + + + + + +
+ + + diff --git a/apps/showcase/src/layout/demo-nav.tsx b/apps/showcase/src/layout/demo-nav.tsx new file mode 100644 index 00000000..255dd928 --- /dev/null +++ b/apps/showcase/src/layout/demo-nav.tsx @@ -0,0 +1,13 @@ +import {ShowcaseSubnav} from "./showcase-subnav.tsx"; + +export const demoItems = [ + {to: "/ui/combined-list", label: "Combined list"}, + {to: "/ui/simple-map", label: "Simple map"}, + {to: "/ui/full", label: "Full"}, + {to: "/ui/custom", label: "Custom"}, + {to: "/ui/router", label: "Router", end: false}, +] as const; + +export function DemoNav() { + return ; +} diff --git a/apps/showcase/src/layout/icons-nav.tsx b/apps/showcase/src/layout/icons-nav.tsx new file mode 100644 index 00000000..694ecfa6 --- /dev/null +++ b/apps/showcase/src/layout/icons-nav.tsx @@ -0,0 +1,10 @@ +import {ShowcaseSubnav} from "./showcase-subnav.tsx"; + +export const iconItems = [ + {to: "/icons", label: "Editor"}, + {to: "/icons/catalog", label: "Catalog"}, +] as const; + +export function IconsNav() { + return ; +} diff --git a/apps/showcase/src/layout/showcase-layout.tsx b/apps/showcase/src/layout/showcase-layout.tsx new file mode 100644 index 00000000..da75c144 --- /dev/null +++ b/apps/showcase/src/layout/showcase-layout.tsx @@ -0,0 +1,62 @@ +import {NavLink, Outlet, useLocation} from "react-router-dom"; + +import {DemoNav} from "./demo-nav.tsx"; +import {IconsNav} from "./icons-nav.tsx"; + +const navItems = [ + {to: "/", label: "Home", end: true}, + {to: "/ui/combined-list", label: "Demos", end: false}, + {to: "/icons", label: "Icons", end: false}, +] as const; + +export function ShowcaseLayout() { + const location = useLocation(); + const showDemoNav = location.pathname.startsWith("/ui"); + const showIconsNav = location.pathname.startsWith("/icons"); + const isIconEditor = location.pathname === "/icons"; + + const mainClassName = [ + "showcase__main", + showDemoNav ? "showcase__main--embed" : "", + isIconEditor ? "showcase__main--icon-editor" : "", + ] + .filter(Boolean) + .join(" "); + + return ( +
+
+
+
+ Mapsight + Showcase +
+ +
+
+ {showDemoNav ? : null} + {showIconsNav ? : null} +
+ +
+
+ ); +} diff --git a/apps/showcase/src/layout/showcase-subnav.tsx b/apps/showcase/src/layout/showcase-subnav.tsx new file mode 100644 index 00000000..a2c1d0bf --- /dev/null +++ b/apps/showcase/src/layout/showcase-subnav.tsx @@ -0,0 +1,39 @@ +import {NavLink} from "react-router-dom"; + +export type SubnavItem = { + to: string; + label: string; + end?: boolean; +}; + +export function ShowcaseSubnav({ + label, + items, +}: { + label: string; + items: readonly SubnavItem[]; +}) { + return ( + + ); +} diff --git a/apps/showcase/src/main.tsx b/apps/showcase/src/main.tsx new file mode 100644 index 00000000..c9f5d4e6 --- /dev/null +++ b/apps/showcase/src/main.tsx @@ -0,0 +1,12 @@ +import "@mapsight/traffic-style/pictograms-fontawesome"; + +import {StrictMode} from "react"; +import {createRoot} from "react-dom/client"; + +import {App} from "./app.tsx"; + +createRoot(document.querySelector("#root")!).render( + + + , +); diff --git a/apps/demo-vite/src/msui.scss b/apps/showcase/src/msui.scss similarity index 98% rename from apps/demo-vite/src/msui.scss rename to apps/showcase/src/msui.scss index fd9227a8..e396ef5e 100644 --- a/apps/demo-vite/src/msui.scss +++ b/apps/showcase/src/msui.scss @@ -17,11 +17,6 @@ html { font-size: 0.825em; } -body { - max-width: 800px; - margin: 0 auto; -} - .ms3-list__info, .ms3-feature-details-content { line-height: 1.5; diff --git a/apps/showcase/src/pages/landing-page.tsx b/apps/showcase/src/pages/landing-page.tsx new file mode 100644 index 00000000..0af3fab3 --- /dev/null +++ b/apps/showcase/src/pages/landing-page.tsx @@ -0,0 +1,47 @@ +import {Link} from "react-router-dom"; + +const sections = [ + { + to: "/ui/combined-list", + title: "Demos", + description: + "Mapsight UI examples — combined list, simple map, full layout, custom overlays, and router integration.", + }, + { + to: "/icons", + title: "Icons", + description: + "Runtime icon editor and catalog — compose pictograms on a map or browse prebuilt sprites and Font Awesome glyphs.", + }, +] as const; + +export function LandingPage() { + return ( +
+
+
+

Mapsight ecosystem showcase

+

+ Interactive demos for the modular Mapsight packages — UI + components, traffic-style icons, and runtime + composition. +

+
+ +
+ {sections.map((section) => ( + +

{section.title}

+

{section.description}

+ Open → + + ))} +
+
+
+ ); +} diff --git a/apps/showcase/src/pages/ui-demos/combined-list-page.tsx b/apps/showcase/src/pages/ui-demos/combined-list-page.tsx new file mode 100644 index 00000000..74defcea --- /dev/null +++ b/apps/showcase/src/pages/ui-demos/combined-list-page.tsx @@ -0,0 +1,20 @@ +import App from "@mapsight/ui/components/app"; + +import { + baseMapsightConfig, + createOptions, + styleFunction, +} from "../../combined-list/shared.tsx"; +import {UiDemoShell} from "./ui-demo-shell.tsx"; + +export function CombinedListPage() { + return ( + + + + ); +} diff --git a/apps/demo-vite/src/custom.tsx b/apps/showcase/src/pages/ui-demos/custom-page.tsx similarity index 81% rename from apps/demo-vite/src/custom.tsx rename to apps/showcase/src/pages/ui-demos/custom-page.tsx index ba1eeb3b..81cfacb9 100644 --- a/apps/demo-vite/src/custom.tsx +++ b/apps/showcase/src/pages/ui-demos/custom-page.tsx @@ -1,9 +1,7 @@ import type {ReactNode} from "react"; import {useReducer} from "react"; -import {createRoot} from "react-dom/client"; import App from "@mapsight/ui/components/app"; -import Instance from "@mapsight/ui/components/instance"; import MeasureDistanceButton from "@mapsight/ui/components/map-overlay/measure-distance-button"; import SharePositionLinkButton from "@mapsight/ui/components/map-overlay/share-position-link-button"; import * as config from "@mapsight/ui/config"; @@ -20,12 +18,12 @@ import createShareLinkPlugin from "@mapsight/ui/plugins/browser/share-position-l import createMeasureDistancePlugin from "@mapsight/ui/plugins/common/measure-distance"; import type {CreateOptions} from "@mapsight/ui/types"; -import createNativeFullscreenPlugin from "./features/native-fullscreen/plugin"; -import styleFunction from "./generated/mapsight-vector-styles/demo.js"; +import createNativeFullscreenPlugin from "../../features/native-fullscreen/plugin.ts"; +import styleFunction from "../../generated/mapsight-vector-styles/demo.js"; +import {demoGeoJsonUrl} from "../../ui-demos/demo-geojson.ts"; +import {UiDemoShell} from "./ui-demo-shell.tsx"; -// test - -const bmc = { +const baseMapsightConfig = { ...config.map( { osm: osm( @@ -71,15 +69,15 @@ const bmc = { ), }, config.mapView( - config.mapViewCenter(1171479, 6848253), // x, y ^= 52.2653825, 10.523575 (lat, lon) - config.mapViewExtent(1097392, 6789091, 1240635, 6895797), // minx, miny, maxx, maxy http://bboxfinder.com/#51.938934,9.858041,52.526000,11.144814 - 14, // z - 10, // minZoom - 18, // maxZoom + config.mapViewCenter(1171479, 6848253), + config.mapViewExtent(1097392, 6789091, 1240635, 6895797), + 14, + 10, + 18, ), ), ...config.features({ - data: xhrJson(new URL("data.geojson", import.meta.url).toString()), + data: xhrJson(demoGeoJsonUrl), userGeolocation: plain(), searchResult: plain(), }), @@ -166,13 +164,15 @@ const createOptions: CreateOptions = { }, }; -const root = createRoot(document.querySelector("#root")!); -root.render( - - - , -); +export function CustomPage() { + return ( + + + + ); +} diff --git a/apps/showcase/src/pages/ui-demos/full-page.tsx b/apps/showcase/src/pages/ui-demos/full-page.tsx new file mode 100644 index 00000000..a3bcdd70 --- /dev/null +++ b/apps/showcase/src/pages/ui-demos/full-page.tsx @@ -0,0 +1,20 @@ +import App from "@mapsight/ui/components/app"; + +import { + baseMapsightConfig, + createOptions, + styleFunction, +} from "../../ui-demos/full-config.tsx"; +import {UiDemoShell} from "./ui-demo-shell.tsx"; + +export function FullPage() { + return ( + + + + ); +} diff --git a/apps/showcase/src/pages/ui-demos/router-page.tsx b/apps/showcase/src/pages/ui-demos/router-page.tsx new file mode 100644 index 00000000..991f1827 --- /dev/null +++ b/apps/showcase/src/pages/ui-demos/router-page.tsx @@ -0,0 +1,66 @@ +import {Link, Route, Routes} from "react-router-dom"; + +import App from "@mapsight/ui/components/app"; + +import { + baseMapsightConfig, + createOptions, + styleFunction, +} from "../../ui-demos/router-config.tsx"; +import {UiDemoShell} from "./ui-demo-shell.tsx"; + +function RoutedApp() { + return ; +} + +export function RouterPage() { + return ( + + + +
+ + Home} /> + Beispiel 1} /> + Beispiel 2} /> + +
+ + + } /> + +
+ ); +} diff --git a/apps/showcase/src/pages/ui-demos/simple-map-page.tsx b/apps/showcase/src/pages/ui-demos/simple-map-page.tsx new file mode 100644 index 00000000..48fed058 --- /dev/null +++ b/apps/showcase/src/pages/ui-demos/simple-map-page.tsx @@ -0,0 +1,24 @@ +import Map from "@mapsight/ui/components/map"; +import MapWrapper from "@mapsight/ui/components/map-wrapper"; + +import { + baseMapsightConfig, + createOptions, + noopStyleFunction, +} from "../../ui-demos/simple-map-config.ts"; +import {UiDemoShell} from "./ui-demo-shell.tsx"; + +export function SimpleMapPage() { + return ( + + + + + + ); +} diff --git a/apps/showcase/src/pages/ui-demos/ui-demo-shell.tsx b/apps/showcase/src/pages/ui-demos/ui-demo-shell.tsx new file mode 100644 index 00000000..ff7ca324 --- /dev/null +++ b/apps/showcase/src/pages/ui-demos/ui-demo-shell.tsx @@ -0,0 +1,49 @@ +import type {ReactNode} from "react"; + +import Instance from "@mapsight/ui/components/instance"; +import createDefaultPlugins from "@mapsight/ui/plugins/browser-defaults"; +import type {CreateOptions} from "@mapsight/ui/types"; + +import type {State} from "@mapsight/core/types"; + +type StyleFunction = Parameters[0]["styleFunction"]; + +export function UiDemoShell({ + baseMapsightConfig, + createOptions, + styleFunction, + mergeDefaultPlugins = true, + children, +}: { + baseMapsightConfig: Partial; + createOptions: CreateOptions; + styleFunction: StyleFunction; + mergeDefaultPlugins?: boolean; + children: ReactNode; +}) { + const resolvedOptions = mergeDefaultPlugins + ? { + ...createOptions, + plugins: [ + ...createDefaultPlugins(), + ...(createOptions.plugins ?? []), + ], + } + : createOptions; + + return ( +
+
+
+ + {children} + +
+
+
+ ); +} diff --git a/apps/showcase/src/ui-demos/demo-geojson.ts b/apps/showcase/src/ui-demos/demo-geojson.ts new file mode 100644 index 00000000..0792d993 --- /dev/null +++ b/apps/showcase/src/ui-demos/demo-geojson.ts @@ -0,0 +1,4 @@ +export const demoGeoJsonUrl = new URL( + "../data/demo.geojson", + import.meta.url, +).toString(); diff --git a/apps/demo-vite/src/full/shared.tsx b/apps/showcase/src/ui-demos/full-config.tsx similarity index 86% rename from apps/demo-vite/src/full/shared.tsx rename to apps/showcase/src/ui-demos/full-config.tsx index 6d55c9e1..645614db 100644 --- a/apps/demo-vite/src/full/shared.tsx +++ b/apps/showcase/src/ui-demos/full-config.tsx @@ -25,7 +25,8 @@ import type {LayerDefinition} from "@mapsight/core/lib/map/lib/WithLayers"; import {DEFAULT_OPTIONS as FIT_FEATURE_DEFAULT_OPTIONS} from "@mapsight/lib-ol/map/fitToFeature"; -import createNativeFullscreenPlugin from "../features/native-fullscreen/plugin"; +import createNativeFullscreenPlugin from "../features/native-fullscreen/plugin.ts"; +import {demoGeoJsonUrl} from "./demo-geojson.ts"; export {default as styleFunction} from "../generated/mapsight-vector-styles/demo"; @@ -45,10 +46,13 @@ const clusterFeaturesOptions = { }; function withClustering(layer: LayerDefinition): LayerDefinition { - Object.assign(layer.options.source.options, { - clusterFeatures: true, - clusterFeaturesOptions: clusterFeaturesOptions, - }); + const source = layer.options?.source; + if (source?.type === "VectorFeatureSource" && source.options) { + Object.assign(source.options, { + clusterFeatures: true, + clusterFeaturesOptions: clusterFeaturesOptions, + }); + } return layer; } @@ -104,18 +108,15 @@ export const baseMapsightConfig = { ), }, config.mapView( - config.mapViewCenter(1171479, 6848253), // x, y ^= 52.2653825, 10.523575 (lat, lon) - config.mapViewExtent(1097392, 6789091, 1240635, 6895797), // minx, miny, maxx, maxy http://bboxfinder.com/#51.938934,9.858041,52.526000,11.144814 - 14, // z - 10, // minZoom - 18, // maxZoom + config.mapViewCenter(1171479, 6848253), + config.mapViewExtent(1097392, 6789091, 1240635, 6895797), + 14, + 10, + 18, ), ), ...config.features({ - data: withFilter( - xhrJson(new URL("../data.geojson", import.meta.url).toString()), - TAG_FILTER, - ), + data: withFilter(xhrJson(demoGeoJsonUrl), TAG_FILTER), userGeolocation: plain(), searchResult: plain(), }), @@ -128,7 +129,7 @@ export const createOptions: CreateOptions = { ["measureDistance", createMeasureDistancePlugin()], ["nativeFullscreen", createNativeFullscreenPlugin()], ], - imagesUrl: "/public/img/", + imagesUrl: "/img/", components: { MapOverlayTopLeft: () => ( diff --git a/apps/demo-vite/src/router/router.tsx b/apps/showcase/src/ui-demos/router-config.tsx similarity index 59% rename from apps/demo-vite/src/router/router.tsx rename to apps/showcase/src/ui-demos/router-config.tsx index 3f5a48cb..3ea71622 100644 --- a/apps/demo-vite/src/router/router.tsx +++ b/apps/showcase/src/ui-demos/router-config.tsx @@ -1,8 +1,5 @@ import {Fragment} from "react"; -import {createRoot} from "react-dom/client"; -import {BrowserRouter, Link, Route, Routes} from "react-router-dom"; -import Instance from "@mapsight/ui/components/instance"; import RegionSelector from "@mapsight/ui/components/map-overlay/region-selector"; import SearchOverlay from "@mapsight/ui/components/map-overlay/search-overlay"; import SharePositionLinkButton from "@mapsight/ui/components/map-overlay/share-position-link-button"; @@ -18,10 +15,11 @@ import createDefaultPlugins from "@mapsight/ui/plugins/browser-defaults"; import createShareLinkPlugin from "@mapsight/ui/plugins/browser/share-position-link"; import type {CreateOptions} from "@mapsight/ui/types"; -import styleFunction from "../generated/mapsight-vector-styles/demo"; -import RoutedApp from "./components/RoutedApp"; +import {demoGeoJsonUrl} from "./demo-geojson.ts"; -const bmc = { +export {default as styleFunction} from "../generated/mapsight-vector-styles/demo"; + +export const baseMapsightConfig = { ...config.map( { osm: osm( @@ -67,27 +65,27 @@ const bmc = { ), }, config.mapView( - config.mapViewCenter(1171479, 6848253), // x, y ^= 52.2653825, 10.523575 (lat, lon) - config.mapViewExtent(1097392, 6789091, 1240635, 6895797), // minx, miny, maxx, maxy http://bboxfinder.com/#51.938934,9.858041,52.526000,11.144814 - 14, // z - 10, // minZoom - 18, // maxZoom + config.mapViewCenter(1171479, 6848253), + config.mapViewExtent(1097392, 6789091, 1240635, 6895797), + 14, + 10, + 18, ), ), ...config.features({ - data: xhrJson(new URL("../data.geojson", import.meta.url).toString()), + data: xhrJson(demoGeoJsonUrl), userGeolocation: plain(), searchResult: plain(), }), ...config.featureList("data", true), }; -const createOptions: CreateOptions = { +export const createOptions: CreateOptions = { plugins: [ ...createDefaultPlugins(), ["shareLink", createShareLinkPlugin()], ], - imagesUrl: "/public/img/", + imagesUrl: "/img/", components: { MapOverlayTopLeft: function CustomMapOverlayTopLeft() { return ( @@ -126,39 +124,3 @@ const createOptions: CreateOptions = { }, }, }; - -const root = createRoot(document.querySelector("#root")!); -root.render( - - - - - - Home} /> - Beispiel 1} /> - Beispiel 2} /> - - - - } /> - - - , -); diff --git a/apps/showcase/src/ui-demos/simple-map-config.ts b/apps/showcase/src/ui-demos/simple-map-config.ts new file mode 100644 index 00000000..b57d1820 --- /dev/null +++ b/apps/showcase/src/ui-demos/simple-map-config.ts @@ -0,0 +1,41 @@ +import {map, mapView, mapViewCenter, mapViewExtent} from "@mapsight/ui/config"; +import {metaData, osm} from "@mapsight/ui/config/map/layers"; +import createDefaultPlugins from "@mapsight/ui/plugins/browser-defaults"; +import type {CreateOptions} from "@mapsight/ui/types"; + +export const baseMapsightConfig = { + ...map( + { + osm: osm( + "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + true, + metaData( + "OSM", + '© OpenStreetMap-Mitwirkende-Mitwirkende', + true, + false, + true, + "Karte", + ), + ), + }, + mapView( + mapViewCenter(1171479, 6848253), + mapViewExtent(1097392, 6789091, 1240635, 6895797), + 14, + 10, + 18, + ), + ), +}; + +export const createOptions: CreateOptions = { + plugins: createDefaultPlugins(), + uiState: { + map: { + show: true, + }, + }, +}; + +export const noopStyleFunction = () => []; diff --git a/apps/demo-vite/src/vector-styles/demo.scss b/apps/showcase/src/vector-styles/demo.scss similarity index 100% rename from apps/demo-vite/src/vector-styles/demo.scss rename to apps/showcase/src/vector-styles/demo.scss diff --git a/apps/showcase/src/vector-styles/icon-demo.scss b/apps/showcase/src/vector-styles/icon-demo.scss new file mode 100644 index 00000000..aca73133 --- /dev/null +++ b/apps/showcase/src/vector-styles/icon-demo.scss @@ -0,0 +1,9 @@ +@use "@mapsight/traffic-style/mapsight-traffic-style-icon-sprite-2x" as sprite; + +@use "@mapsight/traffic-style/src/scss/variables" as variables with ( + $MAPSIGHT_TRAFFIC_STYLE__IMAGE_PATH: "/img/", + $MAPSIGHT_TRAFFIC_STYLE__SPRITE_PATH: "/img/mapsight-traffic-style-icon-sprite-2x.png?v=2023-08-07-16-00", + $MAPSIGHT_TRAFFIC_STYLE__ICONS: sprite.$mapsight-traffic-style-icon-sprite-2x, +); + +@use "@mapsight/traffic-style/src/scss/features/base"; diff --git a/apps/demo-vite/tsconfig.json b/apps/showcase/tsconfig.json similarity index 78% rename from apps/demo-vite/tsconfig.json rename to apps/showcase/tsconfig.json index e42fb59e..f8a15dc1 100644 --- a/apps/demo-vite/tsconfig.json +++ b/apps/showcase/tsconfig.json @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "../../configs/tsconfig-base-app.json", "include": ["src/**/*"], + "references": [{"path": "./tsconfig.node.json"}], "compilerOptions": { "types": ["@types/node"] } diff --git a/apps/showcase/tsconfig.node.json b/apps/showcase/tsconfig.node.json new file mode 100644 index 00000000..50f90c8c --- /dev/null +++ b/apps/showcase/tsconfig.node.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../configs/tsconfig-vite-node.json", + "include": ["vite.config.ts", "../../configs/vite-workspace-dev.mts"] +} diff --git a/apps/demo-vite/vite.config.ts b/apps/showcase/vite.config.ts similarity index 61% rename from apps/demo-vite/vite.config.ts rename to apps/showcase/vite.config.ts index bbd65b80..9dcd05e6 100644 --- a/apps/demo-vite/vite.config.ts +++ b/apps/showcase/vite.config.ts @@ -3,21 +3,38 @@ import {join} from "node:path"; import tailwindcss from "@tailwindcss/vite"; import {defineConfig} from "vite"; -export default defineConfig({ +import { + createWorkspaceDevAliases, + workspacePackages, +} from "../../configs/vite-workspace-dev.mts"; + +export default defineConfig(({command}) => ({ root: "src", publicDir: "../public", + resolve: { + alias: [ + ...(command === "serve" ? createWorkspaceDevAliases() : []), + { + find: /~(.+)/, + replacement: join(process.cwd(), "node_modules/$1"), + }, + ], + }, + optimizeDeps: { + exclude: [ + ...workspacePackages, + "@mapsight/traffic-style/runtime", + "@mapsight/traffic-style/runtime-dev", + "@mapsight/traffic-style/icon-style", + ], + entries: ["**/*.html"], + }, build: { outDir: "../dist", emptyOutDir: true, license: true, rolldownOptions: { - input: [ - "index.html", - "custom.html", - "simple-map.html", - "router/index.html", - "full/index.html", - ], + input: ["index.html"], output: { codeSplitting: { groups: [ @@ -38,13 +55,10 @@ export default defineConfig({ }, }, }, - resolve: { - alias: [ - { - find: /~(.+)/, - replacement: join(process.cwd(), "node_modules/$1"), - }, - ], + server: { + watch: { + ignored: ["**/node_modules/**", "!**/node_modules/@mapsight/**"], + }, }, plugins: [tailwindcss()], css: { @@ -56,4 +70,4 @@ export default defineConfig({ }, }, }, -}); +})); diff --git a/apps/vector-editor/package.json b/apps/vector-editor/package.json index 0618dcb3..6010141e 100644 --- a/apps/vector-editor/package.json +++ b/apps/vector-editor/package.json @@ -35,7 +35,7 @@ "autoprefixer": "^10.5.0", "postcss-import": "^16.1.1", "postcss-nested": "^7.0.2", - "vite": "^8.0.13" + "vite": "^8.0.14" }, "license": "UNLICENSED", "repository": { diff --git a/configs/eslint-config-base.mts b/configs/eslint-config-base.mts index 9af7b058..6659f016 100644 --- a/configs/eslint-config-base.mts +++ b/configs/eslint-config-base.mts @@ -32,6 +32,8 @@ export default defineConfig([ "eslint.config.mts", "gulpfile.mts", "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", "prettier.config.mjs", "postcss.config.mts", ], diff --git a/configs/tsconfig-vite-node.json b/configs/tsconfig-vite-node.json new file mode 100644 index 00000000..a50f1c7a --- /dev/null +++ b/configs/tsconfig-vite-node.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig-base.json", + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "lib": ["ES2024"] + } +} diff --git a/configs/vite-workspace-dev.mts b/configs/vite-workspace-dev.mts new file mode 100644 index 00000000..876ccd5c --- /dev/null +++ b/configs/vite-workspace-dev.mts @@ -0,0 +1,50 @@ +import path from "node:path"; +import {fileURLToPath} from "node:url"; + +export type WorkspaceDevAlias = { + find: string | RegExp; + replacement: string; +}; + +const configDir = path.dirname(fileURLToPath(import.meta.url)); +export const repoRoot = path.resolve(configDir, ".."); + +/** Workspace packages resolved from source during Vite dev (serve). */ +export const workspacePackages = [ + "@mapsight/core", + "@mapsight/ui", + "@mapsight/lib-js", + "@mapsight/lib-ol", + "@mapsight/lib-redux", +] as const; + +const packageSourceRoots = { + "@mapsight/core": path.join(repoRoot, "packages/core/src/js"), + "@mapsight/ui": path.join(repoRoot, "packages/ui/src/js"), + "@mapsight/lib-js": path.join(repoRoot, "packages/lib-js/src/js"), + "@mapsight/lib-ol": path.join(repoRoot, "packages/lib-ol/src/js"), + "@mapsight/lib-redux": path.join(repoRoot, "packages/lib-redux/src/js"), +} as const; + +/** + * Resolve workspace packages from TypeScript source in dev. + * + * - Skips the tsc → dist roundtrip for HMR on package changes. + * - `@/` maps to core source (@mapsight/core's tsconfig paths). Only core uses + * it today; ui can adopt `@mapsight/ui/...` package-style imports internally + * instead of adding a second `@/` alias. + */ +export function createWorkspaceDevAliases(): WorkspaceDevAlias[] { + const coreSourceRoot = packageSourceRoots["@mapsight/core"]; + + return [ + { + find: /^@\/(.*)$/, + replacement: path.join(coreSourceRoot, "$1"), + }, + ...workspacePackages.map((name) => ({ + find: name, + replacement: packageSourceRoots[name], + })), + ]; +} diff --git a/docs/MAPSIGHT_ACTION_GUIDE.md b/docs/MAPSIGHT_ACTION_GUIDE.md new file mode 100644 index 00000000..4653952f --- /dev/null +++ b/docs/MAPSIGHT_ACTION_GUIDE.md @@ -0,0 +1,256 @@ +# Mapsight Action API — Decision Guide + +> **Context:** [MAPSIGHT_REDUX_ARCHITECTURE.md](./MAPSIGHT_REDUX_ARCHITECTURE.md) explains how the store, controllers, and path routing work. This guide answers a narrower question: _which action API do I dispatch for a given task?_ + +Mapsight has three coexisting action systems. Pick the one that matches your intent — not whichever import is closest. + +--- + +## Quick reference + +| I want to… | Use | +| --------------------------- | ---------------------------------------------------------- | +| Apply CMS / preset JSON | `mergeAll`, `set`, `resetMapsightCore` | +| Switch entire view/module | `resetMapsightCore` | +| Animate map, fit to feature | Domain actions in `@mapsight/core/lib/map/actions` | +| Load GeoJSON | `LOAD_FEATURE_SOURCE` family (`load`, …) | +| Toggle list/panel/layout | `@mapsight/ui/store/actions` (`app` slice) | +| OL reported pan/zoom | Already handled in controllers — don't dispatch from React | + +--- + +## 1. Apply CMS / preset JSON + +**Use:** path actions from `@mapsight/core/lib/base/actions`, and `resetMapsightCore` when you also need to clear selections. + +- `set(path, value)` — replace a single value (layer visibility, one field). +- `merge(path, value)` — shallow-merge an object at a path. +- `mergeAll({ map: …, featureSources: … })` — batch several top-level slices at once. +- `resetMapsightCore(config)` — deselect all features, then `mergeAll(config)` (see §2). + +Paths are arrays routed by the first segment (`["map", "layers", id, …]`). + +### Good + +```typescript +import {mergeAll, set} from "@mapsight/core/lib/base/actions"; + +// Bulk preset fragment: layers + sources in one batch +store.dispatch( + mergeAll({ + map: {layers: moduleLayers, view: {zoom: 12}}, + featureSources: moduleSources, + }), +); + +// Single field tweak +store.dispatch(set(["map", "layers", "poi-layer", "options", "visible"], true)); +``` + +### Anti-pattern + +```typescript +// ❌ Don't hand-roll action objects — you lose path routing and batching +store.dispatch({ + type: "MAPSIGHT_MERGE", + value: {visible: true}, + meta: {path: ["map", "layers", "poi-layer", "options"]}, // easy to get wrong +}); +``` + +--- + +## 2. Switch entire view / module + +**Use:** `resetMapsightCore` from `@mapsight/ui/store/actions`. + +Module switches replace most GIS slices and must not leave ghost layers or selections from the previous module. `resetMapsightCore` clears highlight/preselect/select, then deep-merges the new config tree. + +When only a partial update is safe (e.g. restoring localStorage), `mergeAll` alone is fine — but module switches are not that case. + +### Good + +```typescript +import { + resetMapsightCore, + setTagFilterControl, +} from "@mapsight/ui/store/actions"; + +// Stadtplan router: full GIS reset on module change, then UI-only follow-up +const {app, ...base} = getConfigForCityMapModule(moduleId); +store.dispatch(resetMapsightCore(base)); +store.dispatch( + setTagFilterControl( + app.tagSwitcher.show, + "featureSources", + app.tagSwitcher.featureSourceId ?? "_", + ), +); +``` + +`base` should include a **complete** layer catalog for the target module. `mergeAll` deep-merges `map.layers`; omitted layer IDs survive from the previous module. + +### Anti-pattern + +```typescript +// ❌ mergeAll without clearing selections — features stay highlighted across modules +store.dispatch(mergeAll(getConfigForCityMapModule(moduleId))); + +// ❌ Replacing only map.layers — list, featureSources, filters can drift +store.dispatch(set(["map", "layers"], newLayers)); +``` + +--- + +## 3. Animate map, fit to feature + +**Use:** domain actions in `@mapsight/core/lib/map/actions` — `animate`, `fitMapViewToLayerFeature`, `fitMapViewToLayerSourceExtent`, `setLayerVisibility`, interaction helpers, etc. + +These run reducer/controller logic (animations, fit bounds, base-layer rules) that a raw `set(["map", "view", …])` bypasses. + +### Good + +```typescript +import {easeOut} from "ol/easing"; + +import { + animate, + fitMapViewToLayerFeature, +} from "@mapsight/core/lib/map/actions"; + +store.dispatch( + animate("map", { + duration: 1000, + easing: easeOut, + bounds: regionExtent, + nearest: true, + }), +); + +store.dispatch( + fitMapViewToLayerFeature("map", "poi-layer", featureId, {maxZoom: 16}), +); +``` + +### Anti-pattern + +```typescript +import {set} from "@mapsight/core/lib/base/actions"; + +// ❌ Writing view center/zoom directly — skips animation mixin and controlled sync +store.dispatch(set(["map", "view", "center"], clickedCoordinate)); +store.dispatch(set(["map", "view", "zoom"], 14)); +``` + +Use path `set` for **declarative config** (initial view in a preset). Use **domain actions** for **imperative map behavior** after the map is running. + +--- + +## 4. Load GeoJSON + +**Use:** the `LOAD_FEATURE_SOURCE` family from `@mapsight/core/lib/feature-sources/actions`. Prefer the `load()` thunk — it handles request IDs, cache, and success/error follow-ups. + +Related: `setData` for local/cached data, `addFeature` / `updateFeature` for edits (often wrapped in `controlled()`). + +### Good + +```typescript +import {load} from "@mapsight/core/lib/feature-sources/actions"; + +// XHR or local loader — controller picks strategy from featureSources[id].type +store.dispatch(load("featureSources", "pois", {forceRefresh: true})); +``` + +Feature source **config** (`type`, `url`, filters) still belongs in preset JSON via `mergeAll`. `load()` fetches or hydrates **data** into that config. + +### Anti-pattern + +```typescript +import {LOAD_FEATURE_SOURCE} from "@mapsight/core/lib/feature-sources/actions"; + +// ❌ Dispatching the raw constant — no loader, no cache, no requestId deduping +store.dispatch({ + type: LOAD_FEATURE_SOURCE, + id: "pois", + meta: {path: ["featureSources"]}, +}); +``` + +--- + +## 5. Toggle list / panel / layout + +**Use:** `@mapsight/ui/store/actions` — these update the `app` slice (`mapsightUiAppReducer`), not GIS controllers. + +Examples: `setListVisible`, `setView`, `setMapVisible`, `setListPage`, `fetchJson`, `setOverlayModalVisible`. + +**Phase 1 rule:** do not add new fields to `mapsightUiAppReducer` during the demo push. Prefer existing actions or local React state for new UI-only concerns. + +### Good + +```typescript +import {VIEW_MAP_ONLY} from "@mapsight/ui/config/constants/app"; +import {setListVisible, setView} from "@mapsight/ui/store/actions"; + +store.dispatch(setView(VIEW_MAP_ONLY)); +store.dispatch(setListVisible(false)); +``` + +### Anti-pattern + +```typescript +import {set} from "@mapsight/core/lib/base/actions"; + +// ❌ Layout flags don't live under map/list controllers +store.dispatch(set(["app", "listVisible"], false)); // bypasses app reducer cases + +// ❌ Don't put panel open/closed state into map JSON +store.dispatch(set(["map", "options", "listOpen"], true)); +``` + +--- + +## 6. OpenLayers pan / zoom feedback + +**Use:** nothing from application code. `WithMap` (and related mixins) listen to OL view events and dispatch `controlled(async(() => setViewCenter(…)))` back into Redux. + +User-driven pan/zoom must not re-enter through React components — that causes double updates, sync loops, and DevTools noise. + +### Good + +```typescript +// In a React component — read view state if needed, but don't write it on pan/zoom +const center = useSelector((state) => state.map?.view?.center); + +// To move the map intentionally, use domain actions (see §3) +store.dispatch(animate("map", {center: target, duration: 500})); +``` + +Controllers already mark high-frequency feedback with `quiet()` so DevTools stays usable. + +### Anti-pattern + +```typescript +import {setViewCenter} from "@mapsight/core/lib/map/actions"; + +// ❌ Never mirror OL view events from React / plugins +map.getView().on("change:center", () => { + store.dispatch(setViewCenter("map", map.getView().getCenter()!)); +}); + +// ❌ Don't use uncontrolled path sets for live view — fights the controller loop +store.dispatch(set(["map", "view", "center"], map.getView().getCenter())); +``` + +--- + +## Cheat sheet: imports + +| Package | Path | When | +| ---------------- | ----------------------------- | ------------------------------------------------- | +| `@mapsight/core` | `lib/base/actions` | `set`, `merge`, `mergeAll`, `controlled`, `quiet` | +| `@mapsight/core` | `lib/map/actions` | Animate, fit, layer visibility, interactions | +| `@mapsight/core` | `lib/feature-sources/actions` | `load`, feature CRUD, undo/redo | +| `@mapsight/ui` | `store/actions` | `resetMapsightCore`, layout, list UI, fetch cache | + +When in doubt: **serializable config → path actions; imperative GIS ops → domain actions; chrome/layout → UI actions.** diff --git a/docs/MAPSIGHT_REDUX_ARCHITECTURE.md b/docs/MAPSIGHT_REDUX_ARCHITECTURE.md new file mode 100644 index 00000000..169865dc --- /dev/null +++ b/docs/MAPSIGHT_REDUX_ARCHITECTURE.md @@ -0,0 +1,511 @@ +# Mapsight Redux Architecture + +> **What this is:** A reference for how Mapsight’s GIS state layer works today — not a migration plan. +> For stabilization and gradual modernization, see [MAPSIGHT_REDUX_IMPROVEMENT_PLAN.md](./MAPSIGHT_REDUX_IMPROVEMENT_PLAN.md). + +--- + +## TL;DR + +Mapsight is not “Redux with a map component.” It is a **declarative GIS runtime**: + +1. The entire map (layers, view, controls, interactions, feature data) is described as **serializable JSON** in a single Redux store. +2. **Controllers** watch that JSON and sync it to real OpenLayers objects (and back). +3. **Path-based actions** (`set`, `merge`, `mergeAll`) let you mutate any part of the tree — including bulk CMS-driven reconfiguration without a page reload. +4. A **controlled / uncontrolled** distinction prevents infinite loops when OpenLayers reports zoom/pan back into Redux. + +React is a thin view layer on top. The core is usable without React. + +--- + +## Mental model + +Think of Redux state as the **target document** (like a VDOM for the map). Controllers are **reconcilers** that diff state against OpenLayers and apply changes. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Redux Store │ +│ ┌─────────┐ ┌─────────┐ ┌────────────────┐ ┌───────────────┐ │ +│ │ map │ │ list │ │ featureSources │ │ featureSelect…│ │ +│ │ (JSON) │ │ (JSON) │ │ (JSON) │ │ (JSON) │ │ +│ └────┬────┘ └─────────┘ └───────┬────────┘ └───────────────┘ │ +│ │ │ │ +│ ┌────┴────┐ ┌──────┴──────┐ │ +│ │ app │ (UI slice) │ projections │ …other controllers │ +│ └─────────┘ └─────────────┘ │ +└───────────────────────────────┬─────────────────────────────────┘ + │ + observeUncontrolled (state → OL) + controlled + async (OL → state) + │ + ▼ + ┌───────────────────────┐ + │ MapController │ + │ (+ mixin modules) │ + │ ol-proxy │ + └───────────┬───────────┘ + │ + ▼ + ┌───────────────────────┐ + │ OpenLayers │ + │ Map, Layers, View, │ + │ Controls, Sources │ + └───────────────────────┘ +``` + +--- + +## Store creation + +Entry point: `createMapsightStore()` in `packages/core/src/js/index.ts`. + +```typescript +createMapsightStore( + controllers, // { map: MapController, list: ListController, … } + appReducers, // optional extra slices, e.g. { app: mapsightUiAppReducer } + preLoadedState, // initial JSON tree (from preset / CMS / SSR) + appEnhancer, // e.g. redux-thunk for UI +); +``` + +What happens: + +1. **One reducer per controller** — `combineReducers` keys match controller names (`map`, `list`, `featureSources`, …). +2. **Path filtering** — each controller reducer is wrapped with `createFilteredReducerForPath`, so `meta.path[0]` routes actions to the right slice. +3. **Store enhancements** (applied in order): + - `enableAsyncDispatch` — queue actions flagged `MAPSIGHT_ASYNC_ACTION` (dispatch-during-dispatch workaround). + - `enableControlledDispatchAndObserve` — adds `observeUncontrolled` / `subscribeUncontrolled`. + - `batchDispatchMiddleware` — `redux-batched-actions` support. + - `createPrefixedAsyncActionMiddleware` — thunk-like functions flagged as async. +4. **Controller binding** — each controller gets `bindToStore(store)` then `init()` (wires OL listeners). + +The UI package (`@mapsight/ui`) calls this via `create()` in `packages/ui/src/js/index.ts`, supplying default controllers and the `app` reducer. + +--- + +## State shape (top-level slices) + +Default controller keys (`packages/ui/src/js/config/constants/controllers.ts`): + +| Slice key | Controller | Role | +| ------------------- | ----------------------------- | ------------------------------------------------ | +| `map` | `MapController` | Layers, view, controls, interactions, animations | +| `list` | `ListController` | List/query state (mostly path-driven) | +| `featureSources` | `FeatureSourcesController` | GeoJSON data, XHR loading, undo/redo | +| `featureSelections` | `FeatureSelectionsController` | Select / preselect / highlight | +| `tagFilter` | `FilterController` | Tag-based feature filtering | +| `timeFilter` | `FilterController` | Time-based filtering | +| `projections` | `ProjectionsController` | Proj4 definitions | +| `userGeolocation` | `UserGeolocationController` | Geolocation tracking | +| `app` | `mapsightUiAppReducer` | Layout, modals, fetch cache, list UI prefs | + +> **“Dynamic slices” clarification:** You can register multiple instances of the same controller class (e.g. `map` and `map2`) at store creation time. What is dynamic is the **keys inside** each slice (`layers.foo`, `layers.bar`). You cannot add a new top-level slice after the store exists without `injectReducer` (not used today). + +### Example: `map` slice (simplified) + +```typescript +{ + view: { center: […], zoom: 12, resolution: …, rotation: 0 }, + size: [width, height], + layers: { + "base-osm": { + type: "OSM", + options: { visible: true }, + metaData: { title: "…", isBaseLayer: true, group: "base" } + }, + "poi-layer": { + type: "Vector", + options: { + visible: true, + source: { type: "VectorFeatureSource", options: { featureSourceId: "pois" } } + } + } + }, + controls: { … }, + interactions: { … }, + visibleLayers: ["poi-layer", …] +} +``` + +Layer definitions use the **ol-proxy `Description`** shape: `{ type, options?, metaData? }`. Types map to registered OpenLayers constructors in the dependency injector (`packages/core/src/js/ol-proxy`). + +--- + +## Controllers + +`BaseController` (`packages/core/src/js/lib/base/controller.ts`) is the foundation. + +Each controller: + +- Owns **one top-level slice** of state (selected by `state[controllerName]`). +- Exposes `dispatch`, `getState`, `observeUncontrolled`, `getAndObserveUncontrolled`. +- Implements `reduce(state, action)` → runs registered reducers, then **`baseReducer`** (generic path mutations). +- Runs **`init()`** after all controllers are bound — this is where OL sync is wired up. + +### MapController composition + +`MapController` is built from **mixins** copied onto its prototype at module load: + +| Mixin | Responsibility | +| ------------------------- | ----------------------------------------------- | +| `WithMap` | Core `ol/Map`, view sync (center/zoom/rotation) | +| `WithLayers` | Layer create/update/destroy via ol-proxy | +| `WithControls` | OL controls | +| `WithInteractions` | Draw, select, etc. | +| `WithAnimations` | `animate`, fit-to-extent | +| `WithVisibleLayers` | Derived visible layer list | +| `WithSize` | Map container dimensions | +| `WithStyleFunction` | Vector styling | +| `WithFeatureInteractions` | Feature click/hover wiring | +| … | | + +Each mixin’s `init()` registers `getAndObserveUncontrolled` handlers that diff state and call `updateProxyObject`. + +`registerReducer()` allows domain-specific reducer logic inside a controller (e.g. `WithLayers` enforces “only one base layer visible”). + +--- + +## Path-based actions + +Generic mutation API in `packages/core/src/js/lib/base/actions.ts`: + +| Function | Effect | +| --------------------------- | ------------------------------ | +| `set(path, value)` | Replace value at path | +| `merge(path, value)` | Shallow-merge object at path | +| `addTo(path, element)` | Append to array at path | +| `removeFrom(path, element)` | Remove from array at path | +| `unset(path)` | `set(path, undefined)` | +| `setAll` / `mergeAll` / … | Batch variant over object keys | + +Paths are arrays: `["map", "layers", "foo", "options", "visible"]`. + +Actions use generic types: `MAPSIGHT_SET`, `MAPSIGHT_MERGE`, etc. The path lives in `action.meta.path`. + +### Path routing + +`createFilteredReducerForPath(reducer, "map", "path")`: + +- If `action.meta.path[0] === "map"` → strip first segment, pass to reducer. +- Otherwise → ignore (return previous state). + +This lets one action type work across all slices without slice-specific action creators. + +### Immutability + +`baseReducer` delegates to `packages/lib-redux/reducers/immutable-path`, which: + +1. Reads old value at path (`lodash/get`). +2. Applies inner reducer (`set`, `merge`, …). +3. Writes back via `deepChangeState` (clones parent chain, no mutation). + +--- + +## Action flags (meta) + +| Flag | Constant | Purpose | +| ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------ | +| Controlled | `MAPSIGHT_CONTROLLED_ACTION` | “This update came from OL, don’t sync back to OL.” Skipped by `observeUncontrolled`. | +| Async | `MAPSIGHT_ASYNC_ACTION` | Queue dispatch to avoid dispatch-during-dispatch. Often wraps thunks that dispatch controlled actions. | +| Quiet | `MAPSIGHT_QUIET_ACTION` | Hide from Redux DevTools (high-frequency updates like zoom). | + +Helpers: `controlled(action)`, `async(action)`, `quiet(action)` — recursively applied inside batched actions. See [Batched actions caveat](#batched-actions-caveat-redux-batched-actions) below for why that recursion matters. + +--- + +## Controlled ↔ uncontrolled sync (the loop breaker) + +**Problem:** State drives OL, but OL events (pan, zoom) must update state. Without a guard, you get: state change → OL update → OL event → state change → … + +**Solution:** + +``` +User / CMS / React OpenLayers + │ │ + │ dispatch(set(…)) │ + │ (uncontrolled) │ + ├──────────────────────────────►│ observeUncontrolled handler + │ │ applies to OL + │ │ + │ │ view 'change:center' + │◄──────────────────────────────┤ + │ dispatch(controlled( │ + │ async(() => set(…)) │ + │ )) │ + │ (controlled — observers │ + │ do NOT fire) │ +``` + +Implementation: `enableControlledDispatchAndObserve` patches `store.dispatch` to remember if the last action was controlled, and only notifies `observeUncontrolled` listeners when it was not. + +**Typical OL → state path** (`WithMap.ts`): + +```typescript +view.on("change:center", () => { + this.dispatch( + async(() => { + this.dispatch(controlled(setViewCenter(name, view.getCenter()))); + }), + ); +}); +``` + +**Typical state → OL path** (`WithLayers.ts`): + +```typescript +this.getAndObserveUncontrolled( + (state) => state.layers, + (newDefs, oldDefs) => { + /* updateProxyObject per layer */ + }, +); +``` + +### Batched actions caveat (`redux-batched-actions`) + +Mapsight batches related dispatches with `batchActions()` from `redux-batched-actions`. The +`batchDispatchMiddleware` does **not** treat a batch as one atomic dispatch — it unwraps the +wrapper and calls `store.dispatch` once per child action. + +`enableControlledDispatchAndObserve` mirrors that: it sets a `wasControlled` flag on **each** +`dispatch` call, based only on whether **that** action carries +`meta.MAPSIGHT_CONTROLLED_ACTION`. After every dispatch (including each child inside a batch), +the store subscription runs and `observeUncontrolled` listeners fire when `wasControlled` is +false. + +**Implication:** marking only the outer batch wrapper as controlled is not enough. If children +are dispatched without the flag, observers treat them as uncontrolled and may sync state back +to OpenLayers — reintroducing the feedback loop the controlled flag exists to prevent. + +**What Mapsight does:** `controlled()` (and `quiet()`) recurse into batched payloads and apply +the flag to every child: + +```typescript +// packages/core/src/js/lib/base/actions.ts +export function controlled(action: Action) { + action.meta = action.meta || {}; + action.meta[CONTROLLED_ACTION_FLAG] = true; + + if (isBatchedAction(action)) { + action.payload = action.payload.map((batchedAction) => + controlled(batchedAction), + ); + } + + return action; +} +``` + +So `dispatch(controlled(batchActions([setA, setB])))` results in two child dispatches, each +individually flagged — observers stay quiet for the whole batch. + +**Contributor checklist:** + +| Do | Don't | +| -------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| Use `controlled(batchActions([…]))` or `controlled()` per child | Hand-roll `{ meta: { batch: true }, payload: […] }` without recursing | +| Copy the recurse pattern when adding new meta-flag helpers | Assume the outer batch wrapper propagates flags to children | +| Use `flattenActions()` when inspecting batch contents (DevTools, watchers) | Rely on the wrapper action alone for controlled / quiet semantics | + +See also: `packages/lib-redux/src/js/flatten-actions.ts` (deep flatten for nested batches). + +--- + +## ol-proxy + +`packages/core/src/js/ol-proxy/index.ts` bridges JSON definitions to OpenLayers instances. + +**`updateProxyObject({ oldObject, oldDefinition, newDefinition, adder, remover })`:** + +1. If `newDefinition` is null/undefined → call `remover` (destroy). +2. If type changed or options require reconstruction → create new OL object. +3. Otherwise → `setOptions` to patch existing object. + +The dependency injector (`di`) maps `type: "Vector"` → constructor, `optionMap`, `eventMap`. + +This is the same pattern React uses (declarative description → imperative DOM), but for OpenLayers. + +--- + +## Three action systems (know which to use) + +Mapsight has evolved three coexisting patterns: + +### 1. Path actions (preferred for declarative config) + +```typescript +import { set, merge, mergeAll } from "@mapsight/core/lib/base/actions"; + +mergeAll({ map: { layers: { … } }, featureSources: { … } }); +set(["map", "view", "zoom"], 14); +``` + +**Use for:** CMS JSON, presets, cross-slice bulk updates, anything that should be serializable. + +### 2. Domain actions (imperative GIS operations) + +```typescript +import {LOAD_FEATURE_SOURCE} from "@mapsight/core/lib/feature-sources/actions"; +import { + animate, + fitMapViewToLayerFeature, +} from "@mapsight/core/lib/map/actions"; +``` + +**Use for:** Operations with side effects, typed parameters, or reducer logic that does not fit a simple path set (feature loading, undo/redo, fit-to-feature). + +### 3. App UI actions (layout & chrome) + +```typescript +import {setListVisible, setView} from "@mapsight/ui/store/actions"; +``` + +**Use for:** Panel visibility, breakpoints, list pagination, JSON fetch cache. Lives in the `app` slice. _Candidates for eventual extraction out of GIS Redux._ + +--- + +## Declarative pages / MPA without reload + +The original design goal: load a JSON config and reset the entire application state. + +**Pattern:** + +```typescript +import {resetMapsightCore} from "@mapsight/ui/store/actions"; + +import {mergeAll} from "@mapsight/core/lib/base/actions"; + +// Full reset: clear selections + apply new config tree +store.dispatch(resetMapsightCore(newConfig)); + +// Partial reconfiguration (e.g. router module switch) +store.dispatch(mergeAll({map: moduleMapConfig, featureSources: moduleSources})); +``` + +Real example: Stadtplan router plugin dispatches `mergeAll(base)` on module change (`apps/example/src/js/presets/stadtplan.tsx`). + +**Initial state** is assembled in `create()`: + +```typescript +initialState = merge({}, baseMapsightConfig, {app: uiState}); +// optionally merged with reHydratedState from SSR +``` + +Plugins can further restore from localStorage via `mergeAll` after first render. + +--- + +## React integration + +Minimal by design: + +```tsx +// packages/ui/src/js/components/helping/app-context.tsx + + + +``` + +Components use standard `useSelector` / `useDispatch`. The heavy sync work happens in controllers, not in `useEffect` hooks. + +`create()` returns a `MapsightUiContext` with `store`, `render()`, plugins, and `controllers` — usable from non-React hosts (embed scripts, PHP pages). + +### SSR / hydration + +- Server renders with dehydrated state embedded in the DOM. +- Client `create()` merges `reHydratedState` into `initialState`. +- Plugins (e.g. localStorage) may defer `mergeAll` until after first render to avoid DOM mismatch. + +--- + +## Feature sources & selections (data layer) + +**FeatureSourcesController** is the most complex controller: + +- Loads GeoJSON via XHR (`LOAD_FEATURE_SOURCE` → `LOAD_FEATURE_SOURCE_SUCCESS`). +- Supports undo/redo history per source. +- Can bind external store selectors via `bindFeatureSourceToStore`. +- Mixes domain reducers with path-based `baseReducer`. + +**FeatureSelectionsController** manages select / preselect / highlight state, wired to list and map interactions. + +Layers reference feature data indirectly: + +```typescript +source: { + type: "VectorFeatureSource", + options: { featureSourceId: "pois", featureSourcesControllerName: "featureSources" } +} +``` + +--- + +## Plugins + +`@mapsight/ui` supports a plugin system with lifecycle hooks: + +| Phase | When | +| -------------- | ------------------------------------------------- | +| `afterInit` | Before store creation (can modify `initialState`) | +| `afterCreate` | After store exists (can subscribe, dispatch) | +| `beforeRender` | Can delay render (async data) | +| `afterRender` | Post-first-paint (e.g. localStorage restore) | + +Plugins receive `MapsightUiContext` and are the extension point for app-specific behavior without forking core. + +--- + +## Key files reference + +| Area | Path | +| ------------------- | --------------------------------------------------------------------- | +| Store factory | `packages/core/src/js/index.ts` | +| Path actions | `packages/core/src/js/lib/base/actions.ts` | +| Base reducer | `packages/core/src/js/lib/base/reducer.ts` | +| Base controller | `packages/core/src/js/lib/base/controller.ts` | +| Map controller | `packages/core/src/js/lib/map/controller.ts` | +| Map mixins | `packages/core/src/js/lib/map/lib/With*.ts` | +| ol-proxy | `packages/core/src/js/ol-proxy/index.ts` | +| Controlled observe | `packages/lib-redux/src/js/enable-controlled-dispatch-and-observe.ts` | +| Path filter | `packages/lib-redux/src/js/create-filtered-reducer-for-path.ts` | +| Immutable path | `packages/lib-redux/src/js/reducers/immutable-path/` | +| UI entry | `packages/ui/src/js/index.ts` | +| Default controllers | `packages/ui/src/js/controllers/defaults.ts` | +| App reducer | `packages/ui/src/js/store/reducers.ts` | +| Types (core) | `packages/core/src/js/types.ts` | +| Map state types | `packages/core/src/js/lib/map/types.ts` | + +--- + +## Glossary + +| Term | Meaning | +| ----------------------- | ----------------------------------------------------------------------- | +| **Controller** | Class owning one state slice + OL sync side effects | +| **Description** | JSON `{ type, options?, metaData? }` describing an OL object | +| **Path action** | Generic `MAPSIGHT_SET` etc. with `meta.path` | +| **Controlled action** | OL-originated update; observers skip it | +| **Uncontrolled action** | User/CMS/React update; observers apply to OL | +| **Preset** | App-specific function returning initial state JSON (e.g. `stadtplan()`) | +| **Mixin** | MapController capability module (`WithLayers`, …) | + +--- + +## What this architecture optimizes for + +✅ Serializable, CMS-driven map configuration +✅ Single store for time-travel debugging and SSR +✅ Framework-agnostic GIS core +✅ Multiple map/list instances via named controllers +✅ Bidirectional OL sync without feedback loops + +## What it does not optimize for + +❌ Strict TypeScript path safety (yet) +❌ Minimal Redux boilerplate +❌ Colocating UI state with React components +❌ Runtime-dynamic slice injection + +--- + +_Last updated: June 2026_ diff --git a/package.json b/package.json index f2cdca52..213ede39 100644 --- a/package.json +++ b/package.json @@ -10,25 +10,25 @@ "@types/node": "catalog:", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", - "eslint-import-resolver-typescript": "^4.4.4", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import-x": "^4.16.2", "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-n": "^18.0.1", + "eslint-plugin-n": "^18.1.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.6.0", "husky": "^9.1.7", "jiti": "^2.7.0", - "lint-staged": "^17.0.5", + "lint-staged": "^17.0.7", "mkdirp": "^3.0.1", "npm-run-all": "^4.1.5", "playwright": "catalog:", - "prettier": "^3.8.3", + "prettier": "^3.8.4", "rimraf": "^6.1.3", "syncpack": "^15.3.1", - "turbo": "^2.9.14", + "turbo": "^2.9.17", "typescript": "catalog:", - "typescript-eslint": "^8.59.4" + "typescript-eslint": "^8.61.0" }, "engines": { "node": "^24.15.0", diff --git a/packages/core/.npmignore b/packages/core/.npmignore index cf06fbb4..515699ab 100644 --- a/packages/core/.npmignore +++ b/packages/core/.npmignore @@ -1,4 +1,4 @@ -* - -!/dist/**/* -!/src/**/* +# Publish surface is package.json "files" (dist only). +# Exclude test output if it ever lands in dist. +**/__tests__/** +**/*.test.* diff --git a/packages/core/e2e/highlight.spec.ts b/packages/core/e2e/highlight.spec.ts new file mode 100644 index 00000000..ffda5110 --- /dev/null +++ b/packages/core/e2e/highlight.spec.ts @@ -0,0 +1,123 @@ +import {expect, test} from "@playwright/test"; + +const HIGHLIGHT_TEST_FEATURE_ID = "poi-1"; + +type ClientPoint = {x: number; y: number}; + +async function waitForHighlightTest(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.waitForFunction( + () => window.__mapsightHighlightTest?.ready === true, + ); + return page.evaluate(() => ({ + center: window.__mapsightHighlightTest!.centerClientPosition(), + empty: window.__mapsightHighlightTest!.emptyClientPosition(), + })); +} + +async function getHighlightFeatures(page: import("@playwright/test").Page) { + return page.evaluate(() => + window.__mapsightHighlightTest!.getHighlightFeatures(), + ); +} + +function lerp(a: number, b: number, t: number) { + return a + (b - a) * t; +} + +function lerpPoint(from: ClientPoint, to: ClientPoint, t: number): ClientPoint { + return { + x: lerp(from.x, to.x, t), + y: lerp(from.y, to.y, t), + }; +} + +/** Move along a polyline with many intermediate pointer events (Playwright mouse). */ +async function mouseMovePath( + page: import("@playwright/test").Page, + points: Array, + stepsPerSegment = 8, +) { + for (let i = 1; i < points.length; i++) { + const from = points[i - 1]!; + const to = points[i]!; + for (let step = 1; step <= stepsPerSegment; step++) { + const t = step / stepsPerSegment; + const point = lerpPoint(from, to, t); + await page.mouse.move(point.x, point.y); + } + } +} + +test.describe("highlight on mouseover (core e2e)", () => { + test("clears highlight in redux when the pointer leaves the feature (simple two-move)", async ({ + page, + }) => { + const {center} = await waitForHighlightTest(page); + + await page.mouse.move(center.x, center.y); + await expect + .poll(() => getHighlightFeatures(page)) + .toEqual([HIGHLIGHT_TEST_FEATURE_ID]); + + await page.mouse.move(center.x - 180, center.y - 180); + await expect.poll(() => getHighlightFeatures(page)).toEqual([]); + }); + + test("clears highlight after a second pointermove off the feature", async ({ + page, + }) => { + const {center, empty} = await waitForHighlightTest(page); + + await page.mouse.move(center.x, center.y); + await expect + .poll(() => getHighlightFeatures(page)) + .toEqual([HIGHLIGHT_TEST_FEATURE_ID]); + + // Two moves off the feature while still outside hit tolerance. + await page.mouse.move(empty.x, empty.y); + await page.mouse.move(empty.x + 2, empty.y + 2); + await page.waitForTimeout(25); + await expect.poll(() => getHighlightFeatures(page)).toEqual([]); + }); + + test("clears highlight after many incremental moves onto and off the feature", async ({ + page, + }) => { + const {center, empty} = await waitForHighlightTest(page); + + for (let run = 0; run < 30; run++) { + await mouseMovePath(page, [empty, center], 12); + await page.waitForTimeout(25); + expect(await getHighlightFeatures(page)).toEqual([ + HIGHLIGHT_TEST_FEATURE_ID, + ]); + + await mouseMovePath(page, [center, empty], 12); + await page.waitForTimeout(25); + expect(await getHighlightFeatures(page)).toEqual([]); + } + }); + + test("clears highlight after jittery moves around the feature edge", async ({ + page, + }) => { + const {center, empty} = await waitForHighlightTest(page); + + // Oscillate near the feature center (within/outside default hitTolerance). + const edgeOffsets = [ + -12, -8, -4, 0, 4, 8, 12, 8, 4, 0, -4, -8, -16, -24, + ]; + + for (let run = 0; run < 20; run++) { + for (const offset of edgeOffsets) { + await page.mouse.move(center.x + offset, center.y + offset); + await page.waitForTimeout(5); + } + + await page.mouse.move(empty.x, empty.y); + await page.waitForTimeout(25); + expect(await getHighlightFeatures(page)).toEqual([]); + } + }); +}); diff --git a/packages/core/e2e/index.html b/packages/core/e2e/index.html new file mode 100644 index 00000000..7ae75bcb --- /dev/null +++ b/packages/core/e2e/index.html @@ -0,0 +1,24 @@ + + + + + + Mapsight Core E2E + + + +
+ + + diff --git a/packages/core/e2e/main.ts b/packages/core/e2e/main.ts new file mode 100644 index 00000000..92566695 --- /dev/null +++ b/packages/core/e2e/main.ts @@ -0,0 +1,50 @@ +import "@/test/inject-default-ol-proxy"; + +import { + centerPixel, + createHighlightTestMap, + getHighlightFeatures, +} from "@/test/create-highlight-test-map"; + +const mountTarget = document.querySelector("#map"); +if (!mountTarget) { + throw new Error("Map target element #map was not found"); +} + +const {store, map} = createHighlightTestMap({ + stubHits: false, + mountTarget: mountTarget as HTMLDivElement, +}); + +map.renderSync(); + +declare global { + interface Window { + __mapsightHighlightTest?: { + ready: boolean; + getHighlightFeatures: () => string[]; + centerClientPosition: () => {x: number; y: number}; + emptyClientPosition: () => {x: number; y: number}; + }; + } +} + +function clientPositionForMapPixel(pixel: [number, number]) { + const element = map.getTargetElement(); + if (!element) { + throw new Error("Map target element is not set"); + } + const rect = element.getBoundingClientRect(); + return { + x: rect.left + pixel[0], + y: rect.top + pixel[1], + }; +} + +window.__mapsightHighlightTest = { + ready: true, + getHighlightFeatures: () => getHighlightFeatures(store), + centerClientPosition: () => clientPositionForMapPixel(centerPixel(map)), + /** Top-left corner — well outside the point feature and hit tolerance. */ + emptyClientPosition: () => clientPositionForMapPixel([8, 8]), +}; diff --git a/packages/core/e2e/vite.config.ts b/packages/core/e2e/vite.config.ts new file mode 100644 index 00000000..239d10ef --- /dev/null +++ b/packages/core/e2e/vite.config.ts @@ -0,0 +1,20 @@ +import path from "node:path"; +import {fileURLToPath} from "node:url"; + +import {defineConfig} from "vite"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root: dirname, + resolve: { + alias: { + "@": path.resolve(dirname, "../src/js"), + }, + }, + server: { + host: "127.0.0.1", + port: 5173, + strictPort: true, + }, +}); diff --git a/packages/core/eslint.config.mts b/packages/core/eslint.config.mts index e5c1ba03..84b83370 100644 --- a/packages/core/eslint.config.mts +++ b/packages/core/eslint.config.mts @@ -4,6 +4,14 @@ import baseConfig from "../../configs/eslint-config-base.mts"; export default defineConfig([ baseConfig, + { + ignores: [ + "e2e/**", + "playwright.config.ts", + "**/*.test.ts", + "src/js/test/**", + ], + }, { name: "todos", rules: { diff --git a/packages/core/package.json b/packages/core/package.json index 9405496b..41f480e0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,19 +11,25 @@ "eventemitter3": "^5.0.4", "lodash": "catalog:", "minimatch": "^10.2.5", - "redux-batched-actions": "catalog:" + "redux-batched-actions": "catalog:", + "zod": "catalog:" }, "devDependencies": { + "@playwright/test": "catalog:", "@redux-devtools/extension": "^3.3.0", "@types/geojson": "^7946.0.16", "@types/jsdom": "catalog:", "@types/lodash": "catalog:", "@types/node": "catalog:", + "canvas": "catalog:", "jsdom": "catalog:", "ol": "catalog:", + "playwright": "catalog:", "proj4": "catalog:", "tsc-alias": "^1.8.17", - "typescript": "catalog:" + "typescript": "catalog:", + "vite": "^8.0.14", + "vitest": "catalog:" }, "exports": { ".": { @@ -38,6 +44,18 @@ "types": "./dist/lib/*.d.ts", "import": "./dist/lib/*.js" }, + "./lib/*/*": { + "types": "./dist/lib/*/*.d.ts", + "import": "./dist/lib/*/*.js" + }, + "./lib/*/lib/*": { + "types": "./dist/lib/*/lib/*.d.ts", + "import": "./dist/lib/*/lib/*.js" + }, + "./lib/*/schema": { + "types": "./dist/lib/*/schema.d.ts", + "import": "./dist/lib/*/schema.js" + }, "./lib/helpers": { "types": "./dist/lib/helpers/index.d.ts", "import": "./dist/lib/helpers/index.js" @@ -57,8 +75,15 @@ "./env/ssr-simulated-browser": { "types": "./dist/env/ssr-simulated-browser.d.ts", "import": "./dist/env/ssr-simulated-browser.js" + }, + "./schema": { + "types": "./dist/schema/index.d.ts", + "import": "./dist/schema/index.js" } }, + "files": [ + "dist" + ], "license": "UNLICENSED", "peerDependencies": { "@redux-devtools/extension": "^3.3.0", @@ -90,10 +115,13 @@ "clean": "rimraf dist/*", "clean-build": "run-s clean build", "lint": "eslint", + "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "typecheck": "tsc --noEmit", "watch": "run-s build:modules watch:build", "watch:build": "run-p watch:build:modules watch:build:paths", - "watch:build:modules": "tsc --watch", + "watch:build:modules": "tsc --watch --preserveWatchOutput", "watch:build:paths": "tsc-alias --watch" } } diff --git a/packages/core/playwright.config.ts b/packages/core/playwright.config.ts new file mode 100644 index 00000000..80d486ab --- /dev/null +++ b/packages/core/playwright.config.ts @@ -0,0 +1,20 @@ +import {defineConfig} from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "list", + use: { + baseURL: "http://127.0.0.1:5173", + trace: "on-first-retry", + }, + webServer: { + command: + "node ./node_modules/vite/bin/vite.js --config e2e/vite.config.ts", + url: "http://127.0.0.1:5173", + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/packages/core/src/js/lib/base/controller.ts b/packages/core/src/js/lib/base/controller.ts index ac57035c..7d42c793 100644 --- a/packages/core/src/js/lib/base/controller.ts +++ b/packages/core/src/js/lib/base/controller.ts @@ -109,11 +109,13 @@ export class BaseController * * @param selector observe selector * @param handler observe handler + * @param compare optional custom equality function * @returns unsubscribe function */ getAndObserveUncontrolled( selector: Selector, handler: ObserveHandler, + compare?: (previousValue: TValue, newValue: TValue) => boolean, ) { const store = this.getStore(); if (!store) { @@ -127,6 +129,7 @@ export class BaseController const unsubscribe = store.observeUncontrolled( composedSelector, boundHandler as ObserveHandler, + compare ? (a, b) => compare(a as TValue, b as TValue) : undefined, ); boundHandler(composedSelector(store.getState())); return unsubscribe; diff --git a/packages/core/src/js/lib/feature-selections/schema.ts b/packages/core/src/js/lib/feature-selections/schema.ts new file mode 100644 index 00000000..f2fad371 --- /dev/null +++ b/packages/core/src/js/lib/feature-selections/schema.ts @@ -0,0 +1,7 @@ +import {z} from "zod"; + +export const featureSelectionsConfigSchema = z.record(z.string(), z.unknown()); + +export type FeatureSelectionsConfig = z.infer< + typeof featureSelectionsConfigSchema +>; diff --git a/packages/core/src/js/lib/feature-sources/__tests__/combined.test.ts b/packages/core/src/js/lib/feature-sources/__tests__/combined.test.ts new file mode 100644 index 00000000..4b783212 --- /dev/null +++ b/packages/core/src/js/lib/feature-sources/__tests__/combined.test.ts @@ -0,0 +1,192 @@ +import {describe, expect, it} from "vitest"; + +import { + combineFeatureSources, + createCombinedFeatureSourceSelector, +} from "@/lib/feature-sources/lib/combined"; + +describe("combineFeatureSources", () => { + it("merges features from member sources", () => { + const result = combineFeatureSources( + { + a: { + type: "xhr-json", + data: { + type: "FeatureCollection", + features: [ + { + id: "a1", + properties: {}, + type: "Feature", + geometry: { + type: "Point", + coordinates: [10, 10], + }, + }, + ], + }, + lastUpdate: null, + lastActionType: null, + }, + b: { + type: "xhr-json", + data: { + type: "FeatureCollection", + features: [ + { + id: "b1", + properties: {}, + type: "Feature", + geometry: { + type: "Point", + coordinates: [10, 10], + }, + }, + ], + }, + lastUpdate: null, + lastActionType: null, + }, + }, + ["a", "b"], + ); + + expect(result.features).toEqual([ + { + id: "a1", + properties: {}, + type: "Feature", + geometry: { + type: "Point", + coordinates: [10, 10], + }, + }, + { + id: "b1", + properties: {}, + type: "Feature", + geometry: { + type: "Point", + coordinates: [10, 10], + }, + }, + ]); + }); + + it("returns an empty collection for an empty member list", () => { + const result = combineFeatureSources({}, []); + + expect(result).toEqual({ + type: "FeatureCollection", + features: [], + }); + }); + + it("skips errored or empty member sources", () => { + const result = combineFeatureSources( + { + a: { + type: "xhr-json", + error: "failed", + data: { + type: "FeatureCollection", + features: [ + { + id: "a1", + properties: {}, + type: "Feature", + geometry: { + type: "Point", + coordinates: [10, 10], + }, + }, + ], + }, + lastUpdate: null, + lastActionType: null, + }, + b: { + type: "xhr-json", + data: null, + lastUpdate: null, + lastActionType: null, + }, + }, + ["a", "b"], + ); + + expect(result.features).toEqual([]); + }); +}); + +describe("createCombinedFeatureSourceSelector", () => { + it("reads member sources from the configured controller", () => { + const selector = createCombinedFeatureSourceSelector( + ["a", "b"], + "featureSources", + ); + const result = selector({ + featureSources: { + a: { + type: "xhr-json", + data: { + type: "FeatureCollection", + features: [ + { + id: "a1", + properties: {}, + type: "Feature", + geometry: { + type: "Point", + coordinates: [10, 10], + }, + }, + ], + }, + lastUpdate: null, + lastActionType: null, + }, + b: { + type: "xhr-json", + data: { + type: "FeatureCollection", + features: [ + { + id: "b1", + properties: {}, + type: "Feature", + geometry: { + type: "Point", + coordinates: [10, 10], + }, + }, + ], + }, + lastUpdate: null, + lastActionType: null, + }, + }, + }); + + expect(result.data.features).toEqual([ + { + id: "a1", + properties: {}, + type: "Feature", + geometry: { + type: "Point", + coordinates: [10, 10], + }, + }, + { + id: "b1", + properties: {}, + type: "Feature", + geometry: { + type: "Point", + coordinates: [10, 10], + }, + }, + ]); + }); +}); diff --git a/packages/core/src/js/lib/feature-sources/actions.ts b/packages/core/src/js/lib/feature-sources/actions.ts index 0a8bcfc6..168acce4 100644 --- a/packages/core/src/js/lib/feature-sources/actions.ts +++ b/packages/core/src/js/lib/feature-sources/actions.ts @@ -1,4 +1,5 @@ import {async, controlled, withPath} from "@/lib/base/actions"; +import * as combined from "@/lib/feature-sources/loaders/combined-loader"; import * as local from "@/lib/feature-sources/loaders/local-state-loader"; import type {LocalStateLoaderOptions} from "@/lib/feature-sources/loaders/local-state-loader"; import * as noop from "@/lib/feature-sources/loaders/noop-loader"; @@ -17,6 +18,8 @@ function getLoader(type: FeatureSourceType) { return local; case "xhr-json": return xhrJson; + case "combined": + return combined; default: return noop; } @@ -343,7 +346,13 @@ export const load = ( ); return Promise.all([ - loadWithCache(currentState, getState, id, options).then( + loadWithCache( + currentState, + getState, + id, + controllerName, + options, + ).then( function handleLoadResolved(data) { dispatch(loadSuccess(controllerName, id, data)); }, @@ -415,6 +424,7 @@ async function loadWithCache( state: FeatureSourceState, getState: () => unknown, id: string, + controllerName: string, options: LoadOptions = {}, ) { const { @@ -434,5 +444,10 @@ async function loadWithCache( return Promise.resolve(state.data); } - return getLoader(state.type).load(state, loaderOptions, id, getState); + return getLoader(state.type).load( + state, + {...loaderOptions, controllerName}, + id, + getState, + ); } diff --git a/packages/core/src/js/lib/feature-sources/controller.ts b/packages/core/src/js/lib/feature-sources/controller.ts index f1288060..ab4e1559 100644 --- a/packages/core/src/js/lib/feature-sources/controller.ts +++ b/packages/core/src/js/lib/feature-sources/controller.ts @@ -1,7 +1,10 @@ import isEqual from "lodash/isEqual"; import {ensureNonNullable} from "@mapsight/lib-js/nonNullable"; -import {observeState} from "@mapsight/lib-redux/observe-state"; +import { + getAndObserveState, + observeState, +} from "@mapsight/lib-redux/observe-state"; import {async} from "@/lib/base/actions"; import {BaseController} from "@/lib/base/controller"; @@ -26,6 +29,10 @@ import { PAUSE_FEATURE_SOURCE_REFRESH_UNTIL_NEXT_LOAD, setDataOrError, } from "@/lib/feature-sources/actions"; +import { + createCombinedFeatureSourceSelector, + getCombinedFeatureSourceBindings, +} from "@/lib/feature-sources/lib/combined"; import { nextDataHistory, redoChange, @@ -97,7 +104,106 @@ function reduceUncontrolledFeatureSourceChanges( const emptyFeaturesArray: Array = []; +type CombinedFeatureSourceBinding = { + featureSourceNames: string[]; + unsubscribe: () => void; +}; + export class FeatureSourcesController extends BaseController { + #combinedFeatureSourceBindings = new Map< + string, + CombinedFeatureSourceBinding + >(); + + override init() { + const store = this.getStore(); + if (!store) { + return; + } + + this.syncCombinedFeatureSourceBindings(); + + const controllerName = this.getName(); + observeState( + store, + (state) => + getCombinedFeatureSourceBindings( + state[controllerName] as FeatureSourcesState, + ), + () => this.syncCombinedFeatureSourceBindings(), + isEqual, + ); + } + + syncCombinedFeatureSourceBindings() { + const sources = this.getState() as FeatureSourcesState; + const activeIds = new Set(); + + for (const [id, source] of Object.entries(sources)) { + if (source.type !== "combined") { + continue; + } + + const featureSourceNames = source.featureSourceNames ?? []; + activeIds.add(id); + const existing = this.#combinedFeatureSourceBindings.get(id); + if ( + existing && + isEqual(existing.featureSourceNames, featureSourceNames) + ) { + continue; + } + + existing?.unsubscribe(); + this.#combinedFeatureSourceBindings.set(id, { + featureSourceNames, + unsubscribe: this.bindCombinedFeatureSource(id, source), + }); + } + + for (const [id, binding] of this.#combinedFeatureSourceBindings) { + if (!activeIds.has(id)) { + binding.unsubscribe(); + this.#combinedFeatureSourceBindings.delete(id); + } + } + } + + bindCombinedFeatureSource( + featureSourceId: string, + source: FeatureSourceState, + ) { + const controllerName = this.getName(); + const store = this.getStore(); + if (!store) { + console.error( + "Can't bind combined feature source: store is not set", + ); + return () => undefined; + } + + const selector = createCombinedFeatureSourceSelector( + source.featureSourceNames ?? [], + controllerName, + ); + + return getAndObserveState( + store, + selector, + ({data, error}) => { + this.dispatch( + async( + setDataOrError(controllerName, featureSourceId, { + data, + error, + }), + ), + ); + }, + isEqual, + ); + } + bindFeatureSourceToStore( featureSourceId: string, selector: (state: State) => FeatureSourceState, diff --git a/packages/core/src/js/lib/feature-sources/lib/combined.ts b/packages/core/src/js/lib/feature-sources/lib/combined.ts new file mode 100644 index 00000000..c164af54 --- /dev/null +++ b/packages/core/src/js/lib/feature-sources/lib/combined.ts @@ -0,0 +1,57 @@ +import type { + FeatureSourceData, + FeatureSourcesState, +} from "@/lib/feature-sources/types"; +import type {State} from "@/types"; + +/** Merges features from the named feature sources that have loaded data. */ +export function combineFeatureSources( + featureSourcesState: FeatureSourcesState | undefined, + featureSourceNames: string[], +): FeatureSourceData { + let combinedFeatures: FeatureSourceData["features"] = []; + + for (const id of featureSourceNames) { + const source = featureSourcesState?.[id]; + if (!source?.error && source?.data) { + combinedFeatures = [ + ...combinedFeatures, + ...(source.data.features ?? []), + ]; + } + } + + return { + type: "FeatureCollection", + features: combinedFeatures, + }; +} + +export function createCombinedFeatureSourceSelector( + featureSourceNames: string[], + featureSourcesControllerName: string, +) { + return function combinedFeatureSourceSelector(state: State) { + const featureSourcesState = state[featureSourcesControllerName] as + | FeatureSourcesState + | undefined; + + return { + error: null, + data: combineFeatureSources( + featureSourcesState, + featureSourceNames, + ) satisfies FeatureSourceData, + }; + }; +} + +export function getCombinedFeatureSourceBindings( + featureSources: FeatureSourcesState = {}, +) { + return Object.fromEntries( + Object.entries(featureSources) + .filter(([, source]) => source.type === "combined") + .map(([id, source]) => [id, source.featureSourceNames ?? []]), + ); +} diff --git a/packages/core/src/js/lib/feature-sources/loaders/combined-loader.ts b/packages/core/src/js/lib/feature-sources/loaders/combined-loader.ts new file mode 100644 index 00000000..65eb270b --- /dev/null +++ b/packages/core/src/js/lib/feature-sources/loaders/combined-loader.ts @@ -0,0 +1,28 @@ +import {combineFeatureSources} from "@/lib/feature-sources/lib/combined"; +import type { + FeatureSourceData, + FeatureSourceState, + FeatureSourcesState, +} from "@/lib/feature-sources/types"; +import type {State} from "@/types"; + +type CombinedLoaderOptions = { + controllerName?: string; +}; + +export function load( + state: FeatureSourceState, + options: CombinedLoaderOptions = {}, + _id: string, + getState: () => unknown, +): Promise { + const controllerName = options.controllerName ?? "featureSources"; + const featureSourcesState = (getState() as State)[controllerName] as + | FeatureSourcesState + | undefined; + const featureSourceNames = state.featureSourceNames ?? []; + + return Promise.resolve( + combineFeatureSources(featureSourcesState, featureSourceNames), + ); +} diff --git a/packages/core/src/js/lib/feature-sources/schema.ts b/packages/core/src/js/lib/feature-sources/schema.ts new file mode 100644 index 00000000..f4f607d2 --- /dev/null +++ b/packages/core/src/js/lib/feature-sources/schema.ts @@ -0,0 +1,28 @@ +import {z} from "zod"; + +export const featureSourceTypeSchema = z.enum([ + "local", + "xhr-json", + "noop", + "combined", +]); + +export const featureSourceConfigSchema = z + .object({ + type: featureSourceTypeSchema, + url: z.string().optional(), + featureSourceNames: z.array(z.string()).optional(), + filters: z.array(z.string()).optional(), + doRefresh: z.boolean().optional(), + timer: z.number().optional(), + enableHistory: z.boolean().optional(), + }) + .strict(); + +export const featureSourcesConfigSchema = z.record( + z.string(), + featureSourceConfigSchema, +); + +export type FeatureSourceConfig = z.infer; +export type FeatureSourcesConfig = z.infer; diff --git a/packages/core/src/js/lib/feature-sources/selectors.ts b/packages/core/src/js/lib/feature-sources/selectors.ts index d8781f64..b3a9d21c 100644 --- a/packages/core/src/js/lib/feature-sources/selectors.ts +++ b/packages/core/src/js/lib/feature-sources/selectors.ts @@ -1,6 +1,8 @@ import type {Selector} from "@reduxjs/toolkit"; import {createSelector} from "@reduxjs/toolkit"; +import shallowEqualRecords from "@mapsight/lib-js/object/shallowEqualRecords"; + import type { FeatureSourceData, FeatureSourceState, @@ -119,6 +121,8 @@ type FilteredFeatureSourceSelectorCache = { state?: FeatureSourceState; }; +const EMPTY_FILTERS: Record = {}; + export function createFilteredFeatureSourceSelector( featureSourcesControllerName: string, featureSourceId: string, @@ -128,7 +132,8 @@ export function createFilteredFeatureSourceSelector( featureSourcesControllerName, featureSourceId, ); - let filtersSelector: Selector> = () => ({}); + let filtersSelector: Selector> = () => + EMPTY_FILTERS; // internal state holding const cache: FilteredFeatureSourceSelectorCache = {}; @@ -175,7 +180,7 @@ export function createFilteredFeatureSourceSelector( } const filters = filtersSelector(state); - if (filters !== cache.filters) { + if (!shallowEqualRecords(filters, cache.filters)) { cache.filters = filters; hasChanged = true; } diff --git a/packages/core/src/js/lib/feature-sources/types.ts b/packages/core/src/js/lib/feature-sources/types.ts index 4625c3a0..f9e513f1 100644 --- a/packages/core/src/js/lib/feature-sources/types.ts +++ b/packages/core/src/js/lib/feature-sources/types.ts @@ -1,6 +1,9 @@ import type {FeatureSourceDataHistory} from "@/lib/feature-sources/lib/history"; +import type {FeatureSourceConfig} from "@/lib/feature-sources/schema"; import type {Feature, FeatureId} from "@/types"; +export type {FeatureSourceConfig}; + export type FeatureSourceData = { type?: "FeatureCollection"; features?: Array; @@ -8,22 +11,16 @@ export type FeatureSourceData = { export type FeatureSourceType = FeatureSourceState["type"]; -export interface FeatureSourceState { - type: "local" | "xhr-json" | "noop"; +export interface FeatureSourceState extends FeatureSourceConfig { data: FeatureSourceData | null; // TODO: replace the full scan with something smarter (eg bisecting a sorted list) ids?: Array; - filters?: Array; error?: string; - url?: string; lastUpdate: number | null; lastActionType: string | null; isLoading?: boolean; requestId?: number; - doRefresh?: boolean; refreshPaused?: boolean; - timer?: number; - enableHistory?: boolean; dataHistory?: FeatureSourceDataHistory; } diff --git a/packages/core/src/js/lib/filter/types.ts b/packages/core/src/js/lib/filter/types.ts index a9ed69ac..79e2f613 100644 --- a/packages/core/src/js/lib/filter/types.ts +++ b/packages/core/src/js/lib/filter/types.ts @@ -1,5 +1,6 @@ import type {Feature} from "@/types"; +/** Runtime filter slice state; shape depends on the filter implementation. */ export type FilterState = unknown; export type FiltersState = Record; diff --git a/packages/core/src/js/lib/helpers/index.ts b/packages/core/src/js/lib/helpers/index.ts index 6e8d878e..802da83c 100644 --- a/packages/core/src/js/lib/helpers/index.ts +++ b/packages/core/src/js/lib/helpers/index.ts @@ -1,3 +1,5 @@ +export {isDevelopment} from "./isDevelopment"; + export const hasGeolocationSupport = (() => { // eslint-disable-next-line n/no-unsupported-features/node-builtins if (typeof window === "undefined" || !("geolocation" in navigator)) { diff --git a/packages/core/src/js/lib/helpers/isDevelopment.ts b/packages/core/src/js/lib/helpers/isDevelopment.ts new file mode 100644 index 00000000..282e0acf --- /dev/null +++ b/packages/core/src/js/lib/helpers/isDevelopment.ts @@ -0,0 +1,12 @@ +export function isDevelopment(): boolean { + if (typeof process !== "undefined" && process.env.NODE_ENV) { + return process.env.NODE_ENV === "development"; + } + + if (typeof import.meta !== "undefined") { + const env = (import.meta as ImportMeta & {env?: {DEV?: boolean}}).env; + return env?.DEV === true; + } + + return false; +} diff --git a/packages/core/src/js/lib/helpers/schema.ts b/packages/core/src/js/lib/helpers/schema.ts new file mode 100644 index 00000000..5a7d40e8 --- /dev/null +++ b/packages/core/src/js/lib/helpers/schema.ts @@ -0,0 +1,28 @@ +import {z} from "zod"; + +/** Recursive serializable JSON value used in ol-proxy options. */ +export type OptionValue = + | string + | boolean + | number + | ReadonlyArray + | Array + | Options + | undefined + | null; + +export type Options = {[k: string]: OptionValue}; + +export const optionValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.boolean(), + z.number(), + z.null(), + z.undefined(), + z.array(optionValueSchema), + z.record(z.string(), optionValueSchema), + ]), +); + +export const optionsSchema = z.record(z.string(), optionValueSchema); diff --git a/packages/core/src/js/lib/list/schema.ts b/packages/core/src/js/lib/list/schema.ts new file mode 100644 index 00000000..9bc466b4 --- /dev/null +++ b/packages/core/src/js/lib/list/schema.ts @@ -0,0 +1,10 @@ +import {z} from "zod"; + +export const listConfigSchema = z.looseObject({ + featureSource: z.string().optional(), + visible: z.boolean().optional(), + featureSelectionHighlight: z.string().optional(), + featureSelectionSelect: z.string().optional(), +}); + +export type ListConfig = z.infer; diff --git a/packages/core/src/js/lib/list/types.ts b/packages/core/src/js/lib/list/types.ts index 87f897a8..7d745497 100644 --- a/packages/core/src/js/lib/list/types.ts +++ b/packages/core/src/js/lib/list/types.ts @@ -1,3 +1,3 @@ -export type ListState = { - featureSource?: string; -}; +import type {ListConfig} from "@/lib/list/schema"; + +export type ListState = ListConfig; diff --git a/packages/core/src/js/lib/map/__tests__/highlight-hover.test.ts b/packages/core/src/js/lib/map/__tests__/highlight-hover.test.ts new file mode 100644 index 00000000..0cc0a6e1 --- /dev/null +++ b/packages/core/src/js/lib/map/__tests__/highlight-hover.test.ts @@ -0,0 +1,104 @@ +import {describe, expect, it, vi} from "vitest"; + +import { + HIGHLIGHT_TEST_FEATURE_ID, + centerPixel, + createHighlightTestMap, + dispatchPointerMoveAtPixel, + getHighlightFeatures, +} from "@/test/create-highlight-test-map"; + +const HOVER_DISPATCH_DELAY_MS = 10; + +async function flushHoverDispatch() { + await vi.advanceTimersByTimeAsync(HOVER_DISPATCH_DELAY_MS + 5); +} + +describe("highlight on mouseover", () => { + it("selects and deselects highlight in redux when pointer leaves the feature", async () => { + vi.useFakeTimers(); + + let hitMode: "feature" | "empty" = "empty"; + const {store, map} = createHighlightTestMap({ + getHitMode: () => hitMode, + }); + const pixel = centerPixel(map); + + hitMode = "feature"; + dispatchPointerMoveAtPixel(map, pixel); + await flushHoverDispatch(); + expect(getHighlightFeatures(store)).toEqual([ + HIGHLIGHT_TEST_FEATURE_ID, + ]); + + hitMode = "empty"; + dispatchPointerMoveAtPixel(map, pixel); + await flushHoverDispatch(); + expect(getHighlightFeatures(store)).toEqual([]); + + vi.useRealTimers(); + }); + + it("clears highlight after a second pointermove off the feature", async () => { + vi.useFakeTimers(); + + let hitMode: "feature" | "empty" = "feature"; + const {store, map} = createHighlightTestMap({ + getHitMode: () => hitMode, + }); + const pixel = centerPixel(map); + const emptyPixel: [number, number] = [8, 8]; + + hitMode = "feature"; + dispatchPointerMoveAtPixel(map, pixel); + await flushHoverDispatch(); + expect(getHighlightFeatures(store)).toEqual([ + HIGHLIGHT_TEST_FEATURE_ID, + ]); + + hitMode = "empty"; + dispatchPointerMoveAtPixel(map, emptyPixel); + dispatchPointerMoveAtPixel(map, emptyPixel); + await flushHoverDispatch(); + expect(getHighlightFeatures(store)).toEqual([]); + + vi.useRealTimers(); + }); + + it("keeps highlight when pointer re-enters before a brief miss deselect would fire", async () => { + vi.useFakeTimers(); + + let hitMode: "feature" | "empty" = "feature"; + const {store, map} = createHighlightTestMap({ + getHitMode: () => hitMode, + }); + const pixel = centerPixel(map); + + // Hover feature — select scheduled for t+10ms. + dispatchPointerMoveAtPixel(map, pixel); + + // Brief miss (e.g. hit tolerance gap) — deselect scheduled for t+12ms. + hitMode = "empty"; + await vi.advanceTimersByTimeAsync(2); + dispatchPointerMoveAtPixel(map, pixel); + + // Back on feature before deselect runs — select scheduled for t+15ms. + hitMode = "feature"; + await vi.advanceTimersByTimeAsync(3); + dispatchPointerMoveAtPixel(map, pixel); + + // Re-entry select fires (earlier scheduled actions were cancelled). + await vi.advanceTimersByTimeAsync(10); + expect(getHighlightFeatures(store)).toEqual([ + HIGHLIGHT_TEST_FEATURE_ID, + ]); + + // Brief-miss deselect was cancelled when pointer re-entered the feature. + await vi.advanceTimersByTimeAsync(5); + expect(getHighlightFeatures(store)).toEqual([ + HIGHLIGHT_TEST_FEATURE_ID, + ]); + + vi.useRealTimers(); + }); +}); diff --git a/packages/core/src/js/lib/map/__tests__/schema.test.ts b/packages/core/src/js/lib/map/__tests__/schema.test.ts new file mode 100644 index 00000000..6df3433f --- /dev/null +++ b/packages/core/src/js/lib/map/__tests__/schema.test.ts @@ -0,0 +1,93 @@ +import {describe, expect, it} from "vitest"; + +import { + layerConfigSchema, + mapConfigSchema, + vectorFeatureSourceStateSchema, +} from "@/lib/map/schema"; + +describe("map config schema", () => { + it("parses a vector feature layer with structured source options", () => { + const result = layerConfigSchema.safeParse({ + type: "VectorLayer", + options: { + visible: true, + source: { + type: "VectorFeatureSource", + options: { + featureSourceId: "pois", + clusterFeatures: true, + clusterFeaturesOptions: {distance: 40}, + }, + }, + selections: { + mousedown: "select", + mouseover: "highlight", + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("parses tile and OSM source layers", () => { + expect( + layerConfigSchema.safeParse({ + type: "TileLayer", + options: { + source: { + type: "OsmSource", + options: {url: "https://tile.osm.org"}, + }, + }, + }).success, + ).toBe(true); + + expect( + layerConfigSchema.safeParse({ + type: "TileLayer", + options: { + source: { + type: "TileWMSSource", + options: {url: "/wms", params: {LAYERS: "foo"}}, + }, + }, + }).success, + ).toBe(true); + }); + + it("rejects VectorFeatureSource with wrong type literal", () => { + const result = vectorFeatureSourceStateSchema.safeParse({ + type: "VectorSource", + options: {}, + }); + expect(result.success).toBe(false); + }); + + it("parses stadtplan-style map config with cluster options", () => { + const result = mapConfigSchema.safeParse({ + layers: { + pois: { + type: "VectorLayer", + metaData: {title: "POIs", group: "Points"}, + options: { + source: { + type: "VectorFeatureSource", + options: { + featureSourceId: "pois", + keepFeaturesInViewOptions: { + padding: [20, 20, 20, 20], + }, + fitFeaturesInViewOptions: { + padding: [20, 20, 20, 20], + }, + clusterFeatures: true, + clusterFeaturesOptions: {distance: 40}, + }, + }, + }, + }, + }, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/core/src/js/lib/map/lib/WithFeatureInteractions.ts b/packages/core/src/js/lib/map/lib/WithFeatureInteractions.ts index 5a96d01b..0026d8a1 100644 --- a/packages/core/src/js/lib/map/lib/WithFeatureInteractions.ts +++ b/packages/core/src/js/lib/map/lib/WithFeatureInteractions.ts @@ -1,11 +1,13 @@ import type Feature from "ol/Feature"; import forEach from "lodash/forEach"; +import isEqual from "lodash/isEqual"; import type {MapBrowserEvent} from "ol"; import {batchActions} from "redux-batched-actions"; import {ensureNonNullable} from "@mapsight/lib-js/nonNullable"; +import {quiet} from "@/lib/base/actions"; import { deselect, select, @@ -15,14 +17,20 @@ import type {FeatureSelectionsState} from "@/lib/feature-selections/selectors"; import {getOlFeatureId} from "@/lib/helpers/ol"; import {typeSafeObjectKeys} from "@/lib/helpers/types"; import type {MapController} from "@/lib/map/controller"; -import type {InteractionName, MapState} from "@/lib/map/types"; +import type {MapState} from "@/lib/map/types"; import type {Action} from "@/types"; import {setMapCursor} from "../actions"; import {makeLayerSelectionSelector} from "../selectors"; import WithMap from "./WithMap"; +import { + type FeatureInteractionName, + FeatureInteractionNames, +} from "./featureInteractionNames"; import {getIdForLayer} from "./tagLayer"; +export {FeatureInteractionNames, type FeatureInteractionName}; + type MapEventEmitter = Pick< MapController, "getMap" | "getStore" | "getState" | "dispatch" | "getName" @@ -56,16 +64,37 @@ type AnyFeatureInteractionOptions = FeatureInteractionOptions & FeatureInteractionOverrides; export type FeatureInteraction = { - selection: InteractionName; + selection: FeatureInteractionName; options: AnyFeatureInteractionOptions; }; -export type FeatureInteractions = Record; +export type FeatureInteractions = Record< + FeatureInteractionName, + FeatureInteraction +>; const FEATURE_PROPERTY_NAME_SELECTABLE = "selectable"; // TODO: Keep default? const defaultFeatureSelectionsControllerName = "featureSelections"; +const HIGHLIGHT_SELECTION_ID = "highlight"; + +/** See {@link createHandler} — matches `enableAsyncDispatch` deferral timing elsewhere. */ +const FEATURE_INTERACTION_DISPATCH_DELAY_MS = 10; + +type PendingInteractionDispatch = { + actions: Array; + cursorAction: Action | null; +}; + +type HandleEventResult = { + cache: FeatureInteractionEventCache | null; + pendingDispatch: PendingInteractionDispatch | null; +}; + +function quietIfHighlight(action: Action, selectionId: string) { + return selectionId === HIGHLIGHT_SELECTION_ID ? quiet(action) : action; +} // TODO: Support button_s_? // See https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button @@ -141,9 +170,9 @@ const defaultFeatureInteractions = { function getSelectionId( layerId: string, - interactionName: InteractionName, + interactionName: FeatureInteractionName, state: MapState, -) { +): string | undefined { return makeLayerSelectionSelector(layerId, interactionName)(state); } @@ -211,33 +240,33 @@ type FeatureInteractionEventCache = { * @param [options.selectExclusively=true] set to false to keep uncontrolled selections * @param [options.removeUncontrolled=false] set to true to remove selections when they were not done through this interaction handler * @param [options.hitTolerance=5] pixel tolerance when determining hit features - * @param cache previous cache object controlled by parent + * @param cache hover state used to diff the next hit; see {@link createHandler} * @param event openlayers event object * - * @returns {FeatureInteractionEventCache|null} new cache object to be kept by parent + * @returns computed hover state and any redux actions to dispatch (may be deferred) */ function handleEvent( mapController: MapEventEmitter, featureSelectionsControllerName: string, - interactionName: InteractionName, + interactionName: FeatureInteractionName, options: FeatureInteractionOptions = {}, cache: FeatureInteractionEventCache | null = null, event: MapBrowserEvent, -): FeatureInteractionEventCache | null { +): HandleEventResult { if (!interactionName) { - return cache; + return {cache, pendingDispatch: null}; } // TODO: Move code accessing ._map to the controller? const map = mapController.getMap(); if (!map) { - return cache; + return {cache, pendingDispatch: null}; } // TODO: Allow interaction during animation for some event types? const view = map.getView(); if (!view || view.getAnimating() || view.getInteracting()) { - return cache; + return {cache, pendingDispatch: null}; } if (event.originalEvent) { @@ -245,7 +274,7 @@ function handleEvent( const appliedOptions = overrideOptionsForEvent(options, originalEvent); if (appliedOptions === false) { - return cache; + return {cache, pendingDispatch: null}; } } @@ -332,7 +361,10 @@ function handleEvent( if (shouldBeAdded) { hasAdded = true; actions.push( - selectAction(featureSelectionsControllerName, s, f), + quietIfHighlight( + selectAction(featureSelectionsControllerName, s, f), + s, + ), ); } }); @@ -345,7 +377,12 @@ function handleEvent( // Determine if the cached feature selection is invalid by comparing with new selection const shouldBeRemoved = newSelections[s] !== f; if (shouldBeRemoved) { - actions.push(deselect(featureSelectionsControllerName, s, f)); + actions.push( + quietIfHighlight( + deselect(featureSelectionsControllerName, s, f), + s, + ), + ); } }); } @@ -366,14 +403,17 @@ function handleEvent( ) .forEach((f) => actions.push( - deselect(featureSelectionsControllerName, s, f), + quietIfHighlight( + deselect(featureSelectionsControllerName, s, f), + s, + ), ), ); }); } if (!actions.length) { - return cache; + return {cache, pendingDispatch: null}; } let cursorAction: Action | null = null; @@ -383,26 +423,53 @@ function handleEvent( } if (!cache || newCursor !== cache.cursor) { - cursorAction = setMapCursor(mapController.getName(), newCursor); + cursorAction = quiet( + setMapCursor(mapController.getName(), newCursor), + ); } } - // TODO: Check and document why we delay using setTimeout! - setTimeout(function () { - if (cursorAction) { - mapController.dispatch(cursorAction); - } + return { + cache: {selections: newSelections, cursor: newCursor}, + pendingDispatch: {actions, cursorAction}, + }; +} - if (actions.length === 1) { - mapController.dispatch(ensureNonNullable(actions[0])); - } else { - mapController.dispatch(batchActions(actions)); - } - }, 10); +function dispatchInteractionActions( + mapController: MapEventEmitter, + pendingDispatch: PendingInteractionDispatch, +) { + if (pendingDispatch.cursorAction) { + mapController.dispatch(pendingDispatch.cursorAction); + } - return {selections: newSelections, cursor: newCursor}; + if (pendingDispatch.actions.length === 1) { + mapController.dispatch(ensureNonNullable(pendingDispatch.actions[0])); + } else { + mapController.dispatch(batchActions(pendingDispatch.actions)); + } } +/** + * Returns an OL event handler that maps pointer hits to feature-selection actions. + * + * **Deferred dispatch.** Selection actions are not dispatched synchronously inside the + * OL event handler. Each new event cancels any pending dispatch and schedules a fresh + * one after {@link FEATURE_INTERACTION_DISPATCH_DELAY_MS}. That serves two purposes: + * + * 1. **Hover coalescing** — rapid `pointermove` across hit-tolerance edges (see default + * `hitTolerance: 5`) would otherwise select/deselect on every pixel. A short window + * lets a brief miss be cancelled when the pointer re-enters the feature. + * 2. **Defer out of the OL stack** — dispatching immediately would run selection + * observers (style / `FeatureSelectionConnector`) during `pointermove`. This mirrors + * the motivation for `async()` elsewhere, but hover needs cancel-on-new-event, not + * the action queue that `enableAsyncDispatch` provides. + * + * **Cache update.** When a dispatch is pending, in-memory `cache` is not updated until + * the timeout fires. Otherwise a follow-up move off the feature can cancel the pending + * deselect while `cache` is already empty, leaving highlight stuck in redux (see + * `highlight-hover.test.ts` / `e2e/highlight.spec.ts`). + */ function createHandler( mapController: MapEventEmitter, featureSelectionsControllerName: string, @@ -413,8 +480,19 @@ function createHandler( } let cache: FeatureInteractionEventCache | null = null; + let pendingDispatchTimeout: ReturnType | null = null; + + const clearPendingDispatch = () => { + if (pendingDispatchTimeout !== null) { + clearTimeout(pendingDispatchTimeout); + pendingDispatchTimeout = null; + } + }; + return function interactionHandler(event: MapBrowserEvent) { - cache = handleEvent( + clearPendingDispatch(); + + const {cache: nextCache, pendingDispatch} = handleEvent( mapController, featureSelectionsControllerName, options.selection, @@ -422,6 +500,16 @@ function createHandler( cache, event, ); + + if (pendingDispatch) { + pendingDispatchTimeout = setTimeout(() => { + pendingDispatchTimeout = null; + cache = nextCache; + dispatchInteractionActions(mapController, pendingDispatch); + }, FEATURE_INTERACTION_DISPATCH_DELAY_MS); + } else { + cache = nextCache; + } }; } @@ -436,7 +524,7 @@ export default class WithFeatureInteractions extends WithMap { } const handlers: Record< - InteractionName, + FeatureInteractionName, ReturnType > = { mouseover: null, @@ -447,7 +535,7 @@ export default class WithFeatureInteractions extends WithMap { this.getAndObserveUncontrolled( (state) => ({ featureInteractions: state.featureInteractions as Record< - InteractionName, + FeatureInteractionName, FeatureInteraction >, featureSelectionsControllerName: @@ -477,6 +565,7 @@ export default class WithFeatureInteractions extends WithMap { ), }); }, + isEqual, ); map.on("pointermove", function onPointerMove(e) { diff --git a/packages/core/src/js/lib/map/lib/WithLayerOverlays.ts b/packages/core/src/js/lib/map/lib/WithLayerOverlays.ts index a785c43c..4c1c44c8 100644 --- a/packages/core/src/js/lib/map/lib/WithLayerOverlays.ts +++ b/packages/core/src/js/lib/map/lib/WithLayerOverlays.ts @@ -32,26 +32,28 @@ export default class WithLayerOverlays extends WithMap { const updateLayerOverlay = ( id: string, - newDefinition: LayerDefinition, - oldDefinitions: LayerDefinition, + newDefinition: LayerDefinition | undefined, + oldDefinitions: Record, ) => { const oldDefinition = oldDefinitions[id]; - newDefinition = newDefinition && { + const overlayDefinition = newDefinition && { zIndex: Z_INDEX_OVERLAY, ...newDefinition, }; // update overlay // TODO: make overlay optional? - const featureSelections = newDefinition?.options?.selections; + const featureSelections = overlayDefinition?.options?.selections; updateProxyObject({ di: di, oldObject: this._overlays[id], - oldDefinition: {...oldDefinition, type: LAYER_TYPE}, - newDefinition: featureSelections && { - ...newDefinition, - type: LAYER_TYPE, - }, + oldDefinition: oldDefinition + ? {...oldDefinition, type: LAYER_TYPE} + : undefined, + newDefinition: + featureSelections && overlayDefinition + ? {...overlayDefinition, type: LAYER_TYPE} + : undefined, remover: () => { this._overlays[id]?.setMap(null); delete this._overlays[id]; diff --git a/packages/core/src/js/lib/map/lib/WithLayers.ts b/packages/core/src/js/lib/map/lib/WithLayers.ts index 2137c72f..74ff3f7c 100644 --- a/packages/core/src/js/lib/map/lib/WithLayers.ts +++ b/packages/core/src/js/lib/map/lib/WithLayers.ts @@ -9,7 +9,8 @@ import matchesPath from "@mapsight/lib-redux/matchesPath"; import reducers from "@mapsight/lib-redux/reducers/immutable-path"; import type {MapController} from "@/lib/map/controller"; -import {di, updateProxyObject} from "@/ol-proxy/index"; +import type {LayerState} from "@/lib/map/types"; +import {di, updateProxyObject} from "@/ol-proxy"; import {ACTION_SET} from "../../base/reducer"; import { @@ -20,8 +21,7 @@ import WithAnimations from "./WithAnimations"; import proxyPassOpenLayersEventsToMapController from "./proxyPassOpenLayersEventsToMapController"; import {getGroupForLayer, tagLayer} from "./tagLayer"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type LayerDefinition = any; // TODO: Implement types for definitions in redux and ol-proxy +export type LayerDefinition = LayerState; export const LAYER_GROUP_DEFAULT = "default"; @@ -62,8 +62,8 @@ export default class WithLayers extends WithAnimations { const updateLayer = ( id: string, - newDefinition: LayerDefinition, - oldDefinitions: LayerDefinition, + newDefinition: LayerDefinition | undefined, + oldDefinitions: Record, ) => { const oldDefinition = oldDefinitions[id]; @@ -72,7 +72,7 @@ export default class WithLayers extends WithAnimations { di: di, oldObject: this._layers[id], oldDefinition: oldDefinition, - newDefinition: newDefinition, + newDefinition, remover: (oldObject) => { const group = getGroupForLayer(oldObject) || LAYER_GROUP_DEFAULT; @@ -83,12 +83,26 @@ export default class WithLayers extends WithAnimations { delete this._groups[group]?.layers[id]; }, adder: (layer) => { - const group = newDefinition.group || LAYER_GROUP_DEFAULT; + if (!newDefinition) { + return; + } + + const group = + (typeof newDefinition.group === "string" + ? newDefinition.group + : newDefinition.metaData?.group) || + LAYER_GROUP_DEFAULT; const layerGroup = this.getOrCreateLayerGroup(group); this._layers[id] = layer; ensureNonNullable(this._groups[group]).layers[id] = layer; tagLayer(layer, this, id, group); layerGroup.getLayers().push(layer); + if (!newDefinition.type) { + console.error( + `Could not wire layer events for "${id}". Layer definition is missing type.`, + ); + return; + } proxyPassOpenLayersEventsToMapController( this as unknown as MapController, layer, diff --git a/packages/core/src/js/lib/map/lib/WithMap.ts b/packages/core/src/js/lib/map/lib/WithMap.ts index b6f43cb5..5ccd0d7e 100644 --- a/packages/core/src/js/lib/map/lib/WithMap.ts +++ b/packages/core/src/js/lib/map/lib/WithMap.ts @@ -8,7 +8,7 @@ import {di, setOptions, updateProxyObject} from "@/ol-proxy"; import Map from "@/ol-proxy/definitions/Map"; import View from "@/ol-proxy/definitions/View"; -import {async, controlled} from "../../base/actions"; +import {async, controlled, quiet} from "../../base/actions"; import {BaseController} from "../../base/controller"; import { setViewCenter, @@ -81,11 +81,13 @@ export default class WithMap extends BaseController { const resolution = view?.getResolution(); if (zoom && resolution) { this.dispatch( - controlled( - setViewZoomAndResolution( - name, - zoom, - resolution, + quiet( + controlled( + setViewZoomAndResolution( + name, + zoom, + resolution, + ), ), ), ); @@ -100,8 +102,10 @@ export default class WithMap extends BaseController { const center = view?.getCenter(); if (center) { this.dispatch( - controlled( - setViewCenter(name, center), + quiet( + controlled( + setViewCenter(name, center), + ), ), ); } @@ -115,8 +119,13 @@ export default class WithMap extends BaseController { const rotation = view?.getRotation(); if (rotation) { this.dispatch( - controlled( - setViewRotation(name, rotation), + quiet( + controlled( + setViewRotation( + name, + rotation, + ), + ), ), ); } @@ -158,7 +167,6 @@ export default class WithMap extends BaseController { ); this._map = map; - console.log("added map", this); //updateSizeOnTransitionEnd(map); //canvasSizeFixer(map); diff --git a/packages/core/src/js/lib/map/lib/WithSize.ts b/packages/core/src/js/lib/map/lib/WithSize.ts index 68b83e01..0866dd00 100644 --- a/packages/core/src/js/lib/map/lib/WithSize.ts +++ b/packages/core/src/js/lib/map/lib/WithSize.ts @@ -1,4 +1,4 @@ -import {async, controlled, set} from "../../base/actions"; +import {async, controlled, quiet, set} from "../../base/actions"; import WithMap from "./WithMap"; export default class WithSize extends WithMap { @@ -19,7 +19,9 @@ export default class WithSize extends WithMap { oldValue[0] !== newValue[0] || oldValue[1] !== newValue[1]) ) { - this.dispatch(controlled(async(set([name, "size"], newValue)))); + this.dispatch( + quiet(controlled(async(set([name, "size"], newValue)))), + ); } }); } diff --git a/packages/core/src/js/lib/map/lib/featureInteractionNames.ts b/packages/core/src/js/lib/map/lib/featureInteractionNames.ts new file mode 100644 index 00000000..b67528e1 --- /dev/null +++ b/packages/core/src/js/lib/map/lib/featureInteractionNames.ts @@ -0,0 +1,7 @@ +export const FeatureInteractionNames = [ + "mousedown", + "mouseover", + "touch", +] as const; + +export type FeatureInteractionName = (typeof FeatureInteractionNames)[number]; diff --git a/packages/core/src/js/lib/map/schema.ts b/packages/core/src/js/lib/map/schema.ts new file mode 100644 index 00000000..9a4fbb4d --- /dev/null +++ b/packages/core/src/js/lib/map/schema.ts @@ -0,0 +1,124 @@ +import {z} from "zod"; + +import {type Options, optionsSchema} from "@/lib/helpers/schema"; +import {FeatureInteractionNames} from "@/lib/map/lib/featureInteractionNames"; + +export const interactionsSelectionsSchema = z.partialRecord( + z.enum(FeatureInteractionNames), + z.string(), +); + +export const layerMetaDataSchema = z.looseObject({ + title: z.string().optional(), + group: z.string().optional(), + isBaseLayer: z.boolean().optional(), + attribution: z.string().optional(), + legend: z.string().optional(), + miniLegend: z.string().optional(), + lockedInLayerSwitcher: z.boolean().optional(), + visibleInLayerSwitcher: z.boolean().optional(), + visibleInExternalLayerSwitcher: z.boolean().optional(), +}); + +export const vectorFeatureSourceOptionsSchema = z.looseObject({ + featureSourceId: z.string().optional(), + featureSourcesControllerName: z.string().optional(), + featureSelectionsControllerName: z.string().optional(), + keepFeaturesInViewOptions: optionsSchema.optional(), + fitFeaturesInViewOptions: optionsSchema.optional(), + clusterFeatures: z.boolean().optional(), + clusterFeaturesOptions: optionsSchema.optional(), +}); + +export const vectorFeatureSourceStateSchema = z.looseObject({ + type: z.literal("VectorFeatureSource"), + options: vectorFeatureSourceOptionsSchema, +}); + +export const osmSourceStateSchema = z.looseObject({ + type: z.literal("OsmSource"), + options: z.looseObject({url: z.string().optional()}), +}); + +export const tileWmsSourceStateSchema = z.looseObject({ + type: z.literal("TileWMSSource"), + options: z.looseObject({ + projection: z.string().optional(), + url: z.string().optional(), + params: optionsSchema.optional(), + }), +}); + +/** Known ol-proxy source shapes plus a fallback for other source types. */ +export const layerSourceStateSchema = z.union([ + vectorFeatureSourceStateSchema, + osmSourceStateSchema, + tileWmsSourceStateSchema, + z.looseObject({ + type: z.string(), + options: optionsSchema.optional(), + }), +]); + +export const layerOptionsSchema = z.looseObject({ + visible: z.boolean().optional(), + selections: interactionsSelectionsSchema.optional(), + source: layerSourceStateSchema.optional(), +}); + +export const descriptionSchema = z.looseObject({ + type: z.string(), + options: layerOptionsSchema.optional(), + metaData: layerMetaDataSchema.optional(), +}); + +export const layerConfigSchema = descriptionSchema; + +export const mapConfigSchema = z + .looseObject({ + layers: z.record(z.string(), layerConfigSchema).optional(), + size: z.tuple([z.number(), z.number()]).optional(), + view: optionsSchema.optional(), + controls: optionsSchema.optional(), + interactions: optionsSchema.optional(), + visibleLayers: z.array(z.string()).optional(), + viewportAnchor: optionsSchema.optional(), + }) + .partial(); + +/** Loose-object schema output plus ol-proxy option bag. */ +type LooseOptions = z.infer & Options; + +export type LayerMetaData = z.infer; +export type InteractionsSelections = z.infer< + typeof interactionsSelectionsSchema +>; +export type VectorFeatureSourceOptions = LooseOptions< + typeof vectorFeatureSourceOptionsSchema +>; +export type VectorFeatureSourceState = z.infer< + typeof vectorFeatureSourceStateSchema +> & { + options: VectorFeatureSourceOptions; +}; +export type LayerSourceState = z.infer; +export type LayerOptions = { + visible?: boolean; + selections?: InteractionsSelections; + source?: LayerSourceState; +} & Omit; + +type WithLayerOptions = Omit & { + options?: LayerOptions; +}; + +export type Description = WithLayerOptions< + Omit, "type" | "metaData"> +> & { + type: string; + metaData?: LayerMetaData; +}; +export type LayerConfig = Description; +export type MapConfig = Omit, "layers"> & { + layers?: Record; +}; diff --git a/packages/core/src/js/lib/map/selectors.ts b/packages/core/src/js/lib/map/selectors.ts index 9be49c9a..ed188bca 100644 --- a/packages/core/src/js/lib/map/selectors.ts +++ b/packages/core/src/js/lib/map/selectors.ts @@ -9,7 +9,9 @@ import type { FeatureSourceState, FeatureSourcesState, } from "@/lib/feature-sources/types"; -import type {InteractionName, LayerState, MapState} from "@/lib/map/types"; +import type {FeatureInteractionName} from "@/lib/map/lib/WithFeatureInteractions"; +import type {LayerState, MapState} from "@/lib/map/types"; +import {isVectorFeatureSource} from "@/lib/map/types"; import type {State} from "@/types"; /** @@ -24,7 +26,8 @@ export const makeLayerLockedInLayerSwitcherSelector = state.layers[id]?.metaData?.lockedInLayerSwitcher ?? false; export const makeLayerSelectionSelector = - (id: string, interactionName: InteractionName) => (state: MapState) => + (id: string, interactionName: FeatureInteractionName) => + (state: MapState) => state.layers[id]?.options?.selections?.[interactionName]; export const layerIdsIntegratedSwitcherSelector: Selector = @@ -57,13 +60,22 @@ export const makeLayerVisibleSelector = state.layers[layerId]?.options?.visible ?? false; export const makeFeatureSourceIdFromLayerIdSelector = - (layerId: string) => (state: MapState) => - state.layers[layerId]?.options?.source?.options?.featureSourceId; + (layerId: string) => (state: MapState) => { + const source = state.layers[layerId]?.options?.source; + if (!isVectorFeatureSource(source)) { + return undefined; + } + return source.options.featureSourceId; + }; export const makeFeatureSourceControllerNameFromLayerIdSelector = - (layerId: string) => (state: MapState) => - state.layers[layerId]?.options?.source?.options - ?.featureSourcesControllerName; + (layerId: string) => (state: MapState) => { + const source = state.layers[layerId]?.options?.source; + if (!isVectorFeatureSource(source)) { + return undefined; + } + return source.options.featureSourcesControllerName; + }; /** * create selector for featureState corresponding to the featureSource used by a layer @@ -185,11 +197,15 @@ export const reduceToLayersWithMiniLegends = ( return Object.fromEntries(entries); }; -export const layersWithLegendsSelector: Selector = - createSelector(layersSelector, reduceToLayersWithLegends); +export const layersWithLegendsSelector: Selector< + MapState, + Record +> = createSelector(layersSelector, reduceToLayersWithLegends); -export const layersWithMiniLegendsSelector: Selector = - createSelector(layersSelector, reduceToLayersWithMiniLegends); +export const layersWithMiniLegendsSelector: Selector< + MapState, + Record +> = createSelector(layersSelector, reduceToLayersWithMiniLegends); export const visibleLayersWithLegendsSelector: Selector< MapState, diff --git a/packages/core/src/js/lib/map/types.ts b/packages/core/src/js/lib/map/types.ts index 29cbda19..2c67e920 100644 --- a/packages/core/src/js/lib/map/types.ts +++ b/packages/core/src/js/lib/map/types.ts @@ -1,67 +1,60 @@ import type OlLayer from "ol/layer/Layer"; +import type {OptionValue, Options} from "@/lib/helpers/schema"; import type {VectorFeatureSource} from "@/lib/map/lib/VectorFeatureSource"; import type {ViewportAnchor} from "@/lib/map/lib/WithAnchoredViewport"; +import { + type Description, + type InteractionsSelections, + type LayerConfig, + type LayerMetaData, + type LayerOptions, + type LayerSourceState, + type VectorFeatureSourceOptions, + type VectorFeatureSourceState, + descriptionSchema, + vectorFeatureSourceStateSchema, +} from "@/lib/map/schema"; -// TODO: find a better name? "TargetState"? -/** Describes the target state of an ol object (comparable to one entry in the vdom of react) */ -export type Description = { - type: string; - options?: Options; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - metaData?: any; // TODO: type the metadata +export type { + Description, + InteractionsSelections, + LayerMetaData, + LayerOptions, + LayerSourceState, + OptionValue, + Options, + VectorFeatureSourceOptions, + VectorFeatureSourceState, }; export const isDescription = (value: unknown): value is Description => - typeof value === "object" && - value !== null && - "type" in value && - typeof value.type === "string"; + descriptionSchema.safeParse(value).success; -export type Options = {[k: string]: OptionValue}; +/** Runtime layer options; ol-proxy keys beyond the config schema remain valid. */ +export type RuntimeLayerOptions = LayerOptions; -// Needs to be serializable -export type OptionValue = - | string - | boolean - | number - | Array - | Options - | undefined - | null; - -export interface LayerMetaData { - title?: string; - group?: string; - isBaseLayer?: boolean; - attribution?: string; - legend?: string; - miniLegend?: string; - lockedInLayerSwitcher?: boolean; - visibleInLayerSwitcher?: boolean; - visibleInExternalLayerSwitcher?: boolean; -} - -export interface LayerState { - [key: string]: unknown; +/** Runtime layer state; `type` is required in config ingress (`LayerConfig`). */ +export type LayerState = Omit & { + type?: string; metaData?: LayerMetaData; - options?: { - visible?: boolean; - selections?: InteractionsSelections; - source?: LayerSourceState; - }; -} + options?: RuntimeLayerOptions; +} & Record; -type VectorFeatureSourceState = { - type: "VectorFeatureSource"; - options: { - featureSourceId?: string; - featureSourcesControllerName?: string; - featureSelectionsControllerName?: string; - }; -}; +export const isVectorFeatureSource = ( + source: unknown, +): source is VectorFeatureSourceState => + vectorFeatureSourceStateSchema.safeParse(source).success; -export type LayerSourceState = VectorFeatureSourceState; // TODO: add other types +export function getVectorFeatureSource( + layer: LayerState, +): VectorFeatureSourceState { + const source = layer.options?.source; + if (!isVectorFeatureSource(source)) { + throw new Error("Expected layer with VectorFeatureSource source"); + } + return source; +} export interface MapState { [key: string]: unknown; @@ -78,7 +71,3 @@ export type VectorFeatureSourceLayer = OlLayer; export interface LayerStyleProps {} export type LayerStyleState = string | LayerStyleProps; - -export type InteractionName = "mousedown" | "mouseover" | "touch"; - -export type InteractionsSelections = Record; diff --git a/packages/core/src/js/lib/projections/schema.ts b/packages/core/src/js/lib/projections/schema.ts new file mode 100644 index 00000000..e751f95c --- /dev/null +++ b/packages/core/src/js/lib/projections/schema.ts @@ -0,0 +1,5 @@ +import {z} from "zod"; + +export const projectionsConfigSchema = z.array(z.string()); + +export type ProjectionsConfig = z.infer; diff --git a/packages/core/src/js/lib/user-geolocation/schema.ts b/packages/core/src/js/lib/user-geolocation/schema.ts new file mode 100644 index 00000000..8d546f97 --- /dev/null +++ b/packages/core/src/js/lib/user-geolocation/schema.ts @@ -0,0 +1,5 @@ +import {z} from "zod"; + +export const userGeolocationConfigSchema = z.unknown(); + +export type UserGeolocationConfig = z.infer; diff --git a/packages/core/src/js/mixins/EditorMixin.ts b/packages/core/src/js/mixins/EditorMixin.ts index 35b34cdf..54b1a2e9 100644 --- a/packages/core/src/js/mixins/EditorMixin.ts +++ b/packages/core/src/js/mixins/EditorMixin.ts @@ -35,7 +35,7 @@ export const DEFAULT_SELECTIONS = { mousedown: "select", mouseover: "highlight", touch: "select", -}; +} satisfies InteractionsSelections; export const DEFAULT_CONTROLLER_NAMES = { map: "map", @@ -171,9 +171,7 @@ export default class EditorMixin extends Mixin { setInteractionSelections( ensureNonNullable(this.controllers.map), ensureNonNullable(this.ids.layer), - (status - ? this.getSelections() - : {}) as InteractionsSelections, + status ? this.getSelections() : {}, ), set: (options: Partial = {}) => { const { diff --git a/packages/core/src/js/ol-proxy/index.ts b/packages/core/src/js/ol-proxy/index.ts index 22554515..b8db1791 100644 --- a/packages/core/src/js/ol-proxy/index.ts +++ b/packages/core/src/js/ol-proxy/index.ts @@ -1,13 +1,44 @@ import isEqual from "lodash/isEqual"; -import type {Description, OptionValue, Options} from "@/lib/map/types"; +import {isDevelopment} from "@/lib/helpers/isDevelopment"; +import type { + Description, + LayerState, + OptionValue, + Options, +} from "@/lib/map/types"; import {isDescription} from "@/lib/map/types"; import DependencyManager from "./DependencyManager"; +function isMapsightDebugEnabled(): boolean { + if (typeof process !== "undefined" && process.env.MAPSIGHT_DEBUG) { + const flag = process.env.MAPSIGHT_DEBUG; + return flag !== "0" && flag !== "false"; + } + + if (typeof import.meta !== "undefined") { + const flag = ( + import.meta as ImportMeta & {env?: {MAPSIGHT_DEBUG?: string}} + ).env?.MAPSIGHT_DEBUG; + return flag != null && flag !== "" && flag !== "0" && flag !== "false"; + } + + return false; +} + +function olProxyDebug(...args: Parameters) { + if (isDevelopment() || isMapsightDebugEnabled()) { + console.debug(...args); + } +} + export {isDescription} from "@/lib/map/types"; export type {Description} from "@/lib/map/types"; +/** Config ingress or runtime layer definition accepted by ol-proxy. */ +export type LayerProxyDefinition = Description | LayerState; + // TODO: use symbols? export const OPTION_SET = "__set__"; export const OPTION_COLLECTION = "__collection__"; @@ -337,7 +368,7 @@ function hasSomeOptionChanged( const hasChanged = !isEqual(newOptions[key], oldOptions[key]); if (hasChanged) { - console.debug("ol-proxy: v UPDATE triggered by", key); + olProxyDebug("ol-proxy: v UPDATE triggered by", key); } return hasChanged; @@ -395,8 +426,8 @@ export function updateProxyObject< di: DependencyManager; // TODO: remove "old" prefix, there's only one object, no "old" nor "new" oldObject?: TObject; - oldDefinition?: Description; - newDefinition: Description; + oldDefinition?: LayerProxyDefinition; + newDefinition?: LayerProxyDefinition; remover?: (object: TObject, parentObject?: TParentObject) => void; adder?: (object: TObject, parentObject?: TParentObject) => void; parentObject?: TParentObject; @@ -450,7 +481,7 @@ export function updateProxyObject< adder, parentObject, ); - console.debug( + olProxyDebug( "ol-proxy: CREATE", creationReason === "new" ? "new" @@ -482,7 +513,7 @@ export function updateProxyObject< ))) && object ) { - console.debug("ol-proxy: UPDATE", type); + olProxyDebug("ol-proxy: UPDATE", type); setOptions( object, oldOptions, diff --git a/packages/core/src/js/schema/__tests__/create-config-schema.test.ts b/packages/core/src/js/schema/__tests__/create-config-schema.test.ts new file mode 100644 index 00000000..cc07fad7 --- /dev/null +++ b/packages/core/src/js/schema/__tests__/create-config-schema.test.ts @@ -0,0 +1,125 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; +import {z} from "zod"; + +import {featureSourcesConfigSchema} from "@/lib/feature-sources/schema"; +import {listConfigSchema} from "@/lib/list/schema"; +import {layerConfigSchema} from "@/lib/map/schema"; +import { + createMapsightConfigSchema, + formatZodError, + validateConfig, +} from "@/schema"; + +const exampleConfigSchema = createMapsightConfigSchema({ + map: z.object({layers: z.record(z.string(), layerConfigSchema)}).partial(), + list: listConfigSchema, + featureSources: featureSourcesConfigSchema, +}); + +describe("createMapsightConfigSchema", () => { + it("parses a valid minimal preset", () => { + const result = exampleConfigSchema.safeParse({ + map: {layers: {x: {type: "OSM"}}}, + }); + expect(result.success).toBe(true); + }); + + it("fails when a layer is missing type", () => { + const result = exampleConfigSchema.safeParse({ + map: {layers: {x: {options: {}}}}, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(formatZodError(result.error)).toContain("type"); + } + }); + + it("parses feature source config without runtime fields", () => { + const result = exampleConfigSchema.safeParse({ + featureSources: { + pois: {type: "xhr-json", url: "/data/pois.json"}, + }, + }); + expect(result.success).toBe(true); + }); + + it("parses list config fields used by featureList()", () => { + const result = exampleConfigSchema.safeParse({ + list: { + featureSource: "pois", + visible: false, + featureSelectionHighlight: "highlight", + featureSelectionSelect: "select", + }, + }); + expect(result.success).toBe(true); + }); + + it("allows unknown top-level slices via catchall", () => { + const result = exampleConfigSchema.safeParse({ + map2: {layers: {x: {type: "OSM"}}}, + app: {view: "mobile"}, + }); + expect(result.success).toBe(true); + }); +}); + +describe("validateConfig", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("returns parsed data on success", () => { + const config = {map: {layers: {base: {type: "OSM"}}}}; + expect(validateConfig(exampleConfigSchema, config)).toEqual(config); + }); + + it("warns in development on invalid config", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("process", { + ...process, + env: {...process.env, NODE_ENV: "development"}, + }); + + const invalid = {map: {layers: {x: {options: {}}}}}; + const result = validateConfig(exampleConfigSchema, invalid, { + context: "test", + }); + + expect(warn).toHaveBeenCalledWith( + "[mapsight] Config validation failed:", + expect.stringContaining("[test]"), + ); + expect(result).toBe(invalid); + }); + + it("throws in production when strict is true", () => { + vi.stubGlobal("process", { + ...process, + env: {...process.env, NODE_ENV: "production"}, + }); + + expect(() => + validateConfig( + exampleConfigSchema, + {map: {layers: {x: {options: {}}}}}, + {strict: true}, + ), + ).toThrow(/type/); + }); + + it("passes through invalid config silently in production", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("process", { + ...process, + env: {...process.env, NODE_ENV: "production"}, + }); + + const invalid = {map: {layers: {x: {options: {}}}}}; + const result = validateConfig(exampleConfigSchema, invalid); + + expect(warn).not.toHaveBeenCalled(); + expect(result).toBe(invalid); + }); +}); diff --git a/packages/core/src/js/schema/create-config-schema.ts b/packages/core/src/js/schema/create-config-schema.ts new file mode 100644 index 00000000..ac94ad20 --- /dev/null +++ b/packages/core/src/js/schema/create-config-schema.ts @@ -0,0 +1,11 @@ +import {z} from "zod"; + +/** + * Compose a top-level config schema from per-slice schemas. + * Slice keys match controller names registered at store creation time. + */ +export function createMapsightConfigSchema< + TSliceSchemas extends Record, +>(sliceSchemas: TSliceSchemas) { + return z.object(sliceSchemas).partial().catchall(z.unknown()); +} diff --git a/packages/core/src/js/schema/index.ts b/packages/core/src/js/schema/index.ts new file mode 100644 index 00000000..511e0b3c --- /dev/null +++ b/packages/core/src/js/schema/index.ts @@ -0,0 +1,2 @@ +export {createMapsightConfigSchema} from "./create-config-schema"; +export {formatZodError, validateConfig} from "./validate-config"; diff --git a/packages/core/src/js/schema/validate-config.ts b/packages/core/src/js/schema/validate-config.ts new file mode 100644 index 00000000..78ae6477 --- /dev/null +++ b/packages/core/src/js/schema/validate-config.ts @@ -0,0 +1,33 @@ +import type {ZodError, ZodType, z} from "zod"; + +import {isDevelopment} from "@/lib/helpers/isDevelopment"; + +export function formatZodError(error: ZodError, context?: string): string { + const prefix = context ? `[${context}] ` : ""; + const messages = error.issues.map((issue) => { + const path = + issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)"; + return `${path}: ${issue.message}`; + }); + return `${prefix}${messages.join("; ")}`; +} + +export function validateConfig( + schema: T, + config: unknown, + options?: {strict?: boolean; context?: string}, +): z.infer { + const result = schema.safeParse(config); + + if (!result.success) { + const msg = formatZodError(result.error, options?.context); + if (isDevelopment()) { + console.warn("[mapsight] Config validation failed:", msg); + } else if (options?.strict) { + throw new Error(msg); + } + return config as z.infer; + } + + return result.data; +} diff --git a/packages/core/src/js/test/create-highlight-test-map.ts b/packages/core/src/js/test/create-highlight-test-map.ts new file mode 100644 index 00000000..ba734fba --- /dev/null +++ b/packages/core/src/js/test/create-highlight-test-map.ts @@ -0,0 +1,268 @@ +import Feature from "ol/Feature"; +import type OlMap from "ol/Map"; +import MapBrowserEvent from "ol/MapBrowserEvent"; +import Point from "ol/geom/Point"; +import type BaseLayer from "ol/layer/Base"; +import CircleStyle from "ol/style/Circle"; +import Fill from "ol/style/Fill"; +import Style from "ol/style/Style"; + +import {createMapsightStore} from "@/index"; +import {FeatureSelectionsController} from "@/lib/feature-selections/controller"; +import {setData} from "@/lib/feature-sources/actions"; +import {FeatureSourcesController} from "@/lib/feature-sources/controller"; +import {MapController} from "@/lib/map/controller"; +import {getIdForLayer} from "@/lib/map/lib/tagLayer"; +import {ProjectionsController} from "@/lib/projections/controller"; +import type {EnhancedStore} from "@/types"; + +export const HIGHLIGHT_TEST_FEATURE_ID = "poi-1"; +export const HIGHLIGHT_TEST_COORD = [0, 0] as [number, number]; +export const HIGHLIGHT_TEST_MAP_SIZE: [number, number] = [400, 400]; + +export type HighlightTestMapContext = { + store: EnhancedStore; + mapController: MapController; + map: OlMap; + feature: Feature; + layer: BaseLayer; + target: HTMLDivElement; +}; + +type HitMode = "feature" | "empty"; + +export type CreateHighlightTestMapOptions = { + /** Stub OL hit detection (fast, deterministic). Default: true in vitest. */ + stubHits?: boolean; + getHitMode?: () => HitMode; + /** Mount target; creates and appends a div when omitted. */ + mountTarget?: HTMLElement; +}; + +/** + * Minimal mapsight runtime for hover/highlight integration tests. + * In Node/vitest, hit detection is stubbed by default — use Playwright for real OL hits. + */ +export function createHighlightTestMap( + options: CreateHighlightTestMapOptions = {}, +): HighlightTestMapContext { + const { + stubHits = typeof process !== "undefined" && + process.env.VITEST === "true", + getHitMode = () => "empty", + mountTarget, + } = options; + + const mapController = new MapController("map"); + mapController.setStyleFunction(() => [ + new Style({ + image: new CircleStyle({ + radius: 8, + fill: new Fill({color: "red"}), + }), + }), + ]); + + const controllers = { + projections: new ProjectionsController("projections"), + map: mapController, + featureSources: new FeatureSourcesController("featureSources"), + featureSelections: new FeatureSelectionsController("featureSelections"), + }; + + const initialState = { + map: { + view: { + center: HIGHLIGHT_TEST_COORD, + zoom: 10, + minZoom: 0, + maxZoom: 20, + }, + size: HIGHLIGHT_TEST_MAP_SIZE, + layers: { + pois: { + type: "VectorLayer", + options: { + visible: true, + style: "features", + source: { + type: "VectorFeatureSource", + options: { + featureSourceId: "pois", + featureSourcesControllerName: "featureSources", + featureSelectionsControllerName: + "featureSelections", + canCluster: false, + canAnimate: false, + }, + }, + selections: { + mouseover: "highlight", + }, + }, + }, + }, + featureInteractions: { + mouseover: { + selection: "mouseover", + options: { + main: true, + auxiliary: false, + secondary: false, + fourth: false, + fifth: false, + cursor: "pointer", + deselectUncontrolled: null, + hitTolerance: 5, + }, + }, + mousedown: { + selection: "mousedown", + options: { + main: true, + auxiliary: false, + secondary: false, + fourth: false, + fifth: false, + deselectUncontrolled: null, + hitTolerance: 5, + }, + }, + touch: { + selection: "touch", + options: { + deselectUncontrolled: null, + hitTolerance: 5, + }, + }, + }, + }, + featureSelections: { + highlight: {max: 1, features: []}, + }, + featureSources: { + pois: { + type: "local", + data: null, + lastUpdate: null, + lastActionType: null, + }, + }, + }; + + const store = createMapsightStore(controllers, {}, initialState); + + const target = mountTarget ?? document.createElement("div"); + if (!mountTarget) { + target.style.width = `${HIGHLIGHT_TEST_MAP_SIZE[0]}px`; + target.style.height = `${HIGHLIGHT_TEST_MAP_SIZE[1]}px`; + document.body.appendChild(target); + } + mapController.mount(target); + + const map = mapController.getMap(); + if (!map) { + throw new Error("Map was not created"); + } + + map.setSize(HIGHLIGHT_TEST_MAP_SIZE); + + const layer = findLayerById(map, "pois"); + + let feature: Feature; + if (stubHits) { + feature = new Feature({ + geometry: new Point(HIGHLIGHT_TEST_COORD), + }); + feature.setId(HIGHLIGHT_TEST_FEATURE_ID); + stubFeatureHitDetection(map, layer, feature, getHitMode); + } else { + store.dispatch( + setData("featureSources", "pois", { + type: "FeatureCollection", + features: [ + { + id: HIGHLIGHT_TEST_FEATURE_ID, + type: "Feature", + properties: {}, + geometry: { + type: "Point", + coordinates: HIGHLIGHT_TEST_COORD, + }, + }, + ], + }), + ); + map.renderSync(); + const source = ( + layer as {getSource: () => {getFeatures: () => Array}} + ).getSource(); + const loadedFeature = source + .getFeatures() + .find((f) => String(f.getId()) === HIGHLIGHT_TEST_FEATURE_ID); + if (!loadedFeature) { + throw new Error( + "Test feature was not loaded into the vector source", + ); + } + feature = loadedFeature; + } + + return {store, mapController, map, feature, layer, target}; +} + +export function centerPixel(map: OlMap): [number, number] { + const size = map.getSize(); + if (!size) { + throw new Error("Map size is not set"); + } + return [size[0] / 2, size[1] / 2]; +} + +export function dispatchPointerMoveAtPixel( + map: OlMap, + pixel: [number, number], +) { + const [x, y] = pixel; + const originalEvent = new MouseEvent("mousemove", { + clientX: x, + clientY: y, + bubbles: true, + }); + const event = new MapBrowserEvent("pointermove", map, originalEvent, false); + event.pixel = pixel; + map.dispatchEvent(event); +} + +export function stubFeatureHitDetection( + map: OlMap, + layer: BaseLayer, + feature: Feature, + getHitMode: () => HitMode, +) { + map.forEachFeatureAtPixel = (pixel, callback, options) => { + if (getHitMode() === "feature") { + return callback(feature, layer, pixel); + } + return undefined; + }; +} + +export function getHighlightFeatures(store: EnhancedStore): string[] { + return store.getState().featureSelections?.highlight?.features ?? []; +} + +function findLayerById(map: OlMap, layerId: string) { + for (const layer of map.getLayers().getArray()) { + if ("getLayers" in layer && typeof layer.getLayers === "function") { + for (const child of layer.getLayers().getArray()) { + if (getIdForLayer(child) === layerId) { + return child; + } + } + } else if (getIdForLayer(layer) === layerId) { + return layer; + } + } + throw new Error(`Layer ${layerId} was not found on the map`); +} diff --git a/packages/core/src/js/test/inject-default-ol-proxy.ts b/packages/core/src/js/test/inject-default-ol-proxy.ts new file mode 100644 index 00000000..c03d5749 --- /dev/null +++ b/packages/core/src/js/test/inject-default-ol-proxy.ts @@ -0,0 +1,34 @@ +import {di} from "@/ol-proxy"; +import GeoJSONFormat from "@/ol-proxy/definitions/format/GeoJSONFormat"; +import DoubleClickZoomInteraction from "@/ol-proxy/definitions/interaction/DoubleClickZoomInteraction"; +import DragPanInteraction from "@/ol-proxy/definitions/interaction/DragPanInteraction"; +import KeyboardPanInteraction from "@/ol-proxy/definitions/interaction/KeyboardPanInteraction"; +import KeyboardZoomInteraction from "@/ol-proxy/definitions/interaction/KeyboardZoomInteraction"; +import MouseWheelZoomInteraction from "@/ol-proxy/definitions/interaction/MouseWheelZoomInteraction"; +import PinchZoomInteraction from "@/ol-proxy/definitions/interaction/PinchZoomInteraction"; +import SelectInteraction from "@/ol-proxy/definitions/interaction/SelectInteraction"; +import TileLayer from "@/ol-proxy/definitions/layer/TileLayer"; +import VectorLayer from "@/ol-proxy/definitions/layer/VectorLayer"; +import VectorOverlayLayer from "@/ol-proxy/definitions/layer/VectorOverlayLayer"; +import OSMSource from "@/ol-proxy/definitions/source/OsmSource"; +import TileWMSSource from "@/ol-proxy/definitions/source/TileWMSSource"; +import VectorFeatureSource from "@/ol-proxy/definitions/source/VectorFeatureSource"; +import VectorSource from "@/ol-proxy/definitions/source/VectorSource"; + +di.injectDefinitions([ + TileLayer, + VectorLayer, + VectorOverlayLayer, + VectorSource, + TileWMSSource, + OSMSource, + VectorFeatureSource, + GeoJSONFormat, + SelectInteraction, + DragPanInteraction, + DoubleClickZoomInteraction, + PinchZoomInteraction, + MouseWheelZoomInteraction, + KeyboardPanInteraction, + KeyboardZoomInteraction, +]); diff --git a/packages/core/src/js/test/setup-dom.ts b/packages/core/src/js/test/setup-dom.ts new file mode 100644 index 00000000..548d66a8 --- /dev/null +++ b/packages/core/src/js/test/setup-dom.ts @@ -0,0 +1,59 @@ +import {Canvas, Image as CanvasImage} from "canvas"; + +import { + document, + requestAnimationFrame, + window, +} from "@/env/ssr-simulated-browser"; + +globalThis.window = window as Window & typeof globalThis; +globalThis.document = document; +globalThis.requestAnimationFrame = requestAnimationFrame; +globalThis.HTMLElement = window.HTMLElement; +globalThis.MouseEvent = window.MouseEvent; +globalThis.getComputedStyle = window.getComputedStyle; + +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +globalThis.ResizeObserver = + ResizeObserverStub as unknown as typeof ResizeObserver; + +if (!globalThis.ShadowRoot) { + globalThis.ShadowRoot = class {} as typeof ShadowRoot; +} + +if (!globalThis.CanvasPattern) { + globalThis.CanvasPattern = class {} as typeof CanvasPattern; +} + +// node-canvas backing store for OpenLayers vector rendering in Node tests / SSR prep. +globalThis.Image = CanvasImage as unknown as typeof Image; + +const canvasProto = window.HTMLCanvasElement.prototype; +canvasProto.getContext = function getContext( + type: string, + attributes?: CanvasRenderingContext2DSettings, +) { + if (type === "2d") { + const width = Number(this.width) || 300; + const height = Number(this.height) || 150; + const nodeCanvas = new Canvas(width, height); + const context = nodeCanvas.getContext("2d", attributes); + if (!context) { + return null; + } + + // Keep the jsdom element dimensions in sync with the backing canvas. + Object.defineProperty(context, "canvas", { + get: () => this, + }); + + return context as unknown as CanvasRenderingContext2D; + } + + return null; +} as typeof canvasProto.getContext; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 0c106d71..5e1a7157 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "../../configs/tsconfig-base.json", "include": ["src/js/**/*"], + "exclude": ["**/__tests__/**", "**/*.test.ts", "src/js/test/**"], "compilerOptions": { "outDir": "dist", "rootDir": "src/js", diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 00000000..df5da8b5 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,21 @@ +import path from "node:path"; +import {fileURLToPath} from "node:url"; + +import {defineConfig} from "vitest/config"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(dirname, "src/js"), + }, + }, + test: { + include: ["src/js/**/*.test.ts"], + setupFiles: [ + "src/js/test/setup-dom.ts", + "src/js/test/inject-default-ol-proxy.ts", + ], + }, +}); diff --git a/packages/lib-js/.npmignore b/packages/lib-js/.npmignore index cf06fbb4..3af222d6 100644 --- a/packages/lib-js/.npmignore +++ b/packages/lib-js/.npmignore @@ -1,4 +1,3 @@ -* - -!/dist/**/* -!/src/**/* +# Publish surface is package.json "files" (dist only). +**/__tests__/** +**/*.test.* diff --git a/packages/lib-js/package.json b/packages/lib-js/package.json index 3a0b9f63..5e0a6e27 100644 --- a/packages/lib-js/package.json +++ b/packages/lib-js/package.json @@ -16,6 +16,9 @@ "import": "./dist/*.js" } }, + "files": [ + "dist" + ], "license": "UNLICENSED", "repository": { "url": "https://github.com/open-mapsight/mapsight" @@ -27,8 +30,7 @@ "lint": "eslint", "test": "vitest run", "typecheck": "tsc --noEmit", - "watch": "run-p watch:*", - "watch:cjs": "pnpm run build:cjs --watch", - "watch:esm": "pnpm run build:esm --watch" + "watch": "run-s build watch:build", + "watch:build": "tsc --project tsconfig.build.json --outDir dist --watch" } } diff --git a/packages/lib-js/src/js/object/shallowEqualRecords.spec.ts b/packages/lib-js/src/js/object/shallowEqualRecords.spec.ts new file mode 100644 index 00000000..fc473dae --- /dev/null +++ b/packages/lib-js/src/js/object/shallowEqualRecords.spec.ts @@ -0,0 +1,32 @@ +import {strictEqual} from "assert"; + +import {describe, it} from "vitest"; + +import shallowEqualRecords from "./shallowEqualRecords.ts"; + +describe("shallowEqualRecords", () => { + it("returns true for the same reference", () => { + const record = {a: "1", b: "2"}; + strictEqual(shallowEqualRecords(record, record), true); + }); + + it("returns true for equal values with different references", () => { + strictEqual( + shallowEqualRecords({a: "1", b: "2"}, {a: "1", b: "2"}), + true, + ); + }); + + it("returns false when values differ", () => { + strictEqual(shallowEqualRecords({a: "1"}, {a: "2"}), false); + }); + + it("returns false when keys differ", () => { + strictEqual(shallowEqualRecords({a: "1"}, {a: "1", b: "2"}), false); + }); + + it("returns false when one side is undefined", () => { + strictEqual(shallowEqualRecords({a: "1"}, undefined), false); + strictEqual(shallowEqualRecords(undefined, undefined), true); + }); +}); diff --git a/packages/lib-js/src/js/object/shallowEqualRecords.ts b/packages/lib-js/src/js/object/shallowEqualRecords.ts new file mode 100644 index 00000000..6d7f7593 --- /dev/null +++ b/packages/lib-js/src/js/object/shallowEqualRecords.ts @@ -0,0 +1,19 @@ +export default function shallowEqualRecords>( + a: T | undefined, + b: T | undefined, +): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) { + return false; + } + + return keysA.every((key) => a[key] === b[key]); +} diff --git a/packages/lib-js/tsconfig.build.json b/packages/lib-js/tsconfig.build.json index 6636332a..a8a6123f 100644 --- a/packages/lib-js/tsconfig.build.json +++ b/packages/lib-js/tsconfig.build.json @@ -1,6 +1,11 @@ { "extends": "./tsconfig.json", - "exclude": ["scripts/**/*", "**/*.spec.ts", "**/*.test.ts"], + "exclude": [ + "scripts/**/*", + "**/__tests__/**", + "**/*.spec.ts", + "**/*.test.ts" + ], "include": ["src/js/**/*"], "compilerOptions": { "rootDir": "src/js", diff --git a/packages/lib-ol/.npmignore b/packages/lib-ol/.npmignore index cf06fbb4..3af222d6 100644 --- a/packages/lib-ol/.npmignore +++ b/packages/lib-ol/.npmignore @@ -1,4 +1,3 @@ -* - -!/dist/**/* -!/src/**/* +# Publish surface is package.json "files" (dist only). +**/__tests__/** +**/*.test.* diff --git a/packages/lib-ol/package.json b/packages/lib-ol/package.json index 1372bea8..b9deeaf4 100644 --- a/packages/lib-ol/package.json +++ b/packages/lib-ol/package.json @@ -18,6 +18,9 @@ "default": "./dist/*.js" } }, + "files": [ + "dist" + ], "license": "UNLICENSED", "main": "index.js", "peerDependencies": { diff --git a/packages/lib-ol/src/js/style/createCachedStyleFunction.ts b/packages/lib-ol/src/js/style/createCachedStyleFunction.ts index ce53c668..8f002ee6 100644 --- a/packages/lib-ol/src/js/style/createCachedStyleFunction.ts +++ b/packages/lib-ol/src/js/style/createCachedStyleFunction.ts @@ -1,5 +1,5 @@ -import type {Type as GeometryType} from "ol/geom/Geometry"; import type Geometry from "ol/geom/Geometry"; +import type {Type as GeometryType} from "ol/geom/Geometry"; import type GeometryCollection from "ol/geom/GeometryCollection"; import LRUCache from "ol/structs/LRUCache"; import type Style from "ol/style/Style"; @@ -11,6 +11,7 @@ import deriveGeometriesFromBase from "../geometry/deriveGeometriesFromBase.ts"; import type {GroupedRootStyleDeclaration} from "../index.ts"; import declarationToGeometry from "./declarationToGeometry.ts"; import declarationToStyle from "./declarationToStyle.ts"; +import {enterStyleFeatureScope} from "./styleFeatureScope.ts"; import type { MapsightStyleFunction, MapsightStyleFunctionEnv, @@ -69,6 +70,12 @@ export type StyleFunctionOptions = { geometryType: GeometryType, styleName: string, ) => unknown; // Using GroupedRootStyleDeclaration here breaks tsc, probably because the object is too big/complex + volatileHashFunction?: ( + env: MapsightStyleFunctionEnv, + props: MapsightStyleFunctionProps, + geometryType: GeometryType, + styleName: string, + ) => string; allowedProps?: Array | false; allowedStyles?: Array | false; cacheLevel1Size?: number; @@ -83,6 +90,7 @@ export type StyleFunctionOptions = { * @param [metricsCollector] optional callback receiving per-invocation runtime metrics (hash timing, cache hit/miss, and declaration/style timings) * @param declarationHashFunction style declaration hash function * @param declarationFunction style declaration function + * @param [volatileHashFunction] optional volatile hash function for values that should trigger a cache miss when changed, even if the declaration hash is the same (e.g. for time-based styling) * @param [allowedProps=false] list of props allowed, false = all allowed * @param [allowedStyles=false] list of styles allowed, false = all allowed * @param [cacheLevel1Size=100] size of first level cache that caches feature geometry styles based on feature and environment state @@ -96,6 +104,7 @@ export default function createCachedStyleFunction({ metricsCollector, declarationHashFunction, declarationFunction, + volatileHashFunction, allowedProps = false, allowedStyles = false, cacheLevel1Size = DEFAULT_CACHE_LEVEL_1_SIZE, @@ -110,6 +119,24 @@ export default function createCachedStyleFunction({ const cacheLevel2 = new LRUCache(cacheLevel2Size); // the second level cache caches style objects based on the rules that apply and the environment state const cacheLevel3 = new LRUCache