diff --git a/.changeset/breezy-pans-tan.md b/.changeset/breezy-pans-tan.md new file mode 100644 index 0000000..39825a3 --- /dev/null +++ b/.changeset/breezy-pans-tan.md @@ -0,0 +1,5 @@ +--- +"@xtramaps/legend-symbols-maplibre-svelte": major +--- + +add svelte component library to create legend symbols for maplibre styles diff --git a/.changeset/jolly-bears-create.md b/.changeset/jolly-bears-create.md new file mode 100644 index 0000000..13c5db5 --- /dev/null +++ b/.changeset/jolly-bears-create.md @@ -0,0 +1,5 @@ +--- +"@xtramaps/legend-symbols-maplibre-react": major +--- + +add react component library to create legend symbols for maplibre styles diff --git a/.changeset/polite-bobcats-do.md b/.changeset/polite-bobcats-do.md new file mode 100644 index 0000000..fce4fd9 --- /dev/null +++ b/.changeset/polite-bobcats-do.md @@ -0,0 +1,5 @@ +--- +"@xtramaps/legend-symbols-maplibre": major +--- + +add core library to create legend symbols for maplibre styles diff --git a/.changeset/quick-lines-say.md b/.changeset/quick-lines-say.md new file mode 100644 index 0000000..d8bc7bf --- /dev/null +++ b/.changeset/quick-lines-say.md @@ -0,0 +1,5 @@ +--- +"@xtramaps/legend-symbols-maplibre-vue": major +--- + +add vue component library to create legend symbols for maplibre styles diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae16b5c..a004dca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ jobs: release: runs-on: ubuntu-latest permissions: + id-token: write contents: write pull-requests: write steps: @@ -21,10 +22,12 @@ jobs: registry-url: https://registry.npmjs.org - run: npm ci - run: npm run build - - uses: changesets/action@v1 + - name: Create Release Pull Request or Publish to npm + uses: changesets/action@v1 with: publish: npm run release version: npm run version-packages + commit: "chore: bump package versions" + createGithubReleases: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package-lock.json b/package-lock.json index f8eb113..88dabc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,10 @@ { - "name": "xtramaps2", + "name": "xtramaps", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "xtramaps2", + "name": "xtramaps", "license": "MIT", "workspaces": [ "packages/core/*", @@ -666,6 +666,40 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-22.0.1.tgz", + "integrity": "sha512-V7bSw7Ui6+NhpeeuYqGoqamvKuy+3+uCvQ/t4ZJkwN8cx527CAlQQQ2kp+w5R9q+Tw6bUAH+fsq+mPEkicgT8g==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, "node_modules/@microsoft/api-extractor": { "version": "7.57.7", "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.57.7.tgz", @@ -1404,16 +1438,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2484,6 +2508,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -3040,6 +3070,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -3396,6 +3435,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -3569,6 +3614,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3844,6 +3895,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3904,13 +3961,6 @@ "dev": true, "license": "MIT" }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "extraneous": true, - "license": "MIT" - }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -4121,7 +4171,10 @@ "packages/core/legend-symbols-maplibre": { "name": "@xtramaps/legend-symbols-maplibre", "version": "0.0.1", - "license": "MIT" + "license": "MIT", + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "^22.0.0" + } }, "packages/react/legend-symbols-maplibre": { "name": "@xtramaps/legend-symbols-maplibre-react", diff --git a/packages/core/legend-symbols-maplibre/README.md b/packages/core/legend-symbols-maplibre/README.md index ff46864..1ac8fc6 100644 --- a/packages/core/legend-symbols-maplibre/README.md +++ b/packages/core/legend-symbols-maplibre/README.md @@ -1,6 +1,8 @@ # @xtramaps/legend-symbols-maplibre -Core library for generating legend symbols from MapLibre styles. +Framework-agnostic library for generating legend symbols from MapLibre GL styles. Takes a style layer and produces a plain virtual DOM tree (`{ element, attributes, children }`) that can be rendered by any framework. + +Supports `circle`, `line`, `fill`, and `symbol` layer types. ## Install @@ -11,9 +13,31 @@ npm install @xtramaps/legend-symbols-maplibre ## Usage ```ts -import { greet } from "@xtramaps/legend-symbols-maplibre"; +import { legendSymbol } from "@xtramaps/legend-symbols-maplibre"; + +const tree = legendSymbol({ zoom: 14, layer: myLayer, sprite: mySpriteData }); + +// tree is a plain object like: +// { element: "svg", attributes: { viewBox: "0 0 20 20", ... }, children: [...] } +``` + +### Utilities + +```ts +import { + exprHandler, + mapImageToDataURL, + cache, + loadImage, + loadJson, +} from "@xtramaps/legend-symbols-maplibre"; ``` +- `exprHandler({ zoom, properties })` - Evaluates MapLibre style expressions for a given zoom level and feature properties. +- `mapImageToDataURL(map, icon)` - Extracts an icon from a MapLibre map instance as a data URL. +- `cache` - Simple reference-counted cache for sprite data. +- `loadImage(url, { transformRequest })` / `loadJson(url, { transformRequest })` - Load sprite assets with optional request transformation. + ## License MIT diff --git a/packages/core/legend-symbols-maplibre/package.json b/packages/core/legend-symbols-maplibre/package.json index 454daea..62a027d 100644 --- a/packages/core/legend-symbols-maplibre/package.json +++ b/packages/core/legend-symbols-maplibre/package.json @@ -1,7 +1,8 @@ { "name": "@xtramaps/legend-symbols-maplibre", - "version": "0.0.1", + "version": "0.0.0-next-20260320165054", "license": "MIT", + "homepage": "https://github.com/ldproxy/xtramaps/tree/main/packages/core/legend-symbols-maplibre#readme", "repository": { "type": "git", "url": "https://github.com/ldproxy/xtramaps.git", @@ -29,5 +30,8 @@ "scripts": { "build": "vite build", "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "^22.0.0" } } diff --git a/packages/core/legend-symbols-maplibre/src/Circle.ts b/packages/core/legend-symbols-maplibre/src/Circle.ts new file mode 100644 index 0000000..4d85aa7 --- /dev/null +++ b/packages/core/legend-symbols-maplibre/src/Circle.ts @@ -0,0 +1,53 @@ +import type { SymbolHandlerProps, SymbolTree } from "./types"; + +export function Circle({ expr, layer }: SymbolHandlerProps): SymbolTree { + const radius = Math.min(expr(layer, "paint", "circle-radius") as number, 8); + const strokeWidth = Math.min( + expr(layer, "paint", "circle-stroke-width") as number, + 4, + ); + const fillColor = expr(layer, "paint", "circle-color") as string; + const fillOpacity = expr(layer, "paint", "circle-opacity") as number; + const strokeColor = expr(layer, "paint", "circle-stroke-color") as string; + const strokeOpacity = expr(layer, "paint", "circle-stroke-opacity") as number; + const blur = expr(layer, "paint", "circle-blur") as number; + + const innerRadius = radius - strokeWidth / 2; + + return { + element: "svg", + attributes: { + viewBox: "0 0 20 20", + xmlns: "http://www.w3.org/2000/svg", + style: { + filter: `blur(${blur * innerRadius}px)`, + }, + }, + children: [ + { + element: "circle", + attributes: { + key: "l1", + cx: 10, + cy: 10, + fill: fillColor, + opacity: fillOpacity, + r: innerRadius, + }, + }, + { + element: "circle", + attributes: { + key: "l2", + cx: 10, + cy: 10, + fill: "transparent", + opacity: strokeOpacity, + r: radius, + "stroke-width": strokeWidth, + stroke: strokeColor, + }, + }, + ], + }; +} diff --git a/packages/core/legend-symbols-maplibre/src/Fill.ts b/packages/core/legend-symbols-maplibre/src/Fill.ts new file mode 100644 index 0000000..78160c2 --- /dev/null +++ b/packages/core/legend-symbols-maplibre/src/Fill.ts @@ -0,0 +1,31 @@ +import type { SymbolHandlerProps, SymbolTree } from "./types"; + +export function Fill({ image, expr, layer }: SymbolHandlerProps): SymbolTree { + const { url: dataUrl } = image( + expr(layer, "paint", "fill-pattern") as string, + ); + const baseStyle: Record = { + width: "100%", + height: "100%", + opacity: expr(layer, "paint", "fill-opacity"), + }; + const style = dataUrl + ? { + ...baseStyle, + backgroundImage: `url(${dataUrl})`, + backgroundPosition: "top left", + } + : { + ...baseStyle, + backgroundColor: expr(layer, "paint", "fill-color"), + backgroundSize: "66% 66%", + backgroundPosition: "center", + }; + + return { + element: "div", + attributes: { + style, + }, + }; +} diff --git a/packages/core/legend-symbols-maplibre/src/Line.ts b/packages/core/legend-symbols-maplibre/src/Line.ts new file mode 100644 index 0000000..c445e34 --- /dev/null +++ b/packages/core/legend-symbols-maplibre/src/Line.ts @@ -0,0 +1,82 @@ +import type { SymbolHandlerProps, SymbolTree } from "./types"; + +export function Line({ layer, image, expr }: SymbolHandlerProps): SymbolTree { + const { url: dataUrl } = image( + expr(layer, "paint", "line-pattern") as string, + ); + + const style = { + stroke: dataUrl + ? `url(#img1)` + : (expr(layer, "paint", "line-color") as string), + strokeWidth: Math.max( + 2, + Math.min(expr(layer, "paint", "line-width") as number, 8), + ), + strokeOpacity: expr(layer, "paint", "line-opacity") as number | null, + strokeDasharray: expr(layer, "paint", "line-dasharray") as string | null, + }; + const sw = style.strokeWidth; + let cssStyle = `stroke: ${style.stroke};`; + cssStyle += `stroke-width: ${sw};`; + if (style.strokeOpacity) { + cssStyle += `stroke-opacity: ${style.strokeOpacity};`; + } + if (style.strokeDasharray) { + cssStyle += `stroke-dasharray: ${style.strokeDasharray};`; + } + + return { + element: "svg", + attributes: { + viewBox: "0 0 20 20", + xmlns: "http://www.w3.org/2000/svg", + }, + children: [ + { + element: "defs", + attributes: { + key: "defs", + }, + children: [ + { + element: "pattern", + attributes: { + key: "pattern", + id: "img1", + x: 0, + y: 0, + width: style.strokeWidth, + height: style.strokeWidth, + patternUnits: "userSpaceOnUse", + patternTransform: `translate(${-(sw / 2)} ${-(sw / 2)}) rotate(45)`, + }, + children: dataUrl + ? [ + { + element: "image", + attributes: { + key: "img", + xlinkHref: dataUrl, + x: 0, + y: 0, + width: style.strokeWidth, + height: style.strokeWidth, + }, + }, + ] + : [], + }, + ], + }, + { + element: "path", + attributes: { + key: "path", + style: cssStyle, + d: "M0 20 L 20 0", + }, + }, + ], + }; +} diff --git a/packages/core/legend-symbols-maplibre/src/Raster.ts b/packages/core/legend-symbols-maplibre/src/Raster.ts new file mode 100644 index 0000000..7d12b75 --- /dev/null +++ b/packages/core/legend-symbols-maplibre/src/Raster.ts @@ -0,0 +1,28 @@ +import type { SymbolTree } from "./types"; + +export const rasterSymbol: SymbolTree = { + element: "svg", + attributes: { + "aria-hidden": "true", + xmlns: "http://www.w3.org/2000/svg", + width: 16, + height: 16, + fill: "currentColor", + viewBox: "0 0 16 16", + style: { color: "#999" }, + }, + children: [ + { + element: "path", + attributes: { + d: "M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z", + }, + }, + { + element: "path", + attributes: { + d: "M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z", + }, + }, + ], +}; diff --git a/packages/core/legend-symbols-maplibre/src/Symbol.ts b/packages/core/legend-symbols-maplibre/src/Symbol.ts new file mode 100644 index 0000000..ba8547e --- /dev/null +++ b/packages/core/legend-symbols-maplibre/src/Symbol.ts @@ -0,0 +1,95 @@ +import type { SymbolHandlerProps, SymbolTree } from "./types"; + +function renderIconSymbol({ + expr, + layer, + image, +}: SymbolHandlerProps): SymbolTree | null { + const imgKey = expr(layer, "layout", "icon-image") as string | undefined; + const imgSize = expr(layer, "layout", "icon-size") as number | undefined; + + if (!imgKey) { + return null; + } + const { url: dataUrl, dimensions } = image(imgKey); + + if (!dataUrl || !dimensions) { + return null; + } + + const { width, height } = dimensions; + + const backgroundSize = imgSize + ? imgSize * width > 16 || imgSize * height > 16 + ? "contain" + : `${imgSize * width}px ${imgSize * height}px` + : "contain"; + + return { + element: "div", + attributes: { + style: { + backgroundImage: `url(${dataUrl})`, + backgroundSize, + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + width: "100%", + height: "100%", + }, + }, + }; +} + +function renderTextSymbol({ + expr, + layer, +}: Pick): SymbolTree { + const textColor = expr(layer, "paint", "text-color") as string; + const textOpacity = expr(layer, "paint", "text-opacity") as number; + const textHaloColor = expr(layer, "paint", "text-halo-color") as string; + const textHaloWidth = expr(layer, "paint", "text-halo-width") as number; + + const d = "M 4,4 L 16,4 L 16,7 L 11.5 7 L 11.5 16 L 8.5 16 L 8.5 7 L 4 7 Z"; + + return { + element: "svg", + attributes: { + viewBox: "0 0 20 20", + xmlns: "http://www.w3.org/2000/svg", + }, + children: [ + { + element: "path", + attributes: { + key: "l1", + d, + stroke: textHaloColor, + "stroke-width": textHaloWidth * 2, + fill: "transparent", + "stroke-linejoin": "round", + }, + }, + { + element: "path", + attributes: { + key: "l2", + d, + fill: "white", + }, + }, + { + element: "path", + attributes: { + key: "l3", + d, + fill: textColor, + opacity: textOpacity, + }, + }, + ], + }; +} + +export function SymbolHandler(props: SymbolHandlerProps): SymbolTree | null { + return renderIconSymbol(props) || renderTextSymbol(props); +} diff --git a/packages/core/legend-symbols-maplibre/src/index.ts b/packages/core/legend-symbols-maplibre/src/index.ts index b370dea..003440b 100644 --- a/packages/core/legend-symbols-maplibre/src/index.ts +++ b/packages/core/legend-symbols-maplibre/src/index.ts @@ -1,3 +1,28 @@ -export function greet(name: string): string { - return `Hello, ${name}!`; -} +export { legendSymbol } from "./legend-symbol"; +export { rasterSymbol } from "./Raster"; +export type { + CancellablePromise, + ExprFunction, + ImageFunction, + ImageResult, + LegendSymbolProps, + LoadOptions, + MapImageManager, + MapLibreLayer, + SpriteData, + SpriteDimensions, + SpriteEntry, + SymbolHandlerProps, + SymbolTree, + TransformRequestResult, +} from "./types"; +export { + CSSstring, + cache, + camelCase, + exprHandler, + loadImage, + loadJson, + loadSprites, + mapImageToDataURL, +} from "./util"; diff --git a/packages/core/legend-symbols-maplibre/src/legend-symbol.ts b/packages/core/legend-symbols-maplibre/src/legend-symbol.ts new file mode 100644 index 0000000..002ac6c --- /dev/null +++ b/packages/core/legend-symbols-maplibre/src/legend-symbol.ts @@ -0,0 +1,95 @@ +import { Circle } from "./Circle"; +import { Fill } from "./Fill"; +import { Line } from "./Line"; +import { SymbolHandler } from "./Symbol"; +import type { + ImageResult, + LegendSymbolProps, + SymbolHandlerProps, + SymbolTree, +} from "./types"; +import { exprHandler } from "./util"; + +function extractPartOfImage( + img: HTMLImageElement, + { + x, + y, + width, + height, + pixelRatio, + }: { + x: number; + y: number; + width: number; + height: number; + pixelRatio: number; + }, +): { url: string; dimensions: { width: number; height: number } } { + const dpi = 1 / pixelRatio; + const el = document.createElement("canvas"); + el.width = width * dpi; + el.height = height * dpi; + const ctx = el.getContext("2d"); + if (!ctx) throw new Error("Failed to get canvas 2d context"); + ctx.drawImage(img, x, y, width, height, 0, 0, width * dpi, height * dpi); + return { + url: el.toDataURL(), + dimensions: { width: width * dpi, height: height * dpi }, + }; +} + +export function legendSymbol({ + sprite, + zoom, + layer, + properties, +}: LegendSymbolProps): SymbolTree | null { + const TYPE_MAP: Record< + string, + (props: SymbolHandlerProps) => SymbolTree | null + > = { + circle: Circle, + symbol: SymbolHandler, + line: Line, + fill: Fill, + }; + + const handler = TYPE_MAP[layer.type]; + const expr = exprHandler({ zoom, properties }); + const image = (imgKey: string): ImageResult => { + if (!imgKey) return {}; + const cleanKey = imgKey.includes(":") ? imgKey.split(":")[1] : imgKey; + + if (sprite?.json) { + const dimensions = sprite.json[cleanKey]; + const multipleSprites = sprite.sprites && Array.isArray(sprite.sprites); + + if (dimensions) { + if (multipleSprites) { + const individualSprite = sprite.sprites?.find( + (s) => + s.json.status === "fulfilled" && + s.json.value[cleanKey] && + s.image.status === "fulfilled", + ); + if (individualSprite) { + return extractPartOfImage( + ( + individualSprite.image as PromiseFulfilledResult + ).value, + dimensions, + ); + } + } + return extractPartOfImage(sprite.image, dimensions); + } + } + return {}; + }; + + if (handler) { + return handler({ layer, expr, image }); + } + return null; +} diff --git a/packages/core/legend-symbols-maplibre/src/types.ts b/packages/core/legend-symbols-maplibre/src/types.ts new file mode 100644 index 0000000..cc551fc --- /dev/null +++ b/packages/core/legend-symbols-maplibre/src/types.ts @@ -0,0 +1,85 @@ +export interface SymbolTree { + element: string; + attributes: Record; + children?: SymbolTree[]; +} + +export type ExprFunction = ( + layer: MapLibreLayer, + type: "paint" | "layout", + prop: string, +) => unknown; + +export interface ImageResult { + url?: string; + dimensions?: { width: number; height: number }; +} + +export type ImageFunction = (imgKey: string) => ImageResult; + +export interface SymbolHandlerProps { + layer: MapLibreLayer; + expr: ExprFunction; + image: ImageFunction; +} + +export interface SpriteDimensions { + x: number; + y: number; + width: number; + height: number; + pixelRatio: number; +} + +export interface SpriteEntry { + json: PromiseSettledResult>; + image: PromiseSettledResult; +} + +export interface SpriteData { + json: Record; + image: HTMLImageElement; + sprites?: SpriteEntry[]; +} + +export interface LegendSymbolProps { + sprite?: SpriteData; + zoom: number; + layer: MapLibreLayer; + properties?: Record; +} + +export interface MapLibreLayer { + type: string; + paint?: Record; + layout?: Record; + [key: string]: unknown; +} + +export interface TransformRequestResult { + url: string; + headers?: HeadersInit; +} + +export interface LoadOptions { + transformRequest: (url: string) => TransformRequestResult; +} + +export interface MapImageManager { + style: { + imageManager: { + images: Record< + string, + { + data: { + width: number; + height: number; + data: Uint8ClampedArray | number[]; + }; + } + >; + }; + }; +} + +export type CancellablePromise = Promise & { cancel: () => void }; diff --git a/packages/core/legend-symbols-maplibre/src/util.ts b/packages/core/legend-symbols-maplibre/src/util.ts new file mode 100644 index 0000000..7096707 --- /dev/null +++ b/packages/core/legend-symbols-maplibre/src/util.ts @@ -0,0 +1,334 @@ +import { + expression, + latest, + type StyleSpecification, + function as styleFunction, +} from "@maplibre/maplibre-gl-style-spec"; +import type { + CancellablePromise, + ExprFunction, + LoadOptions, + MapImageManager, + SpriteData, + SpriteDimensions, +} from "./types"; + +export function camelCase( + obj: Record, +): Record { + return Object.assign( + {}, + ...Object.keys(obj).map((key) => { + const camelCased = key.includes("-") + ? key.replace(/-[a-z]/g, (g) => g[1].toUpperCase()) + : key; + return { [camelCased]: obj[key] }; + }), + ); +} + +export function CSSstring(string: string): Record { + const cssJson = `{"${string + .replace(/;$/, "") + .replace(/;/g, '", "') + .replace(/: /g, '": "')}"}`; + const obj = JSON.parse(cssJson); + + return camelCase(obj); +} + +const PROP_MAP: [string, string?][] = [ + ["background"], + ["circle"], + ["fill-extrusion"], + ["fill"], + ["heatmap"], + ["hillshade"], + ["line"], + ["raster"], + ["icon", "symbol"], + ["text", "symbol"], +]; + +export function exprHandler({ + zoom, + properties, +}: { + zoom: number; + properties?: Record; +}): ExprFunction { + function prefixFromProp(prop: string): string | null { + const out = PROP_MAP.find((def) => { + const type = def[0]; + return prop.startsWith(type); + }); + return out ? out[1] || out[0] : null; + } + + return (layer, type, prop) => { + const prefix = prefixFromProp(prop); + const specKey = `${type}_${prefix}` as keyof typeof latest; + const specGroup = latest[specKey] as Record< + string, + { default: unknown; type?: string } + >; + const specItem = specGroup[prop]; + const dflt = specItem.default; + + if (!layer[type]) { + return dflt; + } + + const layerGroup = layer[type] as Record; + const input = layerGroup[prop]; + + const objType = typeof input; + if (objType === "undefined") { + return specItem.default; + } + if (typeof input === "object") { + let expr: { evaluate?: (...args: unknown[]) => unknown }; + if (Array.isArray(input)) { + if (specItem.type === "array") { + return input; + } + expr = expression.createExpression(input).value as typeof expr; + } else { + expr = styleFunction.createFunction( + input, + specItem as Parameters[1], + ) as typeof expr; + } + if (!expr.evaluate) { + return null; + } + const result = expr.evaluate({ zoom }, { properties }) as + | { name?: string } + | string + | number + | null; + if (result) { + return typeof result === "object" && "name" in result + ? result.name + : result; + } + return null; + } + return input; + }; +} + +export function mapImageToDataURL( + map: MapImageManager, + icon: string | undefined, +): string | undefined { + if (!icon) { + return undefined; + } + + const image = map.style.imageManager.images[icon]; + if (!image) { + return undefined; + } + + const canvasEl = document.createElement("canvas"); + canvasEl.width = image.data.width; + canvasEl.height = image.data.height; + const ctx = canvasEl.getContext("2d"); + if (!ctx) throw new Error("Failed to get canvas 2d context"); + ctx.putImageData( + new ImageData( + Uint8ClampedArray.from(image.data.data), + image.data.width, + image.data.height, + ), + 0, + 0, + ); + + return canvasEl.toDataURL(); +} + +const dataStore = new Map(); +export const cache = { + add: (key: string, value: unknown) => { + if (dataStore.has(key)) { + throw new Error(`Cache already contains '${key}'`); + } + dataStore.set(key, { + value, + count: 1, + }); + }, + fetch: (key: string): unknown => { + const cacheObj = dataStore.get(key); + if (cacheObj) { + cacheObj.count += 1; + return cacheObj.value; + } + return null; + }, + release: (key: string) => { + const cacheObj = dataStore.get(key); + if (!cacheObj) { + throw new Error(`No such key in cache '${key}'`); + } + cacheObj.count -= 1; + + if (cacheObj.count === 0) { + dataStore.delete(key); + } + }, +}; + +function loadImageViaTag(url: string): CancellablePromise { + let cancelled = false; + const promise = new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "Anonymous"; + img.onload = () => { + if (!cancelled) resolve(img); + }; + img.onerror = (e) => { + if (!cancelled) reject(e); + }; + img.src = url; + }) as CancellablePromise; + promise.cancel = () => { + cancelled = true; + }; + return promise; +} + +function removeUrl(obj: Record): Record { + const obj2 = { ...obj }; + delete obj2.url; + return obj2; +} + +function loadImageViaFetch( + url: string, + init: RequestInit, +): CancellablePromise { + return fetch(url, init) + .then((res) => res.blob()) + .then((blob) => URL.createObjectURL(blob)) + .then((url2) => + loadImageViaTag(url2), + ) as CancellablePromise; +} + +export function loadImage( + url: string, + { transformRequest }: LoadOptions, +): CancellablePromise { + const fetchObj = { ...transformRequest(url) } as Record; + + if (fetchObj.headers) { + return loadImageViaFetch(url, removeUrl(fetchObj) as RequestInit); + } + return loadImageViaTag(url); +} + +export function loadJson( + url: string, + { transformRequest }: LoadOptions, +): Promise { + const fetchObj = { ...transformRequest(url) }; + return fetch( + fetchObj.url, + removeUrl(fetchObj as unknown as Record) as RequestInit, + ).then((res) => res.json()); +} + +export const loadSprites = async ( + style: StyleSpecification, +): Promise => { + if (style.sprite) { + const multipleSprites = Array.isArray(style.sprite); + + if (multipleSprites) { + return loadMultipleSprites(style.sprite as { id: string; url: string }[]); + } + return loadSprite(style.sprite as string); + } + return undefined; +}; + +const loadSprite = async (url: string): Promise => { + const [image, json] = await Promise.all([ + loadSpriteImage(`${url}@2x.png`), + loadSpriteJson(`${url}@2x.json`), + ]); + + return { + image, + json, + }; +}; + +const loadMultipleSprites = async ( + sprites: { id: string; url: string }[], +): Promise => { + const loadedSprites = await Promise.all( + sprites.map((sprite) => + Promise.allSettled([ + loadSpriteImage(`${sprite.url}@2x.png`), + loadSpriteJson(`${sprite.url}@2x.json`), + ]).then(([image, json]) => ({ + id: sprite.id, + image: image as PromiseFulfilledResult, + json: json as PromiseFulfilledResult>, + })), + ), + ); + + const mergedJson: Record = {}; + + loadedSprites.forEach((sprite) => { + if (sprite.json.status === "fulfilled") { + Object.assign(mergedJson, sprite.json.value); + } + }); + + // first image as fallback for compatibility, but main.js will search through sprites if available + const firstImage = ( + loadedSprites.find((sprite) => sprite.image.status === "fulfilled") + ?.image as PromiseFulfilledResult + ).value; + + return { + image: firstImage, + json: mergedJson, + sprites: loadedSprites, + }; +}; + +type ImagePromise = Promise & { cancel?: () => void }; + +const loadSpriteImage = async (url: string): Promise => { + let cancelled = false; + const promise: ImagePromise = new Promise( + (resolve, reject) => { + const img = new Image(); + img.crossOrigin = "Anonymous"; + img.onload = () => { + if (!cancelled) resolve(img); + }; + img.onerror = (e) => { + if (!cancelled) reject(e); + }; + img.src = url; + }, + ); + promise.cancel = () => { + cancelled = true; + }; + return promise; +}; + +const loadSpriteJson = async ( + url: string, +): Promise> => { + return fetch(url).then((res) => res.json()); +}; diff --git a/packages/core/legend-symbols-maplibre/vite.config.ts b/packages/core/legend-symbols-maplibre/vite.config.ts index 2caa0a2..42c2b4d 100644 --- a/packages/core/legend-symbols-maplibre/vite.config.ts +++ b/packages/core/legend-symbols-maplibre/vite.config.ts @@ -1,3 +1,5 @@ import { defineLibConfig } from "../../vite.config.base"; -export default defineLibConfig(); +export default defineLibConfig({ + external: ["@maplibre/maplibre-gl-style-spec"], +}); diff --git a/packages/react/legend-symbols-maplibre/README.md b/packages/react/legend-symbols-maplibre/README.md index 0a735b0..e3c65a8 100644 --- a/packages/react/legend-symbols-maplibre/README.md +++ b/packages/react/legend-symbols-maplibre/README.md @@ -1,6 +1,10 @@ # @xtramaps/legend-symbols-maplibre-react -React components for generating legend symbols from MapLibre styles. +React component for rendering legend symbols from MapLibre GL styles. Wraps [`@xtramaps/legend-symbols-maplibre`](https://github.com/ldproxy/xtramaps/tree/main/packages/core/legend-symbols-maplibre) and converts the virtual DOM tree into React elements. + +## Example + +[Live example](https://raw.githack.com/ldproxy/xtramaps/main/packages/react/legend-symbols-maplibre/examples/react-legend.html) — standalone HTML using the [Daraa topographic style](https://demo.ldproxy.net/daraa/styles/topographic?f=mbs). ## Install @@ -8,14 +12,43 @@ React components for generating legend symbols from MapLibre styles. npm install @xtramaps/legend-symbols-maplibre-react ``` -Requires `react` and `react-dom` as peer dependencies. +Requires `react` and `react-dom` as peer dependencies (`^18.0.0 || ^19.0.0`). ## Usage +### `createLegend` + +The easiest way to get started. Pass a MapLibre style and get back a component that renders legend symbols by layer id. + ```tsx -import { greet } from "@xtramaps/legend-symbols-maplibre-react"; +import { createLegend } from "@xtramaps/legend-symbols-maplibre-react"; + +// Initialize once (loads sprites automatically) +const LegendSymbol = await createLegend(style, 14); + +// Then render by layer id + + + ``` +### `LegendSymbolReact` + +Lower-level component when you need full control over sprite loading and layer objects. + +```tsx +import { LegendSymbolReact } from "@xtramaps/legend-symbols-maplibre-react"; + +; +``` + +Falls back to a raster placeholder icon when the layer type is not supported. + +### Exports + +- `createLegend(style, zoom?)` - Async factory that loads sprites and returns a component accepting `{ layer, zoom?, properties?, style? }`. +- `LegendSymbolReact` - Lower-level component. Accepts `zoom`, `layer`, `sprite`, `properties`, and an optional `style` prop. + ## License MIT diff --git a/packages/react/legend-symbols-maplibre/examples/react-legend.html b/packages/react/legend-symbols-maplibre/examples/react-legend.html new file mode 100644 index 0000000..5684738 --- /dev/null +++ b/packages/react/legend-symbols-maplibre/examples/react-legend.html @@ -0,0 +1,149 @@ + + + + + + MapLibre Legend Symbols - React Example + + + + + +
+ + + + diff --git a/packages/react/legend-symbols-maplibre/package.json b/packages/react/legend-symbols-maplibre/package.json index e9daaac..132aa27 100644 --- a/packages/react/legend-symbols-maplibre/package.json +++ b/packages/react/legend-symbols-maplibre/package.json @@ -1,7 +1,8 @@ { "name": "@xtramaps/legend-symbols-maplibre-react", - "version": "0.0.1", + "version": "0.0.0-next-20260320165054", "license": "MIT", + "homepage": "https://github.com/ldproxy/xtramaps/tree/main/packages/react/legend-symbols-maplibre#readme", "repository": { "type": "git", "url": "https://github.com/ldproxy/xtramaps.git", @@ -31,7 +32,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@xtramaps/legend-symbols-maplibre": "^0.0.1" + "@xtramaps/legend-symbols-maplibre": "0.0.0-next-20260320165054" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/packages/react/legend-symbols-maplibre/src/LegendSymbol.tsx b/packages/react/legend-symbols-maplibre/src/LegendSymbol.tsx new file mode 100644 index 0000000..909f5be --- /dev/null +++ b/packages/react/legend-symbols-maplibre/src/LegendSymbol.tsx @@ -0,0 +1,40 @@ +import { + CSSstring, + camelCase, + type LegendSymbolProps, + legendSymbol, + rasterSymbol, + type SymbolTree, +} from "@xtramaps/legend-symbols-maplibre"; +import { type CSSProperties, createElement, type ReactElement } from "react"; + +function asReact(tree: SymbolTree, outerStyle?: CSSProperties): ReactElement { + let newStyle: CSSProperties = {}; + const { style, ...attributes } = tree.attributes; + + if (typeof style === "string") { + newStyle = CSSstring(style) as CSSProperties; + } else if (typeof style === "object") { + newStyle = style as CSSProperties; + } + + if (outerStyle) { + newStyle = { ...newStyle, ...outerStyle }; + } + + return createElement( + tree.element, + { ...camelCase(attributes), style: newStyle }, + tree.children ? tree.children.map((c: SymbolTree) => asReact(c)) : null, + ); +} + +export interface LegendSymbolReactProps extends LegendSymbolProps { + style?: CSSProperties; +} + +export function LegendSymbolReact(props: LegendSymbolReactProps) { + const icon = legendSymbol(props); + + return asReact(icon ?? rasterSymbol, props.style); +} diff --git a/packages/react/legend-symbols-maplibre/src/createLegend.ts b/packages/react/legend-symbols-maplibre/src/createLegend.ts new file mode 100644 index 0000000..baa8b20 --- /dev/null +++ b/packages/react/legend-symbols-maplibre/src/createLegend.ts @@ -0,0 +1,38 @@ +import type { StyleSpecification } from "@maplibre/maplibre-gl-style-spec"; +import { loadSprites } from "@xtramaps/legend-symbols-maplibre"; +import type { CSSProperties } from "react"; +import { LegendSymbolReact } from "./LegendSymbol"; + +export const createLegend = async ( + style: StyleSpecification, + zoom?: number, +) => { + const sprites = await loadSprites(style); + + return ({ + layer, + zoom: layerZoom, + properties, + style: cssStyle, + }: LegendSymbolWrappedProps) => { + const l = style.layers.find((l) => l.id === layer); + if (!l) { + throw new Error(`Layer ${layer} not found in the provided style`); + } + + return LegendSymbolReact({ + layer: l, + zoom: zoom || layerZoom || 12, + properties, + style: cssStyle, + sprite: sprites, + }); + }; +}; + +export interface LegendSymbolWrappedProps { + layer: string; + zoom?: number; + properties?: Record; + style?: CSSProperties; +} diff --git a/packages/react/legend-symbols-maplibre/src/index.ts b/packages/react/legend-symbols-maplibre/src/index.ts index 6d93a58..4d8c755 100644 --- a/packages/react/legend-symbols-maplibre/src/index.ts +++ b/packages/react/legend-symbols-maplibre/src/index.ts @@ -1 +1,4 @@ -export { greet } from "@xtramaps/legend-symbols-maplibre"; +export type { LegendSymbolWrappedProps } from "./createLegend"; +export { createLegend } from "./createLegend"; +export type { LegendSymbolReactProps } from "./LegendSymbol"; +export { LegendSymbolReact } from "./LegendSymbol"; diff --git a/packages/svelte/legend-symbols-maplibre/README.md b/packages/svelte/legend-symbols-maplibre/README.md index 288a8a4..26b8a30 100644 --- a/packages/svelte/legend-symbols-maplibre/README.md +++ b/packages/svelte/legend-symbols-maplibre/README.md @@ -1,6 +1,10 @@ # @xtramaps/legend-symbols-maplibre-svelte -Svelte components for generating legend symbols from MapLibre styles. +Svelte helpers for rendering legend symbols from MapLibre GL styles. Wraps [`@xtramaps/legend-symbols-maplibre`](https://github.com/ldproxy/xtramaps/tree/main/packages/core/legend-symbols-maplibre) and converts the virtual DOM tree into HTML strings for use with `{@html}`. + +## Example + +[Live example](https://raw.githack.com/ldproxy/xtramaps/main/packages/svelte/legend-symbols-maplibre/examples/svelte-legend.html) — standalone HTML using the [Daraa topographic style](https://demo.ldproxy.net/daraa/styles/topographic?f=mbs). ## Install @@ -8,14 +12,51 @@ Svelte components for generating legend symbols from MapLibre styles. npm install @xtramaps/legend-symbols-maplibre-svelte ``` -Requires `svelte` as a peer dependency. +Requires `svelte` as a peer dependency (`^5.0.0`). ## Usage -```ts -import { greet } from "@xtramaps/legend-symbols-maplibre-svelte"; +### `createLegend` + +The easiest way to get started. Pass a MapLibre style and get back a function that renders legend symbols by layer id. + +```svelte + + +{#if legend} + {@html legend({ layer: "agriculturesrf" })} + {@html legend({ layer: "settlementsrf.1", zoom: 16 })} + {@html legend({ layer: "annotationpnt", style: "width: 24px; height: 24px" })} +{/if} ``` +### `LegendSymbolSvelte` + +Lower-level function when you need full control over sprite loading and layer objects. + +```svelte + + +{@html LegendSymbolSvelte({ zoom: 14, layer: myLayer, sprite: mySpriteData })} +``` + +Falls back to a raster placeholder icon when the layer type is not supported. + +### Exports + +- `createLegend(style, zoom?)` - Async factory that loads sprites and returns a function accepting `{ layer, zoom?, properties?, style? }` and returning an HTML string. +- `LegendSymbolSvelte` - Lower-level function. Accepts `zoom`, `layer`, `sprite`, `properties`, and an optional `style` prop (CSS string). Returns an HTML string. + ## License MIT diff --git a/packages/svelte/legend-symbols-maplibre/examples/svelte-legend.html b/packages/svelte/legend-symbols-maplibre/examples/svelte-legend.html new file mode 100644 index 0000000..9b8653d --- /dev/null +++ b/packages/svelte/legend-symbols-maplibre/examples/svelte-legend.html @@ -0,0 +1,118 @@ + + + + + + MapLibre Legend Symbols - Svelte Example + + + + + +
+ + + + diff --git a/packages/svelte/legend-symbols-maplibre/package.json b/packages/svelte/legend-symbols-maplibre/package.json index 92cf9c6..9cba8a0 100644 --- a/packages/svelte/legend-symbols-maplibre/package.json +++ b/packages/svelte/legend-symbols-maplibre/package.json @@ -1,7 +1,8 @@ { "name": "@xtramaps/legend-symbols-maplibre-svelte", - "version": "0.0.1", + "version": "0.0.0-next-20260320165054", "license": "MIT", + "homepage": "https://github.com/ldproxy/xtramaps/tree/main/packages/svelte/legend-symbols-maplibre#readme", "repository": { "type": "git", "url": "https://github.com/ldproxy/xtramaps.git", @@ -31,7 +32,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@xtramaps/legend-symbols-maplibre": "^0.0.1" + "@xtramaps/legend-symbols-maplibre": "0.0.0-next-20260320165054" }, "peerDependencies": { "svelte": "^5.0.0" diff --git a/packages/svelte/legend-symbols-maplibre/src/LegendSymbol.ts b/packages/svelte/legend-symbols-maplibre/src/LegendSymbol.ts new file mode 100644 index 0000000..3b80e21 --- /dev/null +++ b/packages/svelte/legend-symbols-maplibre/src/LegendSymbol.ts @@ -0,0 +1,62 @@ +import { + type LegendSymbolProps, + legendSymbol, + rasterSymbol, + type SymbolTree, +} from "@xtramaps/legend-symbols-maplibre"; + +function escapeAttr(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function styleToString(style: unknown): string { + if (typeof style === "string") { + return style; + } + if (typeof style === "object" && style !== null) { + return Object.entries(style as Record) + .map(([k, v]) => { + const prop = k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + return `${prop}: ${v}`; + }) + .join("; "); + } + return ""; +} + +function asHtml(tree: SymbolTree, outerStyle?: string): string { + const { style, ...attributes } = tree.attributes; + + let styleStr = styleToString(style); + + if (outerStyle) { + styleStr = styleStr ? `${styleStr}; ${outerStyle}` : outerStyle; + } + + const attrs = Object.entries(attributes) + .map(([k, v]) => `${k}="${escapeAttr(String(v))}"`) + .join(" "); + + const styleAttr = styleStr ? ` style="${escapeAttr(styleStr)}"` : ""; + const attrStr = attrs ? ` ${attrs}` : ""; + + const children = tree.children + ? tree.children.map((c: SymbolTree) => asHtml(c)).join("") + : ""; + + return `<${tree.element}${attrStr}${styleAttr}>${children}`; +} + +export interface LegendSymbolSvelteProps extends LegendSymbolProps { + style?: string; +} + +export function LegendSymbolSvelte(props: LegendSymbolSvelteProps): string { + const icon = legendSymbol(props); + + return asHtml(icon ?? rasterSymbol, props.style); +} diff --git a/packages/svelte/legend-symbols-maplibre/src/createLegend.ts b/packages/svelte/legend-symbols-maplibre/src/createLegend.ts new file mode 100644 index 0000000..bbd0dee --- /dev/null +++ b/packages/svelte/legend-symbols-maplibre/src/createLegend.ts @@ -0,0 +1,37 @@ +import type { StyleSpecification } from "@maplibre/maplibre-gl-style-spec"; +import { loadSprites } from "@xtramaps/legend-symbols-maplibre"; +import { LegendSymbolSvelte } from "./LegendSymbol"; + +export interface LegendSymbolWrappedProps { + layer: string; + zoom?: number; + properties?: Record; + style?: string; +} + +export const createLegend = async ( + style: StyleSpecification, + zoom?: number, +) => { + const sprites = await loadSprites(style); + + return ({ + layer, + zoom: layerZoom, + properties, + style: cssStyle, + }: LegendSymbolWrappedProps): string => { + const l = style.layers.find((l) => l.id === layer); + if (!l) { + throw new Error(`Layer ${layer} not found in the provided style`); + } + + return LegendSymbolSvelte({ + layer: l, + zoom: zoom || layerZoom || 12, + properties, + style: cssStyle, + sprite: sprites, + }); + }; +}; diff --git a/packages/svelte/legend-symbols-maplibre/src/index.ts b/packages/svelte/legend-symbols-maplibre/src/index.ts index 6d93a58..ffa1bbc 100644 --- a/packages/svelte/legend-symbols-maplibre/src/index.ts +++ b/packages/svelte/legend-symbols-maplibre/src/index.ts @@ -1 +1,4 @@ -export { greet } from "@xtramaps/legend-symbols-maplibre"; +export type { LegendSymbolWrappedProps } from "./createLegend"; +export { createLegend } from "./createLegend"; +export type { LegendSymbolSvelteProps } from "./LegendSymbol"; +export { LegendSymbolSvelte } from "./LegendSymbol"; diff --git a/packages/vue/legend-symbols-maplibre/README.md b/packages/vue/legend-symbols-maplibre/README.md index 6ed1df0..8682eef 100644 --- a/packages/vue/legend-symbols-maplibre/README.md +++ b/packages/vue/legend-symbols-maplibre/README.md @@ -1,6 +1,10 @@ # @xtramaps/legend-symbols-maplibre-vue -Vue components for generating legend symbols from MapLibre styles. +Vue component for rendering legend symbols from MapLibre GL styles. Wraps [`@xtramaps/legend-symbols-maplibre`](https://github.com/ldproxy/xtramaps/tree/main/packages/core/legend-symbols-maplibre) and converts the virtual DOM tree into Vue VNodes. + +## Example + +[Live example](https://raw.githack.com/ldproxy/xtramaps/main/packages/vue/legend-symbols-maplibre/examples/vue-legend.html) — standalone HTML using the [Daraa topographic style](https://demo.ldproxy.net/daraa/styles/topographic?f=mbs). ## Install @@ -8,14 +12,51 @@ Vue components for generating legend symbols from MapLibre styles. npm install @xtramaps/legend-symbols-maplibre-vue ``` -Requires `vue` as a peer dependency. +Requires `vue` as a peer dependency (`^3.0.0`). ## Usage +### `createLegend` + +The easiest way to get started. Pass a MapLibre style and get back a component that renders legend symbols by layer id. + +```vue + + + +``` + +### `LegendSymbolVue` + +Lower-level function when you need full control over sprite loading and layer objects. + ```ts -import { greet } from "@xtramaps/legend-symbols-maplibre-vue"; +import { LegendSymbolVue } from "@xtramaps/legend-symbols-maplibre-vue"; + +// In a render function: +LegendSymbolVue({ zoom: 14, layer: myLayer, sprite: mySpriteData }); ``` +Falls back to a raster placeholder icon when the layer type is not supported. + +### Exports + +- `createLegend(style, zoom?)` - Async factory that loads sprites and returns a Vue component accepting `layer`, `zoom?`, `properties?`, and `style?` props. +- `LegendSymbolVue` - Lower-level function. Accepts `zoom`, `layer`, `sprite`, `properties`, and an optional `style` prop. + ## License MIT diff --git a/packages/vue/legend-symbols-maplibre/examples/vue-legend.html b/packages/vue/legend-symbols-maplibre/examples/vue-legend.html new file mode 100644 index 0000000..3e3dd94 --- /dev/null +++ b/packages/vue/legend-symbols-maplibre/examples/vue-legend.html @@ -0,0 +1,138 @@ + + + + + + MapLibre Legend Symbols - Vue Example + + + + + +
+ + + + diff --git a/packages/vue/legend-symbols-maplibre/package.json b/packages/vue/legend-symbols-maplibre/package.json index 5bb4ace..fba109d 100644 --- a/packages/vue/legend-symbols-maplibre/package.json +++ b/packages/vue/legend-symbols-maplibre/package.json @@ -1,7 +1,8 @@ { "name": "@xtramaps/legend-symbols-maplibre-vue", - "version": "0.0.1", + "version": "0.0.0-next-20260320165054", "license": "MIT", + "homepage": "https://github.com/ldproxy/xtramaps/tree/main/packages/vue/legend-symbols-maplibre#readme", "repository": { "type": "git", "url": "https://github.com/ldproxy/xtramaps.git", @@ -31,7 +32,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@xtramaps/legend-symbols-maplibre": "^0.0.1" + "@xtramaps/legend-symbols-maplibre": "0.0.0-next-20260320165054" }, "peerDependencies": { "vue": "^3.0.0" diff --git a/packages/vue/legend-symbols-maplibre/src/LegendSymbol.ts b/packages/vue/legend-symbols-maplibre/src/LegendSymbol.ts new file mode 100644 index 0000000..a1a9403 --- /dev/null +++ b/packages/vue/legend-symbols-maplibre/src/LegendSymbol.ts @@ -0,0 +1,40 @@ +import { + CSSstring, + camelCase, + type LegendSymbolProps, + legendSymbol, + rasterSymbol, + type SymbolTree, +} from "@xtramaps/legend-symbols-maplibre"; +import { type CSSProperties, h, type VNode } from "vue"; + +function asVue(tree: SymbolTree, outerStyle?: CSSProperties): VNode { + let newStyle: CSSProperties = {}; + const { style, ...attributes } = tree.attributes; + + if (typeof style === "string") { + newStyle = CSSstring(style) as CSSProperties; + } else if (typeof style === "object") { + newStyle = style as CSSProperties; + } + + if (outerStyle) { + newStyle = { ...newStyle, ...outerStyle }; + } + + return h( + tree.element, + { ...camelCase(attributes), style: newStyle }, + tree.children ? tree.children.map((c: SymbolTree) => asVue(c)) : undefined, + ); +} + +export interface LegendSymbolVueProps extends LegendSymbolProps { + style?: CSSProperties; +} + +export function LegendSymbolVue(props: LegendSymbolVueProps) { + const icon = legendSymbol(props); + + return asVue(icon ?? rasterSymbol, props.style); +} diff --git a/packages/vue/legend-symbols-maplibre/src/createLegend.ts b/packages/vue/legend-symbols-maplibre/src/createLegend.ts new file mode 100644 index 0000000..75ec465 --- /dev/null +++ b/packages/vue/legend-symbols-maplibre/src/createLegend.ts @@ -0,0 +1,46 @@ +import type { StyleSpecification } from "@maplibre/maplibre-gl-style-spec"; +import { loadSprites } from "@xtramaps/legend-symbols-maplibre"; +import { type CSSProperties, defineComponent } from "vue"; +import { LegendSymbolVue } from "./LegendSymbol"; + +export interface LegendSymbolWrappedProps { + layer: string; + zoom?: number; + properties?: Record; + style?: CSSProperties; +} + +export const createLegend = async ( + style: StyleSpecification, + zoom?: number, +) => { + const sprites = await loadSprites(style); + + return defineComponent({ + name: "LegendSymbol", + props: { + layer: { type: String, required: true }, + zoom: { type: Number }, + properties: { type: Object as () => Record }, + style: { type: Object as () => CSSProperties }, + }, + setup(props) { + return () => { + const l = style.layers.find((l) => l.id === props.layer); + if (!l) { + throw new Error( + `Layer ${props.layer} not found in the provided style`, + ); + } + + return LegendSymbolVue({ + layer: l, + zoom: zoom || props.zoom || 12, + properties: props.properties, + style: props.style, + sprite: sprites, + }); + }; + }, + }); +}; diff --git a/packages/vue/legend-symbols-maplibre/src/index.ts b/packages/vue/legend-symbols-maplibre/src/index.ts index 6d93a58..09c8637 100644 --- a/packages/vue/legend-symbols-maplibre/src/index.ts +++ b/packages/vue/legend-symbols-maplibre/src/index.ts @@ -1 +1,4 @@ -export { greet } from "@xtramaps/legend-symbols-maplibre"; +export type { LegendSymbolWrappedProps } from "./createLegend"; +export { createLegend } from "./createLegend"; +export type { LegendSymbolVueProps } from "./LegendSymbol"; +export { LegendSymbolVue } from "./LegendSymbol";