From ffd6faa8674cf0e27da4d55fcbb927f3bdaca007 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Mon, 9 Mar 2026 19:07:50 -0400 Subject: [PATCH 01/32] Add a JS API for using the icons on pins & such! --- .gitignore | 4 +- js/README.md | 162 ++++++++++++++++++++ js/cli.js | 82 +++++++++++ js/examples/Makefile | 79 ++++++++++ js/examples/beer-marker-amber.svg | 1 + js/examples/bike-circle-green.svg | 1 + js/examples/burger-marker.svg | 1 + js/examples/bus-circle-blue.svg | 1 + js/examples/cafe-black-stroke.svg | 1 + js/examples/camera-marker-darkgrey.svg | 1 + js/examples/cargobike-square-blue.svg | 1 + js/examples/cargobike.svg | 1 + js/examples/dot.svg | 1 + js/examples/ice_cream-circle-pink.svg | 1 + js/examples/jeep-map_pin-stroke-1.svg | 1 + js/examples/pinstash.svg | 1 + js/examples/pizza-square-red.svg | 1 + js/examples/plane-square-navy.svg | 1 + js/examples/rocket-map_pin-purple.svg | 1 + js/examples/sun-square-yellow.svg | 1 + js/examples/tent-square-brown.svg | 1 + js/examples/tree-map_pin-green.svg | 1 + js/index.js | 196 +++++++++++++++++++++++++ js/package.json | 49 +++++++ 24 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 js/README.md create mode 100755 js/cli.js create mode 100644 js/examples/Makefile create mode 100644 js/examples/beer-marker-amber.svg create mode 100644 js/examples/bike-circle-green.svg create mode 100644 js/examples/burger-marker.svg create mode 100644 js/examples/bus-circle-blue.svg create mode 100644 js/examples/cafe-black-stroke.svg create mode 100644 js/examples/camera-marker-darkgrey.svg create mode 100644 js/examples/cargobike-square-blue.svg create mode 100644 js/examples/cargobike.svg create mode 100644 js/examples/dot.svg create mode 100644 js/examples/ice_cream-circle-pink.svg create mode 100644 js/examples/jeep-map_pin-stroke-1.svg create mode 100644 js/examples/pinstash.svg create mode 100644 js/examples/pizza-square-red.svg create mode 100644 js/examples/plane-square-navy.svg create mode 100644 js/examples/rocket-map_pin-purple.svg create mode 100644 js/examples/sun-square-yellow.svg create mode 100644 js/examples/tent-square-brown.svg create mode 100644 js/examples/tree-map_pin-green.svg create mode 100644 js/index.js create mode 100644 js/package.json diff --git a/.gitignore b/.gitignore index 85feb0ef..dbec9795 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ docs/package.json docs/changelog.json docs/external_sources.json docs/srcicons/ -docs/npm_publish_dates.json \ No newline at end of file +docs/npm_publish_dates.json +node_modules/ +package-lock.json diff --git a/js/README.md b/js/README.md new file mode 100644 index 00000000..d4c5ed5c --- /dev/null +++ b/js/README.md @@ -0,0 +1,162 @@ +# Pinhead JS + +**Pinhead JS** is a utility and library for composing [**Pinhead**](https://pinhead.ink) icons into +various shapes, including pins, markers, circles, and squares. It's designed for map developers who +need flexible, programmatically generated icons for MapLibre GL JS, Leaflet, or any other web-based +mapping platform. + +![](./example_outputs/cafe-black-stroke.svg) +![](./example_outputs/bike-circle-green.svg) +![](./example_outputs/jeep-map_pin-stroke-1.svg) +![](./example_outputs/cargobike-square-blue.svg) +![](./example_outputs/burger-marker.svg) +![](./example_outputs/sun-square-yellow.svg) +![](./example_outputs/plane-square-navy.svg) +![](./example_outputs/ice_cream-circle-pink.svg) +![](./example_outputs/beer-marker-amber.svg) +![](./example_outputs/rocket-map_pin-purple.svg) +![](./example_outputs/pizza-square-red.svg) +![](./example_outputs/bus-circle-blue.svg) +![](./example_outputs/camera-marker-darkgrey.svg) +![](./example_outputs/tree-map_pin-green.svg) +![](./example_outputs/tent-square-brown.svg) + +## Features + +- **Icon Composition:** Layer any **Pinhead** icon onto background shapes. +- **Smart Coloring:** Automatically chooses contrasting icon colors based on the background fill. +- **Multiple Shapes:** Supports `circle`, `square`, `map_pin`, and `marker`. +- **CLI & API:** Use it as a command-line tool for batch processing or as a JavaScript library in your app. +- **Custom SVGs:** Pass raw SVG strings to compose custom icons + +## Installation + +```bash +npm install @waysidemapping/pinhead-js +``` + +## Options + +These options are common across both the CLI and API. + +| Option | Description | Default | +| :------------- | :---------------------------------------------------------------- | :---------------------------------------------------- | +| `cornerRadius` | Corner radius (applies to `square` only) | `4` | +| `fill` | Sets the fill color of the icon | `black` or `white` (auto-calculated from `shapeFill`) | +| `padding` | Internal padding between icon and shape edge | Varies by shape | +| `scale` | Scale factor for the output SVG dimensions | `1` | +| `shape` | Background shape: `square`, `circle`, `map_pin`, or `marker` | `none` | +| `shapeFill` | Fill color of the background shape | `black` | +| `stroke` | Color of the stroke (applies to shape if present, otherwise icon) | Auto-calculated (contrasting or darkened/lightened) | +| `strokeWidth` | Width of the stroke | `1` for `marker`, else `0` | + +--- + +## Usage + +### JavaScript API + +Ideal for dynamic icon generation in the browser or on the server. + +```javascript +import { getSprite } from "@waysidemapping/pinhead-js"; + +// Simple icon +const svg = getSprite("cargobike"); + +// Icon with background and custom colors +const marker = getSprite("jeep", { + shape: "map_pin", + shapeFill: "#6486f5", + strokeWidth: 1, +}); +``` + +#### Examples + +| Result | Code | +| :------------------------------------------- | :-------------------------------------------------------------------------------------------------- | +| ![](./example_outputs/cargobike.svg) | `getSprite("cargobike")` | +| ![](./example_outputs/cafe-black-stroke.svg) | `getSprite("cup_and_saucer", { strokeWidth: 1 })` | +| ![](./example_outputs/bike-circle-green.svg) | `getSprite("bicycle", { shape: "circle", shapeFill: "white", fill: "#6dad6f", stroke: "#6dad6f" })` | +| ![](./example_outputs/burger-marker.svg) | `getSprite("burger", { shape: "marker", shapeFill: "#3FB1CE" })` | +| ![](./example_outputs/ice_cream-circle-pink.svg)| `getSprite("ice_cream_on_cone", { shape: "circle", shapeFill: "pink" })` | +| ![](./example_outputs/rocket-map_pin-purple.svg)| `getSprite("rocketship", { shape: "map_pin", shapeFill: "purple" })` | + +### Command Line Interface (CLI) + +#### 1. Generate a single sprite + +Outputs the SVG string directly to `stdout`. + +```bash +npx pinhead get-sprite cargobike --shape=square --shapeFill='#6486f5' > icon.svg +``` + +#### 2. Batch build from configuration + +The `build-sprites` command creates a collection of SVG files based on a JSON configuration file. By default, it looks for `pinhead.json` and writes results to a `./svgs/` directory. + +```bash +npx pinhead build-sprites --config my-icons.json --outdir ./assets/icons +``` + +**`pinhead.json` structure:** + +```json +{ + "groups": [ + { + "icons": { + "bicycle": "bike-icon", + "bus": "bus-marker" + }, + "options": { + "shape": "circle", + "shapeFill": "#6486f5" + } + } + ] +} +``` + +--- + +## Custom SVG icon requirements + +To work with **Pinhead JS**, custom SVG strings must follow these constraints: + +- Use only `` elements. +- Path elements should only contain the `d` attribute. +- The `viewBox` should be `"0 0 15 15"`, or `height` and `width` should be set to `15`. + +--- + +## Integrations + +### MapLibre GL JS + +To use **Pinhead JS** dynamically with MapLibre: + +```javascript +const svg = getSprite("hospital", { shape: "circle", shapeFill: "red" }); + +const img = new Image(); +const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }); +img.src = URL.createObjectURL(blob); +await img.decode(); + +map.addImage("hospital-icon", img); + +URL.revokeObjectURL(url); +``` + +--- + +## Inspiration + +**Pinhead JS** is inspired by the [Maki Icon Editor](https://labs.mapbox.com/maki-icons/editor/) and [makiwich](https://github.com/mapbox/makiwich) + +## License + +**Pinhead JS** is distributed under [CC0](/LICENSE). diff --git a/js/cli.js b/js/cli.js new file mode 100755 index 00000000..6762052e --- /dev/null +++ b/js/cli.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +import fs from "fs"; +import { parseArgs } from "util"; +import { getSprite } from "./index.js"; + +const commands = { + "get-sprite": { + config: { + options: { + fill: { type: "string" }, + shape: { type: "string" }, + shapeFill: { type: "string" }, + stroke: { type: "string" }, + strokeWidth: { type: "string" }, + padding: { type: "string" }, + cornerRadius: { type: "string" }, + scale: { type: "string" }, + }, + allowPositionals: true, + }, + run: ({ values, positionals }) => { + if (values.strokeWidth) + values.strokeWidth = parseFloat(values.strokeWidth); + if (values.padding) values.padding = parseFloat(values.padding); + if (values.cornerRadius) + values.cornerRadius = parseFloat(values.cornerRadius); + if (values.scale) values.scale = parseFloat(values.scale); + // validate arg + switch (positionals.length) { + case 0: + console.error("No icon name specified!"); + return 1; + case 1: + break; + default: + console.error("More than one icon name specified!"); + return 1; + } + console.log(getSprite(positionals[0], values)); + return 0; + }, + }, + "build-sprites": { + config: { + options: { + config: { type: "string", default: "pinhead.json" }, + outdir: { type: "string", default: "./svgs" }, + }, + }, + run: ({ values }) => { + const config = JSON.parse(fs.readFileSync(values.config)); + fs.mkdirSync(values.outdir, {recursive: true}); + for (const { icons, options } of config.groups) { + for (const [icon, name] of Object.entries(icons)) { + fs.writeFileSync( + `${values.outdir}/${name}.svg`, + getSprite(icon, options), + ); + } + } + return 0; + }, + }, +}; + +if (process.argv.length < 3) { + console.log(`Supported subcommands: ${Object.keys(commands).join(", ")}`); + process.exit(0); +} +const subcommand = process.argv[2]; +const args = process.argv.slice(3); +const command = commands[subcommand]; +if (!command) { + if (subcommand.startsWith("-")) { + console.error(`No subcommand specified`); + } else { + console.error(`Unknown subcommand: ${subcommand}`); + } + process.exit(1); +} +process.exit(command.run(parseArgs({ ...command.config, args }))); diff --git a/js/examples/Makefile b/js/examples/Makefile new file mode 100644 index 00000000..fa8513d4 --- /dev/null +++ b/js/examples/Makefile @@ -0,0 +1,79 @@ +GET_SPRITE := node ../cli.js get-sprite + +SCRIPTS := Makefile ../index.js ../cli.js + +ALL := \ + jeep-map_pin-stroke-1.svg \ + cargobike-square-blue.svg \ + bike-circle-green.svg \ + cafe-black-stroke.svg \ + burger-marker.svg \ + dot.svg \ + cargobike.svg \ + sun-square-yellow.svg \ + plane-square-navy.svg \ + ice_cream-circle-pink.svg \ + beer-marker-amber.svg \ + rocket-map_pin-purple.svg \ + pizza-square-red.svg \ + bus-circle-blue.svg \ + camera-marker-darkgrey.svg \ + tree-map_pin-green.svg \ + tent-square-brown.svg + +all: $(ALL) + +jeep-map_pin-stroke-1.svg: $(SCRIPTS) + $(GET_SPRITE) jeep --shape=map_pin --strokeWidth=1 > $@ + +cargobike-square-blue.svg: $(SCRIPTS) + $(GET_SPRITE) cargobike --shape=square --shapeFill='#6486f5' > $@ + +bike-circle-green.svg: $(SCRIPTS) + $(GET_SPRITE) bicycle --shape=circle --strokeWidth=1 --stroke='#6dad6f' --fill='#6dad6f' --shapeFill="white"> $@ + +burger-marker.svg: $(SCRIPTS) + $(GET_SPRITE) burger --shape=marker --shapeFill="#3FB1CE" > $@ + +cafe-black-stroke.svg: $(SCRIPTS) + $(GET_SPRITE) cup_and_saucer --strokeWidth=1 > $@ + +cargobike.svg: $(SCRIPTS) + $(GET_SPRITE) cargobike > $@ + +dot.svg: $(SCRIPTS) + $(GET_SPRITE) dot > $@ + +sun-square-yellow.svg: $(SCRIPTS) + $(GET_SPRITE) sun --shape=square --cornerRadius=7 --shapeFill='yellow' --strokeWidth=1 > $@ + +plane-square-navy.svg: $(SCRIPTS) + $(GET_SPRITE) plane --shape=square --cornerRadius=0 --shapeFill='navy' > $@ + +ice_cream-circle-pink.svg: $(SCRIPTS) + $(GET_SPRITE) ice_cream_on_cone --shape=circle --shapeFill='pink' > $@ + +beer-marker-amber.svg: $(SCRIPTS) + $(GET_SPRITE) beer_mug_with_foam --shape=marker --shapeFill='#FFBF00' > $@ + +rocket-map_pin-purple.svg: $(SCRIPTS) + $(GET_SPRITE) rocketship --shape=map_pin --shapeFill='purple' > $@ + +pizza-square-red.svg: $(SCRIPTS) + $(GET_SPRITE) pizza_slice --shape=square --shapeFill='red' > $@ + +bus-circle-blue.svg: $(SCRIPTS) + $(GET_SPRITE) bus --shape=circle --shapeFill='blue' > $@ + +camera-marker-darkgrey.svg: $(SCRIPTS) + $(GET_SPRITE) camera --shape=marker --shapeFill='#333' > $@ + +tree-map_pin-green.svg: $(SCRIPTS) + $(GET_SPRITE) conifer_tree --shape=map_pin --shapeFill='darkgreen' > $@ + +tent-square-brown.svg: $(SCRIPTS) + $(GET_SPRITE) a_frame_tent --shape=square --shapeFill='brown' > $@ + +.PHONY: clean +clean: + rm -rf $(ALL) diff --git a/js/examples/beer-marker-amber.svg b/js/examples/beer-marker-amber.svg new file mode 100644 index 00000000..6b33e2ee --- /dev/null +++ b/js/examples/beer-marker-amber.svg @@ -0,0 +1 @@ + diff --git a/js/examples/bike-circle-green.svg b/js/examples/bike-circle-green.svg new file mode 100644 index 00000000..9232dd22 --- /dev/null +++ b/js/examples/bike-circle-green.svg @@ -0,0 +1 @@ + diff --git a/js/examples/burger-marker.svg b/js/examples/burger-marker.svg new file mode 100644 index 00000000..10871667 --- /dev/null +++ b/js/examples/burger-marker.svg @@ -0,0 +1 @@ + diff --git a/js/examples/bus-circle-blue.svg b/js/examples/bus-circle-blue.svg new file mode 100644 index 00000000..768fc95b --- /dev/null +++ b/js/examples/bus-circle-blue.svg @@ -0,0 +1 @@ + diff --git a/js/examples/cafe-black-stroke.svg b/js/examples/cafe-black-stroke.svg new file mode 100644 index 00000000..c14fcddf --- /dev/null +++ b/js/examples/cafe-black-stroke.svg @@ -0,0 +1 @@ + diff --git a/js/examples/camera-marker-darkgrey.svg b/js/examples/camera-marker-darkgrey.svg new file mode 100644 index 00000000..1432c0d8 --- /dev/null +++ b/js/examples/camera-marker-darkgrey.svg @@ -0,0 +1 @@ + diff --git a/js/examples/cargobike-square-blue.svg b/js/examples/cargobike-square-blue.svg new file mode 100644 index 00000000..478fc357 --- /dev/null +++ b/js/examples/cargobike-square-blue.svg @@ -0,0 +1 @@ + diff --git a/js/examples/cargobike.svg b/js/examples/cargobike.svg new file mode 100644 index 00000000..d5f26ea3 --- /dev/null +++ b/js/examples/cargobike.svg @@ -0,0 +1 @@ + diff --git a/js/examples/dot.svg b/js/examples/dot.svg new file mode 100644 index 00000000..cb9dbe97 --- /dev/null +++ b/js/examples/dot.svg @@ -0,0 +1 @@ + diff --git a/js/examples/ice_cream-circle-pink.svg b/js/examples/ice_cream-circle-pink.svg new file mode 100644 index 00000000..763367fd --- /dev/null +++ b/js/examples/ice_cream-circle-pink.svg @@ -0,0 +1 @@ + diff --git a/js/examples/jeep-map_pin-stroke-1.svg b/js/examples/jeep-map_pin-stroke-1.svg new file mode 100644 index 00000000..b8b39694 --- /dev/null +++ b/js/examples/jeep-map_pin-stroke-1.svg @@ -0,0 +1 @@ + diff --git a/js/examples/pinstash.svg b/js/examples/pinstash.svg new file mode 100644 index 00000000..328d24be --- /dev/null +++ b/js/examples/pinstash.svg @@ -0,0 +1 @@ + diff --git a/js/examples/pizza-square-red.svg b/js/examples/pizza-square-red.svg new file mode 100644 index 00000000..2ae1471a --- /dev/null +++ b/js/examples/pizza-square-red.svg @@ -0,0 +1 @@ + diff --git a/js/examples/plane-square-navy.svg b/js/examples/plane-square-navy.svg new file mode 100644 index 00000000..dd0e5ebe --- /dev/null +++ b/js/examples/plane-square-navy.svg @@ -0,0 +1 @@ + diff --git a/js/examples/rocket-map_pin-purple.svg b/js/examples/rocket-map_pin-purple.svg new file mode 100644 index 00000000..7f234493 --- /dev/null +++ b/js/examples/rocket-map_pin-purple.svg @@ -0,0 +1 @@ + diff --git a/js/examples/sun-square-yellow.svg b/js/examples/sun-square-yellow.svg new file mode 100644 index 00000000..c030f430 --- /dev/null +++ b/js/examples/sun-square-yellow.svg @@ -0,0 +1 @@ + diff --git a/js/examples/tent-square-brown.svg b/js/examples/tent-square-brown.svg new file mode 100644 index 00000000..19e27eaa --- /dev/null +++ b/js/examples/tent-square-brown.svg @@ -0,0 +1 @@ + diff --git a/js/examples/tree-map_pin-green.svg b/js/examples/tree-map_pin-green.svg new file mode 100644 index 00000000..c796fc02 --- /dev/null +++ b/js/examples/tree-map_pin-green.svg @@ -0,0 +1 @@ + diff --git a/js/index.js b/js/index.js new file mode 100644 index 00000000..f8523b3b --- /dev/null +++ b/js/index.js @@ -0,0 +1,196 @@ +import index from "@waysidemapping/pinhead/dist/icons/index.complete.json" with { type: "json" }; +import tinycolor from "tinycolor2"; + +// minifiy is a JS template tag function to strip whitespace from XML +function minify(strings, ...values) { + // Interleave the strings and values + let output = strings.reduce((acc, part, i) => { + return acc + part + (values[i] !== undefined ? values[i] : ""); + }, ""); + + // remove newlines + output = output.replace(/\n/g, " "); + // trim + output = output.trim(); + // remove double spaces + output = output.replace(/\s\s+/g, " "); + // remove spaces around > and < and /> + output = output.replace(/\s*(\/?>|<)\s*/g, "$1"); + return output; +} + +// Hard coding for 15x15 requirement for Pinhead icons +const size = 15; + +const defaultPadding = { + map_pin: 4, + circle: 2, + square: 2, + marker: 5, +}; + +export function getSprite(name, properties = {}) { + let iconSvg; + if (name.includes("`; + + switch (true) { + case shape === "circle": + cornerRadius = (size + 2 * padding) / 2; + case shape === "square": + const rectSize = size + 2 * padding; + if (strokeWidth) { + svg += minify``; + } + svg += minify``; + break; + case shape === "marker": + svg += minify` + + + + + + + + + `; + case shape === "map_pin": + const d = + "M 11.5,31 C 19.166667,22.234183 23,15.734183 23,11.5 23,5.1487254 17.851275,0 11.5,0 5.1487254,0 0,5.1487254 0,11.5 0,15.734183 3.8333333,22.234183 11.5,31 Z"; + let scaleTransform = ""; + if (bgScale !== 1) { + scaleTransform = `scale(${bgScale}) `; + } + if (strokeWidth) { + svg += minify``; + } + svg += minify``; + break; + default: + // Nothing to do when not drawing a shape + break; + } + for (const path of paths) { + if (!shape && strokeWidth) { + svg += minify``; + } + svg += minify``; + } + svg += ""; + return svg; +} diff --git a/js/package.json b/js/package.json new file mode 100644 index 00000000..2a7fe680 --- /dev/null +++ b/js/package.json @@ -0,0 +1,49 @@ +{ + "name": "@waysidemapping/pinhead-js", + "version": "1.14.0-dev", + "type": "module", + "main": "index.js", + "description": "Quality public domain sprites for your map", + "license": "CC0-1.0", + "keywords": [ + "icons", + "maps", + "cartography", + "public domain" + ], + "homepage": "https://pinhead.ink", + "repository": "github:waysidemapping/pinhead", + "bugs": { + "url": "https://github.com/waysidemapping/pinhead/issues" + }, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/quincylvania" + }, + { + "type": "Ko-Fi", + "url": "https://ko-fi.com/waysidemapping" + } + ], + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "bin": { + "pinhead": "cli.js" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "format": "prettier --write *.js README.md" + }, + "dependencies": { + "@waysidemapping/pinhead": "^14.0.0", + "tinycolor2": "^1.6.0" + }, + "devDependencies": { + "prettier": "^3.8.1" + } +} From e13040e8f78aad861097ea78a8744803e8fcc2b8 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Mon, 9 Mar 2026 19:18:46 -0400 Subject: [PATCH 02/32] Update README.md --- js/README.md | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/js/README.md b/js/README.md index d4c5ed5c..633868ca 100644 --- a/js/README.md +++ b/js/README.md @@ -5,21 +5,21 @@ various shapes, including pins, markers, circles, and squares. It's designed for need flexible, programmatically generated icons for MapLibre GL JS, Leaflet, or any other web-based mapping platform. -![](./example_outputs/cafe-black-stroke.svg) -![](./example_outputs/bike-circle-green.svg) -![](./example_outputs/jeep-map_pin-stroke-1.svg) -![](./example_outputs/cargobike-square-blue.svg) -![](./example_outputs/burger-marker.svg) -![](./example_outputs/sun-square-yellow.svg) -![](./example_outputs/plane-square-navy.svg) -![](./example_outputs/ice_cream-circle-pink.svg) -![](./example_outputs/beer-marker-amber.svg) -![](./example_outputs/rocket-map_pin-purple.svg) -![](./example_outputs/pizza-square-red.svg) -![](./example_outputs/bus-circle-blue.svg) -![](./example_outputs/camera-marker-darkgrey.svg) -![](./example_outputs/tree-map_pin-green.svg) -![](./example_outputs/tent-square-brown.svg) +![](./examples/cafe-black-stroke.svg) +![](./examples/bike-circle-green.svg) +![](./examples/jeep-map_pin-stroke-1.svg) +![](./examples/cargobike-square-blue.svg) +![](./examples/burger-marker.svg) +![](./examples/sun-square-yellow.svg) +![](./examples/plane-square-navy.svg) +![](./examples/ice_cream-circle-pink.svg) +![](./examples/beer-marker-amber.svg) +![](./examples/rocket-map_pin-purple.svg) +![](./examples/pizza-square-red.svg) +![](./examples/bus-circle-blue.svg) +![](./examples/camera-marker-darkgrey.svg) +![](./examples/tree-map_pin-green.svg) +![](./examples/tent-square-brown.svg) ## Features @@ -77,11 +77,11 @@ const marker = getSprite("jeep", { | Result | Code | | :------------------------------------------- | :-------------------------------------------------------------------------------------------------- | | ![](./example_outputs/cargobike.svg) | `getSprite("cargobike")` | -| ![](./example_outputs/cafe-black-stroke.svg) | `getSprite("cup_and_saucer", { strokeWidth: 1 })` | -| ![](./example_outputs/bike-circle-green.svg) | `getSprite("bicycle", { shape: "circle", shapeFill: "white", fill: "#6dad6f", stroke: "#6dad6f" })` | -| ![](./example_outputs/burger-marker.svg) | `getSprite("burger", { shape: "marker", shapeFill: "#3FB1CE" })` | -| ![](./example_outputs/ice_cream-circle-pink.svg)| `getSprite("ice_cream_on_cone", { shape: "circle", shapeFill: "pink" })` | -| ![](./example_outputs/rocket-map_pin-purple.svg)| `getSprite("rocketship", { shape: "map_pin", shapeFill: "purple" })` | +| ![](./examples/cafe-black-stroke.svg) | `getSprite("cup_and_saucer", { strokeWidth: 1 })` | +| ![](./examples/bike-circle-green.svg) | `getSprite("bicycle", { shape: "circle", shapeFill: "white", fill: "#6dad6f", stroke: "#6dad6f" })` | +| ![](./examples/burger-marker.svg) | `getSprite("burger", { shape: "marker", shapeFill: "#3FB1CE" })` | +| ![](./examples/ice_cream-circle-pink.svg)| `getSprite("ice_cream_on_cone", { shape: "circle", shapeFill: "pink" })` | +| ![](./examples/rocket-map_pin-purple.svg)| `getSprite("rocketship", { shape: "map_pin", shapeFill: "purple" })` | ### Command Line Interface (CLI) From 7b3714135619eb8b9fda3e430e725a1f1178af6e Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Mon, 9 Mar 2026 19:19:52 -0400 Subject: [PATCH 03/32] Update README.md --- js/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/README.md b/js/README.md index 633868ca..241c0798 100644 --- a/js/README.md +++ b/js/README.md @@ -76,7 +76,7 @@ const marker = getSprite("jeep", { | Result | Code | | :------------------------------------------- | :-------------------------------------------------------------------------------------------------- | -| ![](./example_outputs/cargobike.svg) | `getSprite("cargobike")` | +| ![](./examples/cargobike.svg) | `getSprite("cargobike")` | | ![](./examples/cafe-black-stroke.svg) | `getSprite("cup_and_saucer", { strokeWidth: 1 })` | | ![](./examples/bike-circle-green.svg) | `getSprite("bicycle", { shape: "circle", shapeFill: "white", fill: "#6dad6f", stroke: "#6dad6f" })` | | ![](./examples/burger-marker.svg) | `getSprite("burger", { shape: "marker", shapeFill: "#3FB1CE" })` | From 572b4a3b6d13241c35ff491c59e86a8172f30ac3 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 19:15:06 -0400 Subject: [PATCH 04/32] test covg! --- .github/workflows/test-js.yml | 16 ++ .gitignore | 1 + js/README.md | 16 +- js/cli.js | 2 +- js/examples/Makefile | 79 ---------- js/examples/generateExamples.js | 11 ++ js/index.js | 20 ++- js/package.json | 7 +- js/test/examples.js | 92 ++++++++++++ js/test/fixtures/beer-marker-amber.png | Bin 0 -> 6287 bytes js/test/fixtures/bike-circle-green.png | Bin 0 -> 3411 bytes js/test/fixtures/burger-marker.png | Bin 0 -> 6088 bytes js/test/fixtures/bus-circle-blue.png | Bin 0 -> 1591 bytes js/test/fixtures/cafe-black-stroke.png | Bin 0 -> 1306 bytes js/test/fixtures/camera-marker-darkgrey.png | Bin 0 -> 6437 bytes js/test/fixtures/cargobike-square-blue.png | Bin 0 -> 1744 bytes js/test/fixtures/cargobike.png | Bin 0 -> 1013 bytes js/test/fixtures/dot.png | Bin 0 -> 444 bytes js/test/fixtures/ice_cream-circle-pink.png | Bin 0 -> 1986 bytes js/test/fixtures/jeep-map_pin-stroke-1.png | Bin 0 -> 3829 bytes js/test/fixtures/pizza-square-red.png | Bin 0 -> 1677 bytes js/test/fixtures/plane-square-navy.png | Bin 0 -> 1032 bytes js/test/fixtures/rocket-map_pin-purple.png | Bin 0 -> 2528 bytes js/test/fixtures/sun-square-yellow.png | Bin 0 -> 2811 bytes js/test/fixtures/tent-square-brown.png | Bin 0 -> 1629 bytes js/test/fixtures/tree-map_pin-green.png | Bin 0 -> 2263 bytes js/test/generateFixtures.js | 15 ++ js/test/getSprite.test.js | 153 ++++++++++++++++++++ js/test/getSvgPathStrings.test.js | 79 ++++++++++ 29 files changed, 397 insertions(+), 94 deletions(-) create mode 100644 .github/workflows/test-js.yml delete mode 100644 js/examples/Makefile create mode 100644 js/examples/generateExamples.js create mode 100644 js/test/examples.js create mode 100644 js/test/fixtures/beer-marker-amber.png create mode 100644 js/test/fixtures/bike-circle-green.png create mode 100644 js/test/fixtures/burger-marker.png create mode 100644 js/test/fixtures/bus-circle-blue.png create mode 100644 js/test/fixtures/cafe-black-stroke.png create mode 100644 js/test/fixtures/camera-marker-darkgrey.png create mode 100644 js/test/fixtures/cargobike-square-blue.png create mode 100644 js/test/fixtures/cargobike.png create mode 100644 js/test/fixtures/dot.png create mode 100644 js/test/fixtures/ice_cream-circle-pink.png create mode 100644 js/test/fixtures/jeep-map_pin-stroke-1.png create mode 100644 js/test/fixtures/pizza-square-red.png create mode 100644 js/test/fixtures/plane-square-navy.png create mode 100644 js/test/fixtures/rocket-map_pin-purple.png create mode 100644 js/test/fixtures/sun-square-yellow.png create mode 100644 js/test/fixtures/tent-square-brown.png create mode 100644 js/test/fixtures/tree-map_pin-green.png create mode 100644 js/test/generateFixtures.js create mode 100644 js/test/getSprite.test.js create mode 100644 js/test/getSvgPathStrings.test.js diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml new file mode 100644 index 00000000..e93376fb --- /dev/null +++ b/.github/workflows/test-js.yml @@ -0,0 +1,16 @@ +name: Run JS Tests + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + - run: npm --prefix js ci + - run: npm --prefix js test + diff --git a/.gitignore b/.gitignore index dbec9795..599cc782 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ docs/srcicons/ docs/npm_publish_dates.json node_modules/ package-lock.json +js/test/fixtures/*-diff.png diff --git a/js/README.md b/js/README.md index d4c5ed5c..30b90cbe 100644 --- a/js/README.md +++ b/js/README.md @@ -74,14 +74,14 @@ const marker = getSprite("jeep", { #### Examples -| Result | Code | -| :------------------------------------------- | :-------------------------------------------------------------------------------------------------- | -| ![](./example_outputs/cargobike.svg) | `getSprite("cargobike")` | -| ![](./example_outputs/cafe-black-stroke.svg) | `getSprite("cup_and_saucer", { strokeWidth: 1 })` | -| ![](./example_outputs/bike-circle-green.svg) | `getSprite("bicycle", { shape: "circle", shapeFill: "white", fill: "#6dad6f", stroke: "#6dad6f" })` | -| ![](./example_outputs/burger-marker.svg) | `getSprite("burger", { shape: "marker", shapeFill: "#3FB1CE" })` | -| ![](./example_outputs/ice_cream-circle-pink.svg)| `getSprite("ice_cream_on_cone", { shape: "circle", shapeFill: "pink" })` | -| ![](./example_outputs/rocket-map_pin-purple.svg)| `getSprite("rocketship", { shape: "map_pin", shapeFill: "purple" })` | +| Result | Code | +| :----------------------------------------------- | :-------------------------------------------------------------------------------------------------- | +| ![](./example_outputs/cargobike.svg) | `getSprite("cargobike")` | +| ![](./example_outputs/cafe-black-stroke.svg) | `getSprite("cup_and_saucer", { strokeWidth: 1 })` | +| ![](./example_outputs/bike-circle-green.svg) | `getSprite("bicycle", { shape: "circle", shapeFill: "white", fill: "#6dad6f", stroke: "#6dad6f" })` | +| ![](./example_outputs/burger-marker.svg) | `getSprite("burger", { shape: "marker", shapeFill: "#3FB1CE" })` | +| ![](./example_outputs/ice_cream-circle-pink.svg) | `getSprite("ice_cream_on_cone", { shape: "circle", shapeFill: "pink" })` | +| ![](./example_outputs/rocket-map_pin-purple.svg) | `getSprite("rocketship", { shape: "map_pin", shapeFill: "purple" })` | ### Command Line Interface (CLI) diff --git a/js/cli.js b/js/cli.js index 6762052e..1e968a9d 100755 --- a/js/cli.js +++ b/js/cli.js @@ -50,7 +50,7 @@ const commands = { }, run: ({ values }) => { const config = JSON.parse(fs.readFileSync(values.config)); - fs.mkdirSync(values.outdir, {recursive: true}); + fs.mkdirSync(values.outdir, { recursive: true }); for (const { icons, options } of config.groups) { for (const [icon, name] of Object.entries(icons)) { fs.writeFileSync( diff --git a/js/examples/Makefile b/js/examples/Makefile deleted file mode 100644 index fa8513d4..00000000 --- a/js/examples/Makefile +++ /dev/null @@ -1,79 +0,0 @@ -GET_SPRITE := node ../cli.js get-sprite - -SCRIPTS := Makefile ../index.js ../cli.js - -ALL := \ - jeep-map_pin-stroke-1.svg \ - cargobike-square-blue.svg \ - bike-circle-green.svg \ - cafe-black-stroke.svg \ - burger-marker.svg \ - dot.svg \ - cargobike.svg \ - sun-square-yellow.svg \ - plane-square-navy.svg \ - ice_cream-circle-pink.svg \ - beer-marker-amber.svg \ - rocket-map_pin-purple.svg \ - pizza-square-red.svg \ - bus-circle-blue.svg \ - camera-marker-darkgrey.svg \ - tree-map_pin-green.svg \ - tent-square-brown.svg - -all: $(ALL) - -jeep-map_pin-stroke-1.svg: $(SCRIPTS) - $(GET_SPRITE) jeep --shape=map_pin --strokeWidth=1 > $@ - -cargobike-square-blue.svg: $(SCRIPTS) - $(GET_SPRITE) cargobike --shape=square --shapeFill='#6486f5' > $@ - -bike-circle-green.svg: $(SCRIPTS) - $(GET_SPRITE) bicycle --shape=circle --strokeWidth=1 --stroke='#6dad6f' --fill='#6dad6f' --shapeFill="white"> $@ - -burger-marker.svg: $(SCRIPTS) - $(GET_SPRITE) burger --shape=marker --shapeFill="#3FB1CE" > $@ - -cafe-black-stroke.svg: $(SCRIPTS) - $(GET_SPRITE) cup_and_saucer --strokeWidth=1 > $@ - -cargobike.svg: $(SCRIPTS) - $(GET_SPRITE) cargobike > $@ - -dot.svg: $(SCRIPTS) - $(GET_SPRITE) dot > $@ - -sun-square-yellow.svg: $(SCRIPTS) - $(GET_SPRITE) sun --shape=square --cornerRadius=7 --shapeFill='yellow' --strokeWidth=1 > $@ - -plane-square-navy.svg: $(SCRIPTS) - $(GET_SPRITE) plane --shape=square --cornerRadius=0 --shapeFill='navy' > $@ - -ice_cream-circle-pink.svg: $(SCRIPTS) - $(GET_SPRITE) ice_cream_on_cone --shape=circle --shapeFill='pink' > $@ - -beer-marker-amber.svg: $(SCRIPTS) - $(GET_SPRITE) beer_mug_with_foam --shape=marker --shapeFill='#FFBF00' > $@ - -rocket-map_pin-purple.svg: $(SCRIPTS) - $(GET_SPRITE) rocketship --shape=map_pin --shapeFill='purple' > $@ - -pizza-square-red.svg: $(SCRIPTS) - $(GET_SPRITE) pizza_slice --shape=square --shapeFill='red' > $@ - -bus-circle-blue.svg: $(SCRIPTS) - $(GET_SPRITE) bus --shape=circle --shapeFill='blue' > $@ - -camera-marker-darkgrey.svg: $(SCRIPTS) - $(GET_SPRITE) camera --shape=marker --shapeFill='#333' > $@ - -tree-map_pin-green.svg: $(SCRIPTS) - $(GET_SPRITE) conifer_tree --shape=map_pin --shapeFill='darkgreen' > $@ - -tent-square-brown.svg: $(SCRIPTS) - $(GET_SPRITE) a_frame_tent --shape=square --shapeFill='brown' > $@ - -.PHONY: clean -clean: - rm -rf $(ALL) diff --git a/js/examples/generateExamples.js b/js/examples/generateExamples.js new file mode 100644 index 00000000..e391bb3f --- /dev/null +++ b/js/examples/generateExamples.js @@ -0,0 +1,11 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getSprite } from "../index.js"; + +import { examples } from "../test/examples.js"; + +for (const example of examples) { + console.log(`Generating fixture for ${example.name}...`); + const svg = getSprite(example.icon, example.properties); + writeFileSync(join("examples", `${example.name}.svg`), Buffer.from(svg)); +} diff --git a/js/index.js b/js/index.js index f8523b3b..a102409a 100644 --- a/js/index.js +++ b/js/index.js @@ -29,6 +29,16 @@ const defaultPadding = { marker: 5, }; +function getSvgPathStrings(iconSvg) { + const paths = []; + for (const { groups } of iconSvg.matchAll( + /]+?d="(?[^"]+?)"/gm, + )) { + paths.push(groups.path); + } + return paths; +} + export function getSprite(name, properties = {}) { let iconSvg; if (name.includes(" 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 FV1mz?F=qe( literal 0 HcmV?d00001 diff --git a/js/test/fixtures/bike-circle-green.png b/js/test/fixtures/bike-circle-green.png new file mode 100644 index 0000000000000000000000000000000000000000..2c3a426a1cfd1072caa7978c797fbfa534bed7e1 GIT binary patch literal 3411 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 p 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 O00002wmZlcSMc&G@sB& z3Rw_Kq3@KE&<7{5VOb<1pV765L=u)%L}p0MOC=Bv1t33~zMyM44qruNPI(EvDaMB7 zM%|&2bc0w#ZYVRMH^IoTtVi=NnxHN+L${Q>qHl#mEy*rv{zE;~Wmf1PN?y=6#-SEu zdiqV!DKVpLPSa*IG%UaR-J=0IMG|yH`Kfr)z_5H`|ABhwlv$xM79{kwama%=W7dWh z8e>C3Ukit7^5)FiutH<3NazpZPz~yv6-BIAzpxM+F~JSwC?ttU9J6wrv;0q`4l6Xq zj)cAvhYpZW@IO%xRdRR7C3C_?hnUS+x!$mGm=zjgNh%f8G3+PJ09EpnPaW%sIz$|E z_>83v36@~mejKVGo6&qhmFTyqr(u`T)3fH$&nMm9PgWR~ui)=dB~c{u_$Vhqzh8S! zyTY0U{VN>WL4I$jHfUeTDI8rJJdUyz>SK@gFK}oF^-e{lJa>Kn&NH2BMqJ5J>Gebw z3z3Tp{#c_|&*6}bdd#tBLjQsthUH5LY^X<;(QNVY z>RQ$~v?gECKO!#-5(Tch zr9}1x90LEzeuGg$!7$8kNOZCeZ+1E=h6x41Fuz-(r;ZKCVqsB?yfI8D2!{DBi5@vN zpuF4qlp|Xt6a?G+oQ7v3DW0Y_!Zsm$jPn~4o=w=eLya&_$Q(O2TQ`6t?)M(CuELsektMQUHVau?$QIqDB@|tqrAX5niddV zF4>QAbR6>w!qxFj)2FTX_mcmhyiMl}>pJg-d17H$Dk5j75r3IsPlVepE4q%O;ub6k ze>cp7CE>QWMqS5IaSLtM%9nU$MW-N&wIX0mA#Qzr=c1!fsu>p2Jv-zGPZSftig<(UYH$Sr} z%~t$n!_t&Jv+MG=u9L97OBO|9itl*N(fIR3WF+9wnr5eE*)Tn37)z7U&$EA`j@2@>B7op1QIhF1nZF4lG+?BM%$gmCh1!0CRP#0LD31ueqCO8y;+_(w)jE=!KG^M;$ zjBzL+`PK9iP0$$%$rGBP1!biY2#5X<`B8U?Mq+o$E>SFnNeO*$96A#DTSEiXLnA4O p)N$f8)?C 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 literal 0 HcmV?d00001 diff --git a/js/test/fixtures/cargobike-square-blue.png b/js/test/fixtures/cargobike-square-blue.png new file mode 100644 index 0000000000000000000000000000000000000000..4f74b2ebdbb2bf2f95da073d713a3fcd639879f5 GIT binary patch literal 1744 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 literal 0 HcmV?d00001 diff --git a/js/test/fixtures/dot.png b/js/test/fixtures/dot.png new file mode 100644 index 0000000000000000000000000000000000000000..1b7af9d5a8f4cbfecef2d444dabe76fdb298c9eb GIT binary patch literal 444 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw1|+Ti+$>^XU~KkuaSW-L^Y+Gl-$Mx^Z6CkS zc5q)I+#$V#v6SgFn?a)mV@|@%hE0vq48k`A6PRjeo$3vrFz@lv>z`HjF9~lk|2uh^ zr?NKN^EjS!dnM*n8(Mr%DtLGF!JY1dJH?xe_1UaLJ!UJJ-OO`LmGCsw@4nmw2}JcyCyDL`-b&g2u>e&D@^V zcMdNSo_FEY3YODZp6fT{G<&(JMud9&YI5h#NDXKTEnf5BB2zZcxuSw*O`BT_k37&} ze{b06&Tf-fFs*t2!u@Fec;xC;k#`VYoVb^F5jAgD8Hd;Goh5F@+I({0$xMJG+$ z_0MtJ!JxM9Z|)2ITkP~#vOcW-PscjtsIT)grMDF6C59k{2x?IE=6&FQd)~_S`O)$V Qz*u4MboFyt=akR{02&s_lK=n! literal 0 HcmV?d00001 diff --git a/js/test/fixtures/ice_cream-circle-pink.png b/js/test/fixtures/ice_cream-circle-pink.png new file mode 100644 index 0000000000000000000000000000000000000000..cd7e8eeb1da74d3899cfc6de15468aa81916d219 GIT binary patch literal 1986 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 bh(qxTce;kk(z=T800000NkvXXu0mjf5R4DV literal 0 HcmV?d00001 diff --git a/js/test/fixtures/tree-map_pin-green.png b/js/test/fixtures/tree-map_pin-green.png new file mode 100644 index 0000000000000000000000000000000000000000..2b5cfadfeaf6fefb7035382a1af416e903572735 GIT binary patch literal 2263 zcmV;|2q^c7P)T5S)5zZju{i%wjO+e;Y%%T#vY(U=KsCRbd$6Nd&imS7DPF5{9AR2VfXs*uX=6 z^)-rKjo)+wAxX;5A?{%p|K{2ghT$F& z{SBV-8|*+M1`)o8z7O$+;STMhvv?417;f00XrPDsF=6bm#`pPd9mBB82D=P{17>vt zbtb9t>l7cuB8Fj)4Q%tZ%eYNQwr}y>3U-7`t^OL%xN0(PBO~0yBOA5dFqY$YZ1KKj z3GvrUhhl;^SYrOeR&oiJ6TZpr<9sUn?qDc=kKIf~stdo1vQw6jUQPHWMc1Xbc7^op zdlX+1!zjmH&g?7|GQFJe*RrouQhIUWNjD;`UvLgO$3H0{1lWnGd)HQHw)jy$IR{q0#Q_UiVEp6W!%F%T+PKW=Cd)KWj|#Eq4RDB z2kQr1m1q569npso0@7!e_b`X~!jrD#W3Ga*jO>)a(sR2O!?@1Iw2^5;C@b7+-yN+-c>L~TuKY3haDdK1 z2j0l^<%W?EU>bP%$3r}9KIF$Eolh0?tk5QC4<} zV(EPf@1??H0PitWu(rAeIdztmog&iZba{Ak$WLk*Vjrzk_&HxyU}JEV5_o`hoqZTX z*)1rk-!^5ZsFH55YC`ni^N=Y%^`yyO+mWK@Q z$fhW=Ws~%QY@#AvHq}53M0kt<9g4^egvr5CiwPLT}veJb@TJj6J55l5pVto?J1D?hvzew&3$NfGJ3=HH7r z{rq$)+jmJB1JGe0gVg$Ge|U!0Pg*EK{34!igi@PTV(^g&kKws3EQ0*sAhH`jY@iGx zTcnG8g|ICQaBFQ5Pk#OcJHOrGsyf~OcCq`xE?24;i0~c)&r(vPglzwM+uB@3!BIX- zU(K{oN{Gx=Y@a@Z9B}fOP74D(ZIwml;M>31o@TfUB*VAf-wHn$zlV1W-{g+Z7=bz! z5#9Yomn-)~&m(O8Z)^Gk2*4@!(MW|y@Vs%#Gbz%A*e)T9kinIdm{8BAB*H!a!mISv zBHL97S%hS`Y^JiQdgJ03XcOUGtl*33gFw!C`pc*2{i+8I?EHUcTHC$w1H6SDOcLRO z<2yf+&C-SVy1P@9_(eP|F_QHTIl?}>K{6}6%hzp&b)@B!5>ocm=nIi8(nY=#F3JhW zrEo{>p|pIqAAN9zvqvh}4yF&~8^$FL%F4^#m(y}m7tH2WB^G#9PK2xfx`R{aWOsu5 zZ{Fvs9#|L7)8*`6nz;V2TRsHICK+`i_0rG2Iz#vUuI-X?%It1n2E(|DqG8r7=X>XT z&BuCX+M!S3J7ri%Eub6+80gn2MsG;xyv%7-~^RH52me)@BOe;7` z{#7cw^;R?CS6abY@~=|SbK^qIgtsX?#vE?GN<~>-BjH2JZXt)8uToK#*G70w?M_*I zl}b96ng|~-yF0jn#aF4MW2uGk1=%eWyGjLFu3mWNuSVoi>?)HxLSq2I1|rIZpYc@L z$et?aVHm|wI%82npS8P(0u&c6Se_)_5>6?*fr1!DaU9IcYfon0y@+zc1x1G;1>_## z^C&MoX%k0W-Dlio&e#Sv7qx*|45K`+H&CI^?Czl?)r1RodjmbC>;@`e7}ZcTX9+-? z!edmRx^Ths-#Ju4!4qX3%Lz|Tpd+pxFzy1UJW)28W)rR}v7B(>Zk3kGr6gr};lf=g zElZY?6g$Fie`#5|l%&`ZF5K?YvUDj)u_s)(jitp>DM_&_T-ao3Imb`Asy_xFQFfCn zYq${Jr0^52>W{%Hg^#$hhJE3J!{4Ux$QJAN4&SZfMz~P#G47DETX29o;Yq7FM*YUP zh3`*R!7$uW&sMR`?2h3Gx59;b&CdIj-Gf8i3m59qDh?^T1;_XiuK%`NrQ2myFbqHF zzT@9!cE|95KjDJTdfun(9z5Y!xS*>)A5wM;9`P?+Pk(N0WCY*2~%yZ`h*6j`g5mvb1^HAhqzFn5^!u@TcPKQ!OF^ur|*hB+u zl38>J7hGtzt$}6vL(+$9JlsTXg*(u_XWi002ovPDHLkV1n^HT0Q^( literal 0 HcmV?d00001 diff --git a/js/test/generateFixtures.js b/js/test/generateFixtures.js new file mode 100644 index 00000000..a18a5b4b --- /dev/null +++ b/js/test/generateFixtures.js @@ -0,0 +1,15 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getSprite } from "../index.js"; +import { Resvg } from "@resvg/resvg-js"; + +import { examples } from "./examples.js"; + +for (const example of examples) { + console.log(`Generating fixture for ${example.name}...`); + const svg = getSprite(example.icon, example.properties); + const resvg = new Resvg(svg, { fitTo: { mode: "zoom", value: 4 } }); + const pngData = resvg.render(); + const pngBuffer = pngData.asPng(); + writeFileSync(join("test", "fixtures", `${example.name}.png`), pngBuffer); +} diff --git a/js/test/getSprite.test.js b/js/test/getSprite.test.js new file mode 100644 index 00000000..f9ca4593 --- /dev/null +++ b/js/test/getSprite.test.js @@ -0,0 +1,153 @@ +import fs from "node:fs"; +import { test } from "node:test"; +import assert from "node:assert"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { getSprite } from "../index.js"; +import { Resvg } from "@resvg/resvg-js"; +import pixelmatch from "pixelmatch"; +import { PNG } from "pngjs"; + +export const examples = [ + { + name: "jeep-map_pin-stroke-1", + icon: "jeep", + properties: { shape: "map_pin", strokeWidth: 1 }, + }, + { + name: "cargobike-square-blue", + icon: "cargobike", + properties: { shape: "square", shapeFill: "#6486f5" }, + }, + { + name: "bike-circle-green", + icon: "bicycle", + properties: { + shape: "circle", + strokeWidth: 1, + stroke: "#6dad6f", + fill: "#6dad6f", + shapeFill: "white", + }, + }, + { + name: "burger-marker", + icon: "burger", + properties: { shape: "marker", shapeFill: "#3FB1CE" }, + }, + { + name: "cafe-black-stroke", + icon: "cup_and_saucer", + properties: { strokeWidth: 1 }, + }, + { name: "cargobike", icon: "cargobike", properties: {} }, + { name: "dot", icon: "dot", properties: {} }, + { + name: "sun-square-yellow", + icon: "sun", + properties: { + shape: "square", + cornerRadius: 7, + shapeFill: "yellow", + strokeWidth: 1, + }, + }, + { + name: "plane-square-navy", + icon: "plane", + properties: { shape: "square", cornerRadius: 0, shapeFill: "navy" }, + }, + { + name: "ice_cream-circle-pink", + icon: "ice_cream_on_cone", + properties: { shape: "circle", shapeFill: "pink" }, + }, + { + name: "beer-marker-amber", + icon: "beer_mug_with_foam", + properties: { shape: "marker", shapeFill: "#FFBF00" }, + }, + { + name: "rocket-map_pin-purple", + icon: "rocketship", + properties: { shape: "map_pin", shapeFill: "purple" }, + }, + { + name: "pizza-square-red", + icon: "pizza_slice", + properties: { shape: "square", shapeFill: "red" }, + }, + { + name: "bus-circle-blue", + icon: "bus", + properties: { shape: "circle", shapeFill: "blue" }, + }, + { + name: "camera-marker-darkgrey", + icon: "camera", + properties: { shape: "marker", shapeFill: "#333" }, + }, + { + name: "tree-map_pin-green", + icon: "conifer_tree", + properties: { shape: "map_pin", shapeFill: "darkgreen" }, + }, + { + name: "tent-square-brown", + icon: "a_frame_tent", + properties: { shape: "square", shapeFill: "brown" }, + }, +]; + +for (const example of examples) { + test(`getSprite matches fixture for ${example.name}`, () => { + const svg = getSprite(example.icon, example.properties); + const resvg = new Resvg(svg, { fitTo: { mode: "zoom", value: 4 } }); + const rendered = resvg.render(); + // get pixels from writing & reading PNG for apples-to-apples comparison instead of using rendered.pixels + const currentPixels = PNG.sync.read(rendered.asPng()).data; + const { width, height } = rendered; + + const fixtureBuffer = readFileSync( + join("test", "fixtures", `${example.name}.png`), + ); + const fixturePng = PNG.sync.read(fixtureBuffer); + + assert.strictEqual( + width, + fixturePng.width, + `Width mismatch for ${example.name}`, + ); + assert.strictEqual( + height, + fixturePng.height, + `Height mismatch for ${example.name}`, + ); + + const threshold = 0.1; + + const diff = new PNG({ height, width }); + const numDiffPixels = pixelmatch( + currentPixels, + fixturePng.data, + diff.data, + width, + height, + { threshold }, + ); + + const totalPixels = width * height; + const allowedDiff = Math.ceil(totalPixels * 0.0); + + if (numDiffPixels > allowedDiff) { + fs.writeFileSync( + join("test", "fixtures", `${example.name}-diff.png`), + PNG.sync.write(diff), + ); + } + assert.ok( + numDiffPixels <= allowedDiff, + `Detected ${numDiffPixels} mismatched pixels for ${example.name} with threshold ${threshold}. Allowed: ${allowedDiff}`, + ); + }); +} diff --git a/js/test/getSvgPathStrings.test.js b/js/test/getSvgPathStrings.test.js new file mode 100644 index 00000000..5c5c5666 --- /dev/null +++ b/js/test/getSvgPathStrings.test.js @@ -0,0 +1,79 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { exportsForTesting } from "../index.js"; + +const { getSvgPathStrings } = exportsForTesting; + +test("getSvgPathStrings extracts path data from anchor.svg", () => { + const svg = ` + +`; + const paths = getSvgPathStrings(svg); + assert.strictEqual(paths.length, 1); + assert.strictEqual( + paths[0], + "M7.5 0C5.5 0 4 1.57 4 3.5c0 1.56 1.04 2.9 2.5 3.34v6.04c-0.93 -0.16 -1.88 -0.55 -2.65 -1.27C2.8 10.63 2 9.07 2 6.5c0.01 -0.56 -0.45 -1.02 -1.02 -1.01C0.43 5.49 -0.01 5.95 0 6.5c0 3 1.01 5.2 2.49 6.57C3.97 14.45 5.84 15 7.5 15c1.67 0 3.54 -0.56 5.01 -1.94S15 9.49 15 6.5c0.06 -1.4 -2.06 -1.4 -2 0c0 2.55 -0.8 4.11 -1.85 5.1C10.37 12.32 9.43 12.71 8.5 12.88V6.84C9.96 6.41 11 5.06 11 3.5C11 1.57 9.5 0 7.5 0zM7.5 2C8.33 2 9 2.67 9 3.5S8.33 5 7.5 5S6 4.33 6 3.5S6.67 2 7.5 2z", + ); +}); + +test("getSvgPathStrings extracts path data from antenna_array.svg", () => { + const svg = ` + +`; + const paths = getSvgPathStrings(svg); + assert.strictEqual(paths.length, 1); + assert.strictEqual( + paths[0], + "M11.67 7.54L11.67 9.79C11.67 10.63 10 10.63 10 9.79C10 9.79 10 7.29 10 7.29C10 7.29 8.33 7.29 8.33 7.29C8.33 7.29 8.33 8.13 8.33 8.96C8.33 9.79 6.67 9.79 6.67 8.96C6.67 8.13 6.67 7.29 6.67 7.29C6.67 7.29 5 7.29 5 7.29C5 7.29 5 9.79 5 9.79C5 10.63 3.33 10.63 3.33 9.79L3.33 7.54L1.67 8.13L1.67 11.46C1.67 12.29 0 12.29 0 11.46C0 11.46 0 4.79 0 4.79C0 3.96 1.67 3.96 1.67 4.79C1.67 4.79 1.67 6.46 1.67 6.46L3.33 5.88C3.33 5.88 3.33 3.13 3.33 3.13C3.33 2.29 5 2.29 5 3.13C5 3.13 5 5.63 5 5.63C5 5.63 6.67 5.63 6.67 5.63C6.67 5.63 6.67 3.96 6.67 3.96C6.67 3.13 8.33 3.13 8.33 3.96C8.33 3.96 8.33 5.63 8.33 5.63C8.33 5.63 10 5.63 10 5.63C10 3.13 10 3.13 10 3.13C10 2.29 11.67 2.29 11.67 3.13C11.67 3.13 11.67 5.88 11.67 5.88L13.33 6.46C13.33 6.46 13.33 4.79 13.33 4.79C13.33 3.96 15 3.96 15 4.79C15 4.79 15 11.46 15 11.46C15 12.29 13.33 12.29 13.33 11.46L13.33 8.13L11.67 7.54Z", + ); +}); + +test("getSvgPathStrings extracts path data from anvil.svg", () => { + const svg = ` + +`; + const paths = getSvgPathStrings(svg); + assert.strictEqual(paths.length, 1); + assert.strictEqual( + paths[0], + "M11 9.5L13 11L13 12L11 12C10.33 11.33 9.5 11 8.5 11C7.5 11 6.67 11.33 6 12L4 12L4 11L6 9.5L11 9.5ZM15 4L15 5C12.86 5 11.11 6.68 11 8.8L11 9L6 9C6 8.36 5.75 7.75 5.29 7.29L5 7L5 4L15 4ZM0 4.5L4.5 4.5L4.5 7C2.83 7 1.33 6.17 0 4.5Z", + ); +}); + +test("getSvgPathStrings extracts path data from arch.svg", () => { + const svg = ` + +`; + const paths = getSvgPathStrings(svg); + assert.strictEqual(paths.length, 1); + assert.strictEqual( + paths[0], + "M2 1L13 1C13.55 1 14 1.45 14 2L14 14L12 14L12.01 7.5C12.01 5.02 10 3 7.52 3L7.52 3C5.03 3 3.01 5.01 3.01 7.5L3 14L1 14L1 2C1 1.45 1.45 1 2 1Z", + ); +}); + +test("getSvgPathStrings extracts path data from balloon.svg", () => { + const svg = ` + +`; + const paths = getSvgPathStrings(svg); + assert.strictEqual(paths.length, 1); + assert.strictEqual( + paths[0], + "M7.5 0C10.5 0 12.5 2.5 12.5 6C12.5 10 8 13 8 13C8 13 8.5 14 8.5 14.5C8.5 15 6.5 15 6.5 14.5C6.5 14 7 13 7 13C7 13 2.5 10 2.5 6C2.5 2.5 4.5 0 7.5 0zM7.5 1C4.5 1 3 4.5 3.5 6.5C4 4.5 5.5 2 7.5 1z", + ); +}); + +test("getSvgPathStrings extracts path data when there are line breaks", () => { + const svg = ` + +`; + const paths = getSvgPathStrings(svg); + assert.strictEqual(paths.length, 1); + assert.strictEqual( + paths[0], + "M7.5 0C10.5 0 12.5 2.5 12.5 6C12.5 10 8 13 8 13C8 13 8.5 14 8.5 14.5C8.5 15 6.5 15 6.5 14.5C6.5 14 7 13 7 13C7 13 2.5 10 2.5 6C2.5 2.5 4.5 0 7.5 0zM7.5 1C4.5 1 3 4.5 3.5 6.5C4 4.5 5.5 2 7.5 1z", + ); +}); From 40e5429069205ade4872a3efb4defd2930504d7c Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 19:16:57 -0400 Subject: [PATCH 05/32] doh, no lock file --- .github/workflows/test-js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index e93376fb..d16ec01c 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -11,6 +11,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '20.x' - - run: npm --prefix js ci + - run: npm --prefix js i - run: npm --prefix js test From 9b00f125553bbeaced8c64564d70da7252ac3e0f Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 20:23:57 -0400 Subject: [PATCH 06/32] Update README.md --- js/README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/js/README.md b/js/README.md index 241c0798..916d7a27 100644 --- a/js/README.md +++ b/js/README.md @@ -32,7 +32,7 @@ mapping platform. ## Installation ```bash -npm install @waysidemapping/pinhead-js +npm install @waysidemapping/pinhead ``` ## Options @@ -132,6 +132,24 @@ To work with **Pinhead JS**, custom SVG strings must follow these constraints: --- + +## Versioning + +Because **Pinhead** generally uses major version numbers, but **Pinhead JS** uses it's `changelog.json` too offer compatibility across versions, the major version of **Pinhead JS** reflects breaking API changes, the minor version reflects the version of Pinhead, and the patch version is incremented for non-breaking changes. + +EG: `@waysidemapping/pinhead-js==1.15.0` bundles `@waysidemapping/pinhead==15.0.0` + +If you wish to use a very specific version of **Pinhead**, you can import it yourself and use **Pinhead JS**'s custom SVG support: + +``` +import { getSprite } from "@waysidemapping/pinhead-js"; +import index from "@waysidemapping/pinhead/dist/icons/index.complete.json" with { type: "json" }; + +const svg = getSprite(index.icons['bicycle'], svg, { shape: 'marker' }); +``` + +--- + ## Integrations ### MapLibre GL JS From 4b741cc1afa7d5364a6334c3a215bf554b9cc3e3 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 20:26:29 -0400 Subject: [PATCH 07/32] Update README.md --- js/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/README.md b/js/README.md index 916d7a27..9ce453f6 100644 --- a/js/README.md +++ b/js/README.md @@ -145,7 +145,7 @@ If you wish to use a very specific version of **Pinhead**, you can import it you import { getSprite } from "@waysidemapping/pinhead-js"; import index from "@waysidemapping/pinhead/dist/icons/index.complete.json" with { type: "json" }; -const svg = getSprite(index.icons['bicycle'], svg, { shape: 'marker' }); +const svg = getSprite(index.icons['bicycle'].svg, { shape: 'marker' }); ``` --- From 47a905f4164fdf9bd8e75d2d7bb4fc1d3eaf9d40 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 20:27:30 -0400 Subject: [PATCH 08/32] Update README.md --- js/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/README.md b/js/README.md index 9ce453f6..448b4636 100644 --- a/js/README.md +++ b/js/README.md @@ -157,7 +157,7 @@ const svg = getSprite(index.icons['bicycle'].svg, { shape: 'marker' }); To use **Pinhead JS** dynamically with MapLibre: ```javascript -const svg = getSprite("hospital", { shape: "circle", shapeFill: "red" }); +const svg = getSprite("greek_cross", { shape: "circle", shapeFill: "red" }); const img = new Image(); const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }); From 534801307673d5961ab5d5703662d468a12febdb Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 21:01:51 -0400 Subject: [PATCH 09/32] import examples --- js/test/getSprite.test.js | 91 +-------------------------------------- 1 file changed, 1 insertion(+), 90 deletions(-) diff --git a/js/test/getSprite.test.js b/js/test/getSprite.test.js index f9ca4593..4637fe6e 100644 --- a/js/test/getSprite.test.js +++ b/js/test/getSprite.test.js @@ -8,96 +8,7 @@ import { Resvg } from "@resvg/resvg-js"; import pixelmatch from "pixelmatch"; import { PNG } from "pngjs"; -export const examples = [ - { - name: "jeep-map_pin-stroke-1", - icon: "jeep", - properties: { shape: "map_pin", strokeWidth: 1 }, - }, - { - name: "cargobike-square-blue", - icon: "cargobike", - properties: { shape: "square", shapeFill: "#6486f5" }, - }, - { - name: "bike-circle-green", - icon: "bicycle", - properties: { - shape: "circle", - strokeWidth: 1, - stroke: "#6dad6f", - fill: "#6dad6f", - shapeFill: "white", - }, - }, - { - name: "burger-marker", - icon: "burger", - properties: { shape: "marker", shapeFill: "#3FB1CE" }, - }, - { - name: "cafe-black-stroke", - icon: "cup_and_saucer", - properties: { strokeWidth: 1 }, - }, - { name: "cargobike", icon: "cargobike", properties: {} }, - { name: "dot", icon: "dot", properties: {} }, - { - name: "sun-square-yellow", - icon: "sun", - properties: { - shape: "square", - cornerRadius: 7, - shapeFill: "yellow", - strokeWidth: 1, - }, - }, - { - name: "plane-square-navy", - icon: "plane", - properties: { shape: "square", cornerRadius: 0, shapeFill: "navy" }, - }, - { - name: "ice_cream-circle-pink", - icon: "ice_cream_on_cone", - properties: { shape: "circle", shapeFill: "pink" }, - }, - { - name: "beer-marker-amber", - icon: "beer_mug_with_foam", - properties: { shape: "marker", shapeFill: "#FFBF00" }, - }, - { - name: "rocket-map_pin-purple", - icon: "rocketship", - properties: { shape: "map_pin", shapeFill: "purple" }, - }, - { - name: "pizza-square-red", - icon: "pizza_slice", - properties: { shape: "square", shapeFill: "red" }, - }, - { - name: "bus-circle-blue", - icon: "bus", - properties: { shape: "circle", shapeFill: "blue" }, - }, - { - name: "camera-marker-darkgrey", - icon: "camera", - properties: { shape: "marker", shapeFill: "#333" }, - }, - { - name: "tree-map_pin-green", - icon: "conifer_tree", - properties: { shape: "map_pin", shapeFill: "darkgreen" }, - }, - { - name: "tent-square-brown", - icon: "a_frame_tent", - properties: { shape: "square", shapeFill: "brown" }, - }, -]; +import { examples } from "./examples.js"; for (const example of examples) { test(`getSprite matches fixture for ${example.name}`, () => { From 75dbbfe4a1e69143345fd4c03e869e7731266b06 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 21:09:26 -0400 Subject: [PATCH 10/32] some reorg and format readme too mostly to ensure consistent js formatting --- js/README.md | 25 ++-- js/icon.js | 194 +++++++++++++++++++++++++++ js/index.js | 209 +----------------------------- js/package.json | 3 +- js/test/getSprite.test.js | 2 +- js/test/getSvgPathStrings.test.js | 4 +- js/util.js | 9 ++ 7 files changed, 220 insertions(+), 226 deletions(-) create mode 100644 js/icon.js create mode 100644 js/util.js diff --git a/js/README.md b/js/README.md index 448b4636..ebab8a70 100644 --- a/js/README.md +++ b/js/README.md @@ -74,14 +74,14 @@ const marker = getSprite("jeep", { #### Examples -| Result | Code | -| :------------------------------------------- | :-------------------------------------------------------------------------------------------------- | -| ![](./examples/cargobike.svg) | `getSprite("cargobike")` | -| ![](./examples/cafe-black-stroke.svg) | `getSprite("cup_and_saucer", { strokeWidth: 1 })` | -| ![](./examples/bike-circle-green.svg) | `getSprite("bicycle", { shape: "circle", shapeFill: "white", fill: "#6dad6f", stroke: "#6dad6f" })` | -| ![](./examples/burger-marker.svg) | `getSprite("burger", { shape: "marker", shapeFill: "#3FB1CE" })` | -| ![](./examples/ice_cream-circle-pink.svg)| `getSprite("ice_cream_on_cone", { shape: "circle", shapeFill: "pink" })` | -| ![](./examples/rocket-map_pin-purple.svg)| `getSprite("rocketship", { shape: "map_pin", shapeFill: "purple" })` | +| Result | Code | +| :---------------------------------------- | :-------------------------------------------------------------------------------------------------- | +| ![](./examples/cargobike.svg) | `getSprite("cargobike")` | +| ![](./examples/cafe-black-stroke.svg) | `getSprite("cup_and_saucer", { strokeWidth: 1 })` | +| ![](./examples/bike-circle-green.svg) | `getSprite("bicycle", { shape: "circle", shapeFill: "white", fill: "#6dad6f", stroke: "#6dad6f" })` | +| ![](./examples/burger-marker.svg) | `getSprite("burger", { shape: "marker", shapeFill: "#3FB1CE" })` | +| ![](./examples/ice_cream-circle-pink.svg) | `getSprite("ice_cream_on_cone", { shape: "circle", shapeFill: "pink" })` | +| ![](./examples/rocket-map_pin-purple.svg) | `getSprite("rocketship", { shape: "map_pin", shapeFill: "purple" })` | ### Command Line Interface (CLI) @@ -132,20 +132,19 @@ To work with **Pinhead JS**, custom SVG strings must follow these constraints: --- - -## Versioning +## Versioning Because **Pinhead** generally uses major version numbers, but **Pinhead JS** uses it's `changelog.json` too offer compatibility across versions, the major version of **Pinhead JS** reflects breaking API changes, the minor version reflects the version of Pinhead, and the patch version is incremented for non-breaking changes. EG: `@waysidemapping/pinhead-js==1.15.0` bundles `@waysidemapping/pinhead==15.0.0` -If you wish to use a very specific version of **Pinhead**, you can import it yourself and use **Pinhead JS**'s custom SVG support: +If you wish to use a very specific version of **Pinhead**, you can import it yourself and use **Pinhead JS**'s custom SVG support: -``` +```javascript import { getSprite } from "@waysidemapping/pinhead-js"; import index from "@waysidemapping/pinhead/dist/icons/index.complete.json" with { type: "json" }; -const svg = getSprite(index.icons['bicycle'].svg, { shape: 'marker' }); +const svg = getSprite(index.icons["bicycle"].svg, { shape: "marker" }); ``` --- diff --git a/js/icon.js b/js/icon.js new file mode 100644 index 00000000..34abeb3a --- /dev/null +++ b/js/icon.js @@ -0,0 +1,194 @@ +import index from "@waysidemapping/pinhead/dist/icons/index.complete.json" with { type: "json" }; +import tinycolor from "tinycolor2"; +import { getSvgPathStrings } from "./util.js"; + +// minifiy is a JS template tag function to strip whitespace from XML +function minify(strings, ...values) { + // Interleave the strings and values + let output = strings.reduce((acc, part, i) => { + return acc + part + (values[i] !== undefined ? values[i] : ""); + }, ""); + + // remove newlines + output = output.replace(/\n/g, " "); + // trim + output = output.trim(); + // remove double spaces + output = output.replace(/\s\s+/g, " "); + // remove spaces around > and < and /> + output = output.replace(/\s*(\/?>|<)\s*/g, "$1"); + return output; +} + +// Hard coding for 15x15 requirement for Pinhead icons +const size = 15; + +const defaultPadding = { + map_pin: 4, + circle: 2, + square: 2, + marker: 5, +}; + +export function getSprite(name, properties = {}) { + let iconSvg; + if (name.includes("`; + + switch (true) { + case shape === "circle": + cornerRadius = (size + 2 * padding) / 2; + case shape === "square": + const rectSize = size + 2 * padding; + if (strokeWidth) { + svg += minify``; + } + svg += minify``; + break; + case shape === "marker": + svg += minify` + + + + + + + + + `; + case shape === "map_pin": + const d = + "M 11.5,31 C 19.166667,22.234183 23,15.734183 23,11.5 23,5.1487254 17.851275,0 11.5,0 5.1487254,0 0,5.1487254 0,11.5 0,15.734183 3.8333333,22.234183 11.5,31 Z"; + let scaleTransform = ""; + if (bgScale !== 1) { + scaleTransform = `scale(${bgScale}) `; + } + if (strokeWidth) { + svg += minify``; + } + svg += minify``; + break; + default: + // Nothing to do when not drawing a shape + break; + } + for (const path of paths) { + if (!shape && strokeWidth) { + svg += minify``; + } + svg += minify``; + } + svg += ""; + return svg; +} diff --git a/js/index.js b/js/index.js index a102409a..1e264d22 100644 --- a/js/index.js +++ b/js/index.js @@ -1,208 +1 @@ -import index from "@waysidemapping/pinhead/dist/icons/index.complete.json" with { type: "json" }; -import tinycolor from "tinycolor2"; - -// minifiy is a JS template tag function to strip whitespace from XML -function minify(strings, ...values) { - // Interleave the strings and values - let output = strings.reduce((acc, part, i) => { - return acc + part + (values[i] !== undefined ? values[i] : ""); - }, ""); - - // remove newlines - output = output.replace(/\n/g, " "); - // trim - output = output.trim(); - // remove double spaces - output = output.replace(/\s\s+/g, " "); - // remove spaces around > and < and /> - output = output.replace(/\s*(\/?>|<)\s*/g, "$1"); - return output; -} - -// Hard coding for 15x15 requirement for Pinhead icons -const size = 15; - -const defaultPadding = { - map_pin: 4, - circle: 2, - square: 2, - marker: 5, -}; - -function getSvgPathStrings(iconSvg) { - const paths = []; - for (const { groups } of iconSvg.matchAll( - /]+?d="(?[^"]+?)"/gm, - )) { - paths.push(groups.path); - } - return paths; -} - -export function getSprite(name, properties = {}) { - let iconSvg; - if (name.includes("`; - - switch (true) { - case shape === "circle": - cornerRadius = (size + 2 * padding) / 2; - case shape === "square": - const rectSize = size + 2 * padding; - if (strokeWidth) { - svg += minify``; - } - svg += minify``; - break; - case shape === "marker": - svg += minify` - - - - - - - - - `; - case shape === "map_pin": - const d = - "M 11.5,31 C 19.166667,22.234183 23,15.734183 23,11.5 23,5.1487254 17.851275,0 11.5,0 5.1487254,0 0,5.1487254 0,11.5 0,15.734183 3.8333333,22.234183 11.5,31 Z"; - let scaleTransform = ""; - if (bgScale !== 1) { - scaleTransform = `scale(${bgScale}) `; - } - if (strokeWidth) { - svg += minify``; - } - svg += minify``; - break; - default: - // Nothing to do when not drawing a shape - break; - } - for (const path of paths) { - if (!shape && strokeWidth) { - svg += minify``; - } - svg += minify``; - } - svg += ""; - return svg; -} - -export let exportsForTesting; -if (process?.env?.NODE_ENV === "test") { - exportsForTesting = { getSvgPathStrings }; -} +export { getSprite } from "./icon.js"; diff --git a/js/package.json b/js/package.json index c02020d5..a828d76f 100644 --- a/js/package.json +++ b/js/package.json @@ -37,7 +37,8 @@ }, "scripts": { "test": "env NODE_ENV=test node --test test/*.test.js", - "format": "prettier --write *.js test/*.js" + "format": "prettier --write *.js test/*.js README.md", + "check-format": "prettier --check *.js test/*.js README.md" }, "dependencies": { "@waysidemapping/pinhead": "^14.0.0", diff --git a/js/test/getSprite.test.js b/js/test/getSprite.test.js index 4637fe6e..c32c5395 100644 --- a/js/test/getSprite.test.js +++ b/js/test/getSprite.test.js @@ -3,7 +3,7 @@ import { test } from "node:test"; import assert from "node:assert"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { getSprite } from "../index.js"; +import { getSprite } from "../icon.js"; import { Resvg } from "@resvg/resvg-js"; import pixelmatch from "pixelmatch"; import { PNG } from "pngjs"; diff --git a/js/test/getSvgPathStrings.test.js b/js/test/getSvgPathStrings.test.js index 5c5c5666..17c10081 100644 --- a/js/test/getSvgPathStrings.test.js +++ b/js/test/getSvgPathStrings.test.js @@ -1,8 +1,6 @@ import { test } from "node:test"; import assert from "node:assert"; -import { exportsForTesting } from "../index.js"; - -const { getSvgPathStrings } = exportsForTesting; +import { getSvgPathStrings } from "../util.js"; test("getSvgPathStrings extracts path data from anchor.svg", () => { const svg = ` diff --git a/js/util.js b/js/util.js new file mode 100644 index 00000000..e9832e37 --- /dev/null +++ b/js/util.js @@ -0,0 +1,9 @@ +export function getSvgPathStrings(iconSvg) { + const paths = []; + for (const { groups } of iconSvg.matchAll( + /]+?d="(?[^"]+?)"/gm, + )) { + paths.push(groups.path); + } + return paths; +} From d98f8217624032318751f393ddea43a192956f05 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 21:09:39 -0400 Subject: [PATCH 11/32] format ci check --- .github/workflows/check-format-js.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/check-format-js.yml diff --git a/.github/workflows/check-format-js.yml b/.github/workflows/check-format-js.yml new file mode 100644 index 00000000..c7ede6ee --- /dev/null +++ b/.github/workflows/check-format-js.yml @@ -0,0 +1,16 @@ +name: Check JS formatting + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + - run: npm --prefix js i + - run: npm --prefix js check-format + From fee2e73b20c6c5bb034cd84c86645a95e52130d1 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 21:12:39 -0400 Subject: [PATCH 12/32] rename getSprite to getIcon --- js/README.md | 36 ++++++++++++++++----------------- js/cli.js | 10 ++++----- js/examples/generateExamples.js | 4 ++-- js/icon.js | 2 +- js/index.js | 2 +- js/test/generateFixtures.js | 4 ++-- js/test/getSprite.test.js | 6 +++--- 7 files changed, 32 insertions(+), 32 deletions(-) diff --git a/js/README.md b/js/README.md index ebab8a70..e5d69c98 100644 --- a/js/README.md +++ b/js/README.md @@ -59,13 +59,13 @@ These options are common across both the CLI and API. Ideal for dynamic icon generation in the browser or on the server. ```javascript -import { getSprite } from "@waysidemapping/pinhead-js"; +import { getIcon } from "@waysidemapping/pinhead-js"; // Simple icon -const svg = getSprite("cargobike"); +const svg = getIcon("cargobike"); // Icon with background and custom colors -const marker = getSprite("jeep", { +const marker = getIcon("jeep", { shape: "map_pin", shapeFill: "#6486f5", strokeWidth: 1, @@ -74,31 +74,31 @@ const marker = getSprite("jeep", { #### Examples -| Result | Code | -| :---------------------------------------- | :-------------------------------------------------------------------------------------------------- | -| ![](./examples/cargobike.svg) | `getSprite("cargobike")` | -| ![](./examples/cafe-black-stroke.svg) | `getSprite("cup_and_saucer", { strokeWidth: 1 })` | -| ![](./examples/bike-circle-green.svg) | `getSprite("bicycle", { shape: "circle", shapeFill: "white", fill: "#6dad6f", stroke: "#6dad6f" })` | -| ![](./examples/burger-marker.svg) | `getSprite("burger", { shape: "marker", shapeFill: "#3FB1CE" })` | -| ![](./examples/ice_cream-circle-pink.svg) | `getSprite("ice_cream_on_cone", { shape: "circle", shapeFill: "pink" })` | -| ![](./examples/rocket-map_pin-purple.svg) | `getSprite("rocketship", { shape: "map_pin", shapeFill: "purple" })` | +| Result | Code | +| :---------------------------------------- | :------------------------------------------------------------------------------------------------ | +| ![](./examples/cargobike.svg) | `getIcon("cargobike")` | +| ![](./examples/cafe-black-stroke.svg) | `getIcon("cup_and_saucer", { strokeWidth: 1 })` | +| ![](./examples/bike-circle-green.svg) | `getIcon("bicycle", { shape: "circle", shapeFill: "white", fill: "#6dad6f", stroke: "#6dad6f" })` | +| ![](./examples/burger-marker.svg) | `getIcon("burger", { shape: "marker", shapeFill: "#3FB1CE" })` | +| ![](./examples/ice_cream-circle-pink.svg) | `getIcon("ice_cream_on_cone", { shape: "circle", shapeFill: "pink" })` | +| ![](./examples/rocket-map_pin-purple.svg) | `getIcon("rocketship", { shape: "map_pin", shapeFill: "purple" })` | ### Command Line Interface (CLI) -#### 1. Generate a single sprite +#### 1. Generate a single icon Outputs the SVG string directly to `stdout`. ```bash -npx pinhead get-sprite cargobike --shape=square --shapeFill='#6486f5' > icon.svg +npx pinhead get-icon cargobike --shape=square --shapeFill='#6486f5' > icon.svg ``` #### 2. Batch build from configuration -The `build-sprites` command creates a collection of SVG files based on a JSON configuration file. By default, it looks for `pinhead.json` and writes results to a `./svgs/` directory. +The `build-icons` command creates a collection of SVG files based on a JSON configuration file. By default, it looks for `pinhead.json` and writes results to a `./svgs/` directory. ```bash -npx pinhead build-sprites --config my-icons.json --outdir ./assets/icons +npx pinhead build-icons --config my-icons.json --outdir ./assets/icons ``` **`pinhead.json` structure:** @@ -141,10 +141,10 @@ EG: `@waysidemapping/pinhead-js==1.15.0` bundles `@waysidemapping/pinhead==15.0. If you wish to use a very specific version of **Pinhead**, you can import it yourself and use **Pinhead JS**'s custom SVG support: ```javascript -import { getSprite } from "@waysidemapping/pinhead-js"; +import { getIcon } from "@waysidemapping/pinhead-js"; import index from "@waysidemapping/pinhead/dist/icons/index.complete.json" with { type: "json" }; -const svg = getSprite(index.icons["bicycle"].svg, { shape: "marker" }); +const svg = getIcon(index.icons["bicycle"].svg, { shape: "marker" }); ``` --- @@ -156,7 +156,7 @@ const svg = getSprite(index.icons["bicycle"].svg, { shape: "marker" }); To use **Pinhead JS** dynamically with MapLibre: ```javascript -const svg = getSprite("greek_cross", { shape: "circle", shapeFill: "red" }); +const svg = getIcon("greek_cross", { shape: "circle", shapeFill: "red" }); const img = new Image(); const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }); diff --git a/js/cli.js b/js/cli.js index 1e968a9d..04959d7e 100755 --- a/js/cli.js +++ b/js/cli.js @@ -2,10 +2,10 @@ import fs from "fs"; import { parseArgs } from "util"; -import { getSprite } from "./index.js"; +import { getIcon } from "./index.js"; const commands = { - "get-sprite": { + "get-icon": { config: { options: { fill: { type: "string" }, @@ -37,11 +37,11 @@ const commands = { console.error("More than one icon name specified!"); return 1; } - console.log(getSprite(positionals[0], values)); + console.log(getIcon(positionals[0], values)); return 0; }, }, - "build-sprites": { + "build-icons": { config: { options: { config: { type: "string", default: "pinhead.json" }, @@ -55,7 +55,7 @@ const commands = { for (const [icon, name] of Object.entries(icons)) { fs.writeFileSync( `${values.outdir}/${name}.svg`, - getSprite(icon, options), + getIcon(icon, options), ); } } diff --git a/js/examples/generateExamples.js b/js/examples/generateExamples.js index e391bb3f..9acc5017 100644 --- a/js/examples/generateExamples.js +++ b/js/examples/generateExamples.js @@ -1,11 +1,11 @@ import { writeFileSync } from "node:fs"; import { join } from "node:path"; -import { getSprite } from "../index.js"; +import { getIcon } from "../index.js"; import { examples } from "../test/examples.js"; for (const example of examples) { console.log(`Generating fixture for ${example.name}...`); - const svg = getSprite(example.icon, example.properties); + const svg = getIcon(example.icon, example.properties); writeFileSync(join("examples", `${example.name}.svg`), Buffer.from(svg)); } diff --git a/js/icon.js b/js/icon.js index 34abeb3a..cb9fcc09 100644 --- a/js/icon.js +++ b/js/icon.js @@ -30,7 +30,7 @@ const defaultPadding = { marker: 5, }; -export function getSprite(name, properties = {}) { +export function getIcon(name, properties = {}) { let iconSvg; if (name.includes(" { - const svg = getSprite(example.icon, example.properties); + test(`getIcon matches fixture for ${example.name}`, () => { + const svg = getIcon(example.icon, example.properties); const resvg = new Resvg(svg, { fitTo: { mode: "zoom", value: 4 } }); const rendered = resvg.render(); // get pixels from writing & reading PNG for apples-to-apples comparison instead of using rendered.pixels From e7316f844bba74e9f58a8cbaf289375e30d2fee0 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 21:36:48 -0400 Subject: [PATCH 13/32] WIP migrateName. need to think through logic --- js/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/js/index.js b/js/index.js index fa8d7e05..b433ed61 100644 --- a/js/index.js +++ b/js/index.js @@ -1 +1,2 @@ export { getIcon } from "./icon.js"; +export { migrateName } from "./migrate.js"; From 556985703b17515ac213aff59ba45a0d031659d1 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 21:37:23 -0400 Subject: [PATCH 14/32] oops --- js/migrate.js | 40 ++++++++++++++++++++++++++++++++++++++++ js/test/migrate.test.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 js/migrate.js create mode 100644 js/test/migrate.test.js diff --git a/js/migrate.js b/js/migrate.js new file mode 100644 index 00000000..922afaf1 --- /dev/null +++ b/js/migrate.js @@ -0,0 +1,40 @@ +import changelog from "@waysidemapping/pinhead/dist/changelog.json" with { type: "json" }; +import externalSources from "@waysidemapping/pinhead/dist/external_sources.json" with { type: "json" }; + +changelog.sort((a, b) => parseInt(a) - parseInt(b)); +const validExternalSources = new Set(externalSources.map(({ id }) => id)); + +export function migrateName(name, from = "pinhead") { + let resolvedName = name; + let pinheadVersion; + let externalKey; + if (from.startsWith("pinhead")) { + if (from.startsWith("pinhead@")) { + pinheadVersion = from.split("@", 2)[1]; + // TODO: validate pinheadVersion! + } else if (from !== "pinhead") { + throw new Error(`"${from}" is not a valid value to migrate from`); + } + } else if (validExternalSources.has(from)) { + externalKey = from; + } else { + throw new Error(`"${from}" is not a valid value to migrate from`); + } + + for (const version of changelog) { + // FIXME + for (const change of version.iconChanges) { + if (externalKey && change[externalKey] === resolvedName) { + resolvedName = change[externalKey]; + } else if ( + pinheadVersion && + parseInt(change.majorVersion) < pinheadVersion + ) { + continue; + } else if (change.oldId === resolvedName) { + resolvedName = change.newId; + } + } + } + return resolvedName; +} diff --git a/js/test/migrate.test.js b/js/test/migrate.test.js new file mode 100644 index 00000000..760b521d --- /dev/null +++ b/js/test/migrate.test.js @@ -0,0 +1,30 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { migrateName } from "../index.js"; + +test("migrateName pedestrian -> person_walking", () => { + assert.strictEqual(migrateName("pedestrian"), "person_walking"); +}); + +// Doesn't migrate names that are valid current names even if they previously meant something else +test("migrateName treasure_map -> treasure_map", () => { + assert.strictEqual(migrateName("treasure_map"), "treasure_map"); +}); + +test("migrateName pinhead@12:treasure_map -> treasure_map", () => { + assert.strictEqual( + migrateName("treasure_map", "pinhead@12"), + "bifold_map_with_dotted_line_to_x", + ); +}); + +test("migrateName temaki:pedestrian -> person_walking", () => { + assert.strictEqual(migrateName("pedestrian"), "person_walking"); +}); + +test("migrateName osmcarto:shop/outdoor -> person_wearing_backpack_walking_with_hiking_pole", () => { + assert.strictEqual( + migrateName("shop/outdoor", "osmcarto"), + "person_wearing_backpack_walking_with_hiking_pole", + ); +}); From b4899c019d98dbaa1deb4f709ab2f385208d2f14 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 21:38:36 -0400 Subject: [PATCH 15/32] fix one bug --- js/migrate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/migrate.js b/js/migrate.js index 922afaf1..f4ef72eb 100644 --- a/js/migrate.js +++ b/js/migrate.js @@ -25,7 +25,7 @@ export function migrateName(name, from = "pinhead") { // FIXME for (const change of version.iconChanges) { if (externalKey && change[externalKey] === resolvedName) { - resolvedName = change[externalKey]; + resolvedName = change.newId; } else if ( pinheadVersion && parseInt(change.majorVersion) < pinheadVersion From 73e0a1851cc22671b7b0aadf64d4baf556a94734 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 21:50:47 -0400 Subject: [PATCH 16/32] fix one bug --- js/migrate.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/js/migrate.js b/js/migrate.js index f4ef72eb..aad6bc00 100644 --- a/js/migrate.js +++ b/js/migrate.js @@ -22,16 +22,24 @@ export function migrateName(name, from = "pinhead") { } for (const version of changelog) { - // FIXME for (const change of version.iconChanges) { if (externalKey && change[externalKey] === resolvedName) { + // migrate from an external key resolvedName = change.newId; + } else if ( + !pinheadVersion && + change.newId === name + ) { + // undo any migrations if not targeting specific pinhead version and a name is re-used + resolvedName = name; } else if ( pinheadVersion && parseInt(change.majorVersion) < pinheadVersion ) { + // do nothing if migrating from a specific version newer than current continue; } else if (change.oldId === resolvedName) { + // migrate! resolvedName = change.newId; } } From a32c5636ab18b9951e7079858cf90ffccf2bb883 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Tue, 10 Mar 2026 21:54:33 -0400 Subject: [PATCH 17/32] fmt --- js/migrate.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/js/migrate.js b/js/migrate.js index aad6bc00..4cabd71a 100644 --- a/js/migrate.js +++ b/js/migrate.js @@ -26,10 +26,7 @@ export function migrateName(name, from = "pinhead") { if (externalKey && change[externalKey] === resolvedName) { // migrate from an external key resolvedName = change.newId; - } else if ( - !pinheadVersion && - change.newId === name - ) { + } else if (!pinheadVersion && change.newId === name) { // undo any migrations if not targeting specific pinhead version and a name is re-used resolvedName = name; } else if ( From 0e9b2861bcc7d4b49c64a3de71a6d4ce7040e52a Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Wed, 11 Mar 2026 10:05:15 -0400 Subject: [PATCH 18/32] Update check-format-js.yml --- .github/workflows/check-format-js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-format-js.yml b/.github/workflows/check-format-js.yml index c7ede6ee..8e590c9e 100644 --- a/.github/workflows/check-format-js.yml +++ b/.github/workflows/check-format-js.yml @@ -12,5 +12,5 @@ jobs: with: node-version: '20.x' - run: npm --prefix js i - - run: npm --prefix js check-format + - run: npm --prefix js run check-format From 638a3f7d0c542095d6273acb1d462eaee1c85c21 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Thu, 12 Mar 2026 00:35:49 -0400 Subject: [PATCH 19/32] revamp migrate --- js/README.md | 35 +++- js/icon.js | 22 +-- js/migrate.js | 96 +++++++---- .../{getSprite.test.js => getIcon.test.js} | 0 js/test/migrate.test.js | 152 +++++++++++++++++- js/test/minify.test.js | 39 +++++ js/util.js | 18 +++ 7 files changed, 304 insertions(+), 58 deletions(-) rename js/test/{getSprite.test.js => getIcon.test.js} (100%) create mode 100644 js/test/minify.test.js diff --git a/js/README.md b/js/README.md index e5d69c98..158d23ed 100644 --- a/js/README.md +++ b/js/README.md @@ -23,11 +23,12 @@ mapping platform. ## Features -- **Icon Composition:** Layer any **Pinhead** icon onto background shapes. +- **Icon Composition:** Layer any Pinhead icon onto background shapes. - **Smart Coloring:** Automatically chooses contrasting icon colors based on the background fill. - **Multiple Shapes:** Supports `circle`, `square`, `map_pin`, and `marker`. - **CLI & API:** Use it as a command-line tool for batch processing or as a JavaScript library in your app. - **Custom SVGs:** Pass raw SVG strings to compose custom icons +- **Migation:** A function is provided to simplify the usage of Pinehead's `changelog.json`. ## Installation @@ -56,6 +57,8 @@ These options are common across both the CLI and API. ### JavaScript API +#### Create Icons + Ideal for dynamic icon generation in the browser or on the server. ```javascript @@ -72,7 +75,7 @@ const marker = getIcon("jeep", { }); ``` -#### Examples +##### Examples | Result | Code | | :---------------------------------------- | :------------------------------------------------------------------------------------------------ | @@ -83,6 +86,22 @@ const marker = getIcon("jeep", { | ![](./examples/ice_cream-circle-pink.svg) | `getIcon("ice_cream_on_cone", { shape: "circle", shapeFill: "pink" })` | | ![](./examples/rocket-map_pin-purple.svg) | `getIcon("rocketship", { shape: "map_pin", shapeFill: "purple" })` | +#### Migrate an icon name + +```javascript +import { migrateName } from "@waysidemapping/pinhead-js"; + +// Migrate a name previously used by Pinhead. If a name was used more than once, the more recent name is returned. +let name = migrateName("pedestrian"); // -> "person_walking" + +// Migrate from a specific version (treasure_map was renamed, in v13, but a new treasure_map was introduced then too) +migrateName("treasure_map", "pinhead@10"); // -> "bifold_map_with_dotted_line_to_x" +migrateName("treasure_map", "pinhead@13"); // -> "treasure_map" + +// Migrate from a seed source +migrateName("maps", "nps"); // -> "bifold_map_with_dotted_line_to_x" +``` + ### Command Line Interface (CLI) #### 1. Generate a single icon @@ -124,7 +143,7 @@ npx pinhead build-icons --config my-icons.json --outdir ./assets/icons ## Custom SVG icon requirements -To work with **Pinhead JS**, custom SVG strings must follow these constraints: +To work with Pinhead JS, custom SVG strings must follow these constraints: - Use only `` elements. - Path elements should only contain the `d` attribute. @@ -134,11 +153,11 @@ To work with **Pinhead JS**, custom SVG strings must follow these constraints: ## Versioning -Because **Pinhead** generally uses major version numbers, but **Pinhead JS** uses it's `changelog.json` too offer compatibility across versions, the major version of **Pinhead JS** reflects breaking API changes, the minor version reflects the version of Pinhead, and the patch version is incremented for non-breaking changes. +Because Pinhead generally uses major version numbers, but Pinhead JS uses it's `changelog.json` too offer compatibility across versions, the major version of Pinhead JS reflects breaking API changes, the minor version reflects the version of Pinhead, and the patch version is incremented for non-breaking changes. EG: `@waysidemapping/pinhead-js==1.15.0` bundles `@waysidemapping/pinhead==15.0.0` -If you wish to use a very specific version of **Pinhead**, you can import it yourself and use **Pinhead JS**'s custom SVG support: +If you wish to use a very specific version of Pinhead, you can import it yourself and use Pinhead JS's custom SVG support: ```javascript import { getIcon } from "@waysidemapping/pinhead-js"; @@ -153,7 +172,7 @@ const svg = getIcon(index.icons["bicycle"].svg, { shape: "marker" }); ### MapLibre GL JS -To use **Pinhead JS** dynamically with MapLibre: +To use Pinhead JS dynamically with MapLibre: ```javascript const svg = getIcon("greek_cross", { shape: "circle", shapeFill: "red" }); @@ -172,8 +191,8 @@ URL.revokeObjectURL(url); ## Inspiration -**Pinhead JS** is inspired by the [Maki Icon Editor](https://labs.mapbox.com/maki-icons/editor/) and [makiwich](https://github.com/mapbox/makiwich) +Pinhead JS is inspired by the [Maki Icon Editor](https://labs.mapbox.com/maki-icons/editor/) and [makiwich](https://github.com/mapbox/makiwich) ## License -**Pinhead JS** is distributed under [CC0](/LICENSE). +Pinhead JS is distributed under [CC0](/LICENSE). diff --git a/js/icon.js b/js/icon.js index cb9fcc09..c3f15c54 100644 --- a/js/icon.js +++ b/js/icon.js @@ -1,24 +1,6 @@ import index from "@waysidemapping/pinhead/dist/icons/index.complete.json" with { type: "json" }; import tinycolor from "tinycolor2"; -import { getSvgPathStrings } from "./util.js"; - -// minifiy is a JS template tag function to strip whitespace from XML -function minify(strings, ...values) { - // Interleave the strings and values - let output = strings.reduce((acc, part, i) => { - return acc + part + (values[i] !== undefined ? values[i] : ""); - }, ""); - - // remove newlines - output = output.replace(/\n/g, " "); - // trim - output = output.trim(); - // remove double spaces - output = output.replace(/\s\s+/g, " "); - // remove spaces around > and < and /> - output = output.replace(/\s*(\/?>|<)\s*/g, "$1"); - return output; -} +import { getSvgPathStrings, minify } from "./util.js"; // Hard coding for 15x15 requirement for Pinhead icons const size = 15; @@ -37,8 +19,6 @@ export function getIcon(name, properties = {}) { } else { const icon = index.icons[name]; if (!icon) { - // TODO: lookup using old names? - // TODO: lookup using original(eg maki/temaki/etc) names? throw new Error(`unknown icon: ${name}`); } iconSvg = icon.svg; diff --git a/js/migrate.js b/js/migrate.js index 4cabd71a..d88ecfb7 100644 --- a/js/migrate.js +++ b/js/migrate.js @@ -4,42 +4,84 @@ import externalSources from "@waysidemapping/pinhead/dist/external_sources.json" changelog.sort((a, b) => parseInt(a) - parseInt(b)); const validExternalSources = new Set(externalSources.map(({ id }) => id)); -export function migrateName(name, from = "pinhead") { +export function nameExistsInVersion(name, version) { + let found = false; + for (const change of changelog) { + const v = parseInt(change.majorVersion); + if (v > version) return found; + for (const icon of change.iconChanges) { + if (icon.oldId === name) { + found = false; + } + } + for (const icon of change.iconChanges) { + if (icon.newId === name) { + found = true; + } + } + } + return found; +} + +function migrateNameFromVersion(name, version) { + if (!nameExistsInVersion(name, version)) { + throw new Error(`"${name}" is not a name known to pinhead ${version}`); + } let resolvedName = name; - let pinheadVersion; - let externalKey; - if (from.startsWith("pinhead")) { - if (from.startsWith("pinhead@")) { - pinheadVersion = from.split("@", 2)[1]; - // TODO: validate pinheadVersion! - } else if (from !== "pinhead") { - throw new Error(`"${from}" is not a valid value to migrate from`); + for (const change of changelog.filter( + ({ majorVersion }) => parseInt(majorVersion) > version, + )) { + for (const icon of change.iconChanges) { + if (icon.oldId === resolvedName) { + resolvedName = icon.newId; + } } - } else if (validExternalSources.has(from)) { - externalKey = from; - } else { - throw new Error(`"${from}" is not a valid value to migrate from`); } + return resolvedName; +} - for (const version of changelog) { - for (const change of version.iconChanges) { - if (externalKey && change[externalKey] === resolvedName) { - // migrate from an external key - resolvedName = change.newId; - } else if (!pinheadVersion && change.newId === name) { +function migrateNameFromExternal(name, from) { + for (const change of changelog) { + for (const icon of change.iconChanges) { + if (icon[from] === name) { + return migrateNameFromVersion( + icon.newId, + parseInt(change.majorVersion), + ); + } + } + } + throw new Error(`"${name}" is not a ${from} name known to pinhead`); +} + +function migratePinheadName(name) { + let resolvedName; + for (const change of changelog) { + for (const icon of change.iconChanges) { + if (icon.newId === name) { // undo any migrations if not targeting specific pinhead version and a name is re-used resolvedName = name; - } else if ( - pinheadVersion && - parseInt(change.majorVersion) < pinheadVersion - ) { - // do nothing if migrating from a specific version newer than current - continue; - } else if (change.oldId === resolvedName) { + } else if (icon.oldId === (resolvedName || name)) { // migrate! - resolvedName = change.newId; + resolvedName = icon.newId; } } } + if (!resolvedName) + throw new Error(`"${name}" is not a name known to pinhead`); return resolvedName; } + +export function migrateName(name, from = "pinhead") { + let resolvedName; + let pinheadVersion; + let externalKey; + if (from.startsWith("pinhead@")) { + return migrateNameFromVersion(name, parseInt(from.split("@", 2)[1])); + } else if (validExternalSources.has(from)) { + return migrateNameFromExternal(name, from); + } else if (from === "pinhead") { + return migratePinheadName(name); + } + throw new Error(`"${from}" is not a valid value to migrate from`); +} diff --git a/js/test/getSprite.test.js b/js/test/getIcon.test.js similarity index 100% rename from js/test/getSprite.test.js rename to js/test/getIcon.test.js diff --git a/js/test/migrate.test.js b/js/test/migrate.test.js index 760b521d..01f88b34 100644 --- a/js/test/migrate.test.js +++ b/js/test/migrate.test.js @@ -1,6 +1,54 @@ import { test } from "node:test"; import assert from "node:assert"; -import { migrateName } from "../index.js"; +import { nameExistsInVersion, migrateName } from "../migrate.js"; + +// Handle rename +test("nameExistsInVersion pedestrian v10 -> false", () => { + assert.strictEqual(nameExistsInVersion("pedestrian", 10), false); +}); +test("nameExistsInVersion pedestrian v1 -> true", () => { + assert.strictEqual(nameExistsInVersion("pedestrian", 1), true); +}); + +// Handle rename to make room for replacement +test("nameExistsInVersion treasure_map v12 -> true", () => { + assert.strictEqual(nameExistsInVersion("treasure_map", 10), true); +}); +test("nameExistsInVersion treasure_map v13 -> true", () => { + assert.strictEqual(nameExistsInVersion("treasure_map", 1), true); +}); + +// Don't find old names that aren't valid in requested version +test("nameExistsInVersion bifold_map_with_dotted_line_to_x v12 -> false", () => { + assert.strictEqual( + nameExistsInVersion("bifold_map_with_dotted_line_to_x", 12), + false, + ); +}); +test("nameExistsInVersion person_swinging_golf_club_beside_golf_pin v5 -> false", () => { + assert.strictEqual( + nameExistsInVersion("person_swinging_golf_club_beside_golf_pin", 5), + false, + ); +}); +test("nameExistsInVersion person_swinging_golf_club_beside_golf_pin v6 -> true", () => { + assert.strictEqual( + nameExistsInVersion("person_swinging_golf_club_beside_golf_pin", 6), + true, + ); +}); +test("nameExistsInVersion person_swinging_golf_club_beside_golf_pin v9 -> true", () => { + assert.strictEqual( + nameExistsInVersion("person_swinging_golf_club_beside_golf_pin", 9), + true, + ); +}); +test("nameExistsInVersion person_swinging_golf_club_beside_golf_pin v10 -> false", () => { + assert.strictEqual( + nameExistsInVersion("person_swinging_golf_club_beside_golf_pin", 10), + false, + ); +}); test("migrateName pedestrian -> person_walking", () => { assert.strictEqual(migrateName("pedestrian"), "person_walking"); @@ -11,13 +59,24 @@ test("migrateName treasure_map -> treasure_map", () => { assert.strictEqual(migrateName("treasure_map"), "treasure_map"); }); -test("migrateName pinhead@12:treasure_map -> treasure_map", () => { +test("migrateName pinhead@12:treasure_map -> bifold_map_with_dotted_line_to_x", () => { assert.strictEqual( migrateName("treasure_map", "pinhead@12"), "bifold_map_with_dotted_line_to_x", ); }); +test("migrateName nps:maps -> bifold_map_with_dotted_line_to_x", () => { + assert.strictEqual( + migrateName("maps", "nps"), + "bifold_map_with_dotted_line_to_x", + ); +}); + +test("migrateName pinhead@13:treasure_map -> treasure_map", () => { + assert.strictEqual(migrateName("treasure_map", "pinhead@13"), "treasure_map"); +}); + test("migrateName temaki:pedestrian -> person_walking", () => { assert.strictEqual(migrateName("pedestrian"), "person_walking"); }); @@ -28,3 +87,92 @@ test("migrateName osmcarto:shop/outdoor -> person_wearing_backpack_walking_with_ "person_wearing_backpack_walking_with_hiking_pole", ); }); + +test("migrateName throws an error on unknown name", () => { + assert.throws(() => migrateName("fadasfadsfadsfasf"), { + message: /not a name known/, + }); +}); + +test("migrateName pinhead@10:pedestrian throws an error", () => { + assert.throws(() => migrateName("pedestrian", "pinhead@10"), { + message: /not a name known/, + }); +}); + +test("migrateName golfer_and_golf_pin -> person_swinging_golf_club_beside_flagstick_with_pennant", () => { + assert.strictEqual( + migrateName("golfer_and_golf_pin"), + "person_swinging_golf_club_beside_flagstick_with_pennant", + ); +}); + +test("migrateName person_swinging_golf_club_beside_golf_pin -> person_swinging_golf_club_beside_flagstick_with_pennant", () => { + assert.strictEqual( + migrateName("person_swinging_golf_club_beside_golf_pin"), + "person_swinging_golf_club_beside_flagstick_with_pennant", + ); +}); + +test("migrateName pinhead@5:golfer_and_golf_pin -> person_swinging_golf_club_beside_flagstick_with_pennant", () => { + assert.strictEqual( + migrateName("golfer_and_golf_pin", "pinhead@5"), + "person_swinging_golf_club_beside_flagstick_with_pennant", + ); +}); + +test("migrateName pinhead@5:person_swinging_golf_club_beside_golf_pin throws an error on unknown name", () => { + assert.throws( + () => migrateName("person_swinging_golf_club_beside_golf_pin", "pinhead@5"), + { message: /not a name known/ }, + ); +}); + +test("migrateName pinhead@6:person_swinging_golf_club_beside_golf_pin -> person_swinging_golf_club_beside_flagstick_with_pennant", () => { + assert.strictEqual( + migrateName("person_swinging_golf_club_beside_golf_pin", "pinhead@6"), + "person_swinging_golf_club_beside_flagstick_with_pennant", + ); +}); + +test("migrateName pinhead@6:person_swinging_golf_club_beside_golf_pin throws an error on unknown name", () => { + assert.throws(() => migrateName("golfer_and_golf_pin", "pinhead@6"), { + message: /not a name known/, + }); +}); + +test("migrateName pinhead@7:person_swinging_golf_club_beside_golf_pin -> person_swinging_golf_club_beside_flagstick_with_pennant", () => { + assert.strictEqual( + migrateName("person_swinging_golf_club_beside_golf_pin", "pinhead@7"), + "person_swinging_golf_club_beside_flagstick_with_pennant", + ); +}); + +test("migrateName pinhead@9:person_swinging_golf_club_beside_golf_pin -> person_swinging_golf_club_beside_flagstick_with_pennant", () => { + assert.strictEqual( + migrateName("person_swinging_golf_club_beside_golf_pin", "pinhead@9"), + "person_swinging_golf_club_beside_flagstick_with_pennant", + ); +}); + +test("migrateName pinhead@10:person_swinging_golf_club_beside_golf_pin throws an error on unknown name", () => { + assert.throws( + () => + migrateName("person_swinging_golf_club_beside_golf_pin", "pinhead@10"), + { message: /not a name known/ }, + ); +}); + +test("migrateName car_profile -> sedan", () => { + assert.strictEqual(migrateName("car_profile"), "sedan"); +}); + +test("migrateName pinhead@3:car_profile -> sedan", () => { + assert.strictEqual(migrateName("car_profile", "pinhead@3"), "sedan"); +}); + +test("migrateName pinhead@2:car_profile throws an error", () => { + assert.throws(() => migrateName("car_profile", "pinhead@2"), { + message: /not a name known/, + }); +}); diff --git a/js/test/minify.test.js b/js/test/minify.test.js new file mode 100644 index 00000000..cf6a5bdc --- /dev/null +++ b/js/test/minify.test.js @@ -0,0 +1,39 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { minify } from "../util.js"; + +test("minify strips repeated spaces", () => { + assert.strictEqual(minify`foo bar`, "foo bar"); +}); + +test("minify around start of xml tag", () => { + assert.strictEqual(minify`foo < bar`, "foo { + assert.strictEqual(minify`foo > bar`, "foo>bar"); +}); + +test("minify around end of closed xml tag", () => { + assert.strictEqual(minify`foo /> bar`, "foo/>bar"); +}); + +test("minify interpolates values", () => { + assert.strictEqual(minify`a ${1} b ${2} c ${"sdf"}`, "a 1 b 2 c sdf"); +}); + +test("minify cleans up a sloppy svg", () => { + assert.strictEqual( + minify` + + `, + ``, + ); +}); diff --git a/js/util.js b/js/util.js index e9832e37..fc306dfa 100644 --- a/js/util.js +++ b/js/util.js @@ -7,3 +7,21 @@ export function getSvgPathStrings(iconSvg) { } return paths; } + +// minify is a JS template tag function to strip whitespace from XML +export function minify(strings, ...values) { + // Interleave the strings and values + let output = strings.reduce((acc, part, i) => { + return acc + part + (values[i] !== undefined ? values[i] : ""); + }, ""); + + // remove newlines + output = output.replace(/\n/g, " "); + // trim + output = output.trim(); + // remove double spaces + output = output.replace(/\s\s+/g, " "); + // remove spaces around > and < and /> + output = output.replace(/\s*(\/?>|<)\s*/g, "$1"); + return output; +} From 688f8cf55f57db14fe19b454c4f5cfaae80d9c06 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Thu, 12 Mar 2026 00:39:43 -0400 Subject: [PATCH 20/32] version --- js/README.md | 17 ----------------- js/package.json | 2 +- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/js/README.md b/js/README.md index 158d23ed..f883f695 100644 --- a/js/README.md +++ b/js/README.md @@ -151,23 +151,6 @@ To work with Pinhead JS, custom SVG strings must follow these constraints: --- -## Versioning - -Because Pinhead generally uses major version numbers, but Pinhead JS uses it's `changelog.json` too offer compatibility across versions, the major version of Pinhead JS reflects breaking API changes, the minor version reflects the version of Pinhead, and the patch version is incremented for non-breaking changes. - -EG: `@waysidemapping/pinhead-js==1.15.0` bundles `@waysidemapping/pinhead==15.0.0` - -If you wish to use a very specific version of Pinhead, you can import it yourself and use Pinhead JS's custom SVG support: - -```javascript -import { getIcon } from "@waysidemapping/pinhead-js"; -import index from "@waysidemapping/pinhead/dist/icons/index.complete.json" with { type: "json" }; - -const svg = getIcon(index.icons["bicycle"].svg, { shape: "marker" }); -``` - ---- - ## Integrations ### MapLibre GL JS diff --git a/js/package.json b/js/package.json index a828d76f..0fd1d7c6 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "@waysidemapping/pinhead-js", - "version": "1.14.0-dev", + "version": "15.16.0-dev", "type": "module", "main": "index.js", "description": "Quality public domain sprites for your map", From da5647a519de65f4b67f556a6c524f3f12480922 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Thu, 12 Mar 2026 00:42:06 -0400 Subject: [PATCH 21/32] Update README.md --- js/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/README.md b/js/README.md index f883f695..f5c0bfb8 100644 --- a/js/README.md +++ b/js/README.md @@ -33,7 +33,7 @@ mapping platform. ## Installation ```bash -npm install @waysidemapping/pinhead +npm install @waysidemapping/pinhead-js ``` ## Options From 7a0c5239db9932f8dac6580268e4cef9f47f8c03 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Wed, 18 Mar 2026 21:24:16 -0400 Subject: [PATCH 22/32] bump --- js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/package.json b/js/package.json index 0fd1d7c6..c0be6134 100644 --- a/js/package.json +++ b/js/package.json @@ -41,7 +41,7 @@ "check-format": "prettier --check *.js test/*.js README.md" }, "dependencies": { - "@waysidemapping/pinhead": "^14.0.0", + "@waysidemapping/pinhead": "^15.16.0", "tinycolor2": "^1.6.0" }, "devDependencies": { From 0348960de13da7f14787fa597f851c4e19155cab Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Mon, 23 Mar 2026 08:49:34 -0400 Subject: [PATCH 23/32] rotation --- js/examples/beer-marker-amber.svg | 2 +- js/examples/bike-circle-green.svg | 2 +- js/examples/burger-marker.svg | 2 +- js/examples/bus-circle-blue.svg | 2 +- js/examples/cafe-black-stroke.svg | 2 +- js/examples/camera-marker-darkgrey.svg | 2 +- js/examples/cargobike-square-blue.svg | 2 +- js/examples/cargobike.svg | 2 +- js/examples/dot.svg | 2 +- js/examples/ice_cream-circle-pink.svg | 2 +- js/examples/jeep-map_pin-stroke-1.svg | 2 +- js/examples/pizza-square-red.svg | 2 +- js/examples/plane-down-square-navy.svg | 1 + js/examples/plane-square-navy.svg | 2 +- js/examples/rocket-map_pin-purple.svg | 2 +- js/examples/sun-square-yellow.svg | 2 +- js/examples/tent-square-brown.svg | 2 +- js/examples/tree-map_pin-green.svg | 2 +- js/icon.js | 8 ++++++-- js/test/examples.js | 12 +++++++++++- js/test/fixtures/plane-down-square-navy.png | Bin 0 -> 1059 bytes js/test/fixtures/plane-square-navy.png | Bin 1032 -> 1050 bytes 22 files changed, 35 insertions(+), 20 deletions(-) create mode 100644 js/examples/plane-down-square-navy.svg create mode 100644 js/test/fixtures/plane-down-square-navy.png diff --git a/js/examples/beer-marker-amber.svg b/js/examples/beer-marker-amber.svg index 6b33e2ee..3c39b6f7 100644 --- a/js/examples/beer-marker-amber.svg +++ b/js/examples/beer-marker-amber.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/bike-circle-green.svg b/js/examples/bike-circle-green.svg index 9232dd22..348e9d44 100644 --- a/js/examples/bike-circle-green.svg +++ b/js/examples/bike-circle-green.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/burger-marker.svg b/js/examples/burger-marker.svg index 10871667..66903d2b 100644 --- a/js/examples/burger-marker.svg +++ b/js/examples/burger-marker.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/bus-circle-blue.svg b/js/examples/bus-circle-blue.svg index 768fc95b..b80fc47b 100644 --- a/js/examples/bus-circle-blue.svg +++ b/js/examples/bus-circle-blue.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/cafe-black-stroke.svg b/js/examples/cafe-black-stroke.svg index c14fcddf..14f4680b 100644 --- a/js/examples/cafe-black-stroke.svg +++ b/js/examples/cafe-black-stroke.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/camera-marker-darkgrey.svg b/js/examples/camera-marker-darkgrey.svg index 1432c0d8..b0f4c4ca 100644 --- a/js/examples/camera-marker-darkgrey.svg +++ b/js/examples/camera-marker-darkgrey.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/cargobike-square-blue.svg b/js/examples/cargobike-square-blue.svg index 478fc357..5101260f 100644 --- a/js/examples/cargobike-square-blue.svg +++ b/js/examples/cargobike-square-blue.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/cargobike.svg b/js/examples/cargobike.svg index d5f26ea3..64010484 100644 --- a/js/examples/cargobike.svg +++ b/js/examples/cargobike.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/dot.svg b/js/examples/dot.svg index cb9dbe97..6429b525 100644 --- a/js/examples/dot.svg +++ b/js/examples/dot.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/ice_cream-circle-pink.svg b/js/examples/ice_cream-circle-pink.svg index 763367fd..e9c111c0 100644 --- a/js/examples/ice_cream-circle-pink.svg +++ b/js/examples/ice_cream-circle-pink.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/jeep-map_pin-stroke-1.svg b/js/examples/jeep-map_pin-stroke-1.svg index b8b39694..1468c927 100644 --- a/js/examples/jeep-map_pin-stroke-1.svg +++ b/js/examples/jeep-map_pin-stroke-1.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/pizza-square-red.svg b/js/examples/pizza-square-red.svg index 2ae1471a..ef247196 100644 --- a/js/examples/pizza-square-red.svg +++ b/js/examples/pizza-square-red.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/plane-down-square-navy.svg b/js/examples/plane-down-square-navy.svg new file mode 100644 index 00000000..478fa7c4 --- /dev/null +++ b/js/examples/plane-down-square-navy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/plane-square-navy.svg b/js/examples/plane-square-navy.svg index dd0e5ebe..4c1af70c 100644 --- a/js/examples/plane-square-navy.svg +++ b/js/examples/plane-square-navy.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/rocket-map_pin-purple.svg b/js/examples/rocket-map_pin-purple.svg index 7f234493..7e064112 100644 --- a/js/examples/rocket-map_pin-purple.svg +++ b/js/examples/rocket-map_pin-purple.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/sun-square-yellow.svg b/js/examples/sun-square-yellow.svg index c030f430..e0454637 100644 --- a/js/examples/sun-square-yellow.svg +++ b/js/examples/sun-square-yellow.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/tent-square-brown.svg b/js/examples/tent-square-brown.svg index 19e27eaa..fc2ac592 100644 --- a/js/examples/tent-square-brown.svg +++ b/js/examples/tent-square-brown.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/examples/tree-map_pin-green.svg b/js/examples/tree-map_pin-green.svg index c796fc02..17b3dd00 100644 --- a/js/examples/tree-map_pin-green.svg +++ b/js/examples/tree-map_pin-green.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/js/icon.js b/js/icon.js index c3f15c54..fc8abbb9 100644 --- a/js/icon.js +++ b/js/icon.js @@ -153,10 +153,14 @@ export function getIcon(name, properties = {}) { // Nothing to do when not drawing a shape break; } + let rotate = ""; + if (properties.rotate) { + rotate = ` rotate(${properties.rotate} 7.5 7.5)`; + } for (const path of paths) { if (!shape && strokeWidth) { svg += minify``; } svg += minify``; diff --git a/js/test/examples.js b/js/test/examples.js index 841edb7b..2e98877d 100644 --- a/js/test/examples.js +++ b/js/test/examples.js @@ -44,9 +44,19 @@ export const examples = [ }, { name: "plane-square-navy", - icon: "plane", + icon: "plane_up", properties: { shape: "square", cornerRadius: 0, shapeFill: "navy" }, }, + { + name: "plane-down-square-navy", + icon: "plane_up", + properties: { + shape: "square", + cornerRadius: 0, + shapeFill: "navy", + rotate: 180, + }, + }, { name: "ice_cream-circle-pink", icon: "ice_cream_on_cone", diff --git a/js/test/fixtures/plane-down-square-navy.png b/js/test/fixtures/plane-down-square-navy.png new file mode 100644 index 0000000000000000000000000000000000000000..fb8952246dcdec5e5a02c193c7a3c67068c5a7b5 GIT binary patch literal 1059 zcmV+;1l;?HP)$1KBS zNOr%w+uftH?@VBmnI7bs?Pl+KQ?1m|lgfFQ!GvWnVHr$V1{0RSgk>;c8BADku)DiG zb#ii}E-wD4@pxihslVmrmfG6-sCIW()!N!m^GdjagFQVRny=4|Mt@)MCX+|=O8Hu? zg#u}N`&03-X?<+IhD*l;d);90yLj~bx8|9D7Kz#PR{t%SjhO}<4`$w5t*(AhdwX9B zC}!5Y-aQyI;q-LNjNGxt0mrNZ(ju`&`PA=^%+I?pqi_WWBMDmBVqj4eFbatl3yY$G zQ3$k{SQHhE>O_l;MbW{i4zv_llmv{bpryj1WMFi1^3`k|*3HPjgM&-+=Wfg>O-aGX zwXvNp^v2Yc&S0{%3+(EuQ~VW}b=p$3rhsvdy1&1!HaGj`+3d6n%)TK`TaJ!~X2HER z&+5w_-?zHKI-SqveYIaszg)-HzF;=a@tASl?yq88wdv!#a=js+IDzLpl^MnOb*|%U zDVPncUT;*uG5g9k>-e*By`gzAi-2NFGYw^P9bdb^IJj%SWZ}3#C@ltukFSgGzJ&y9x0lu7;YRV@l@AtaA29MoBW4=woX-bD2rw3M)+c6` zPrg{BeZfeAMyzcXjy3PC>H=d;vQVs5zDcl1OTb8qMqmNQI>-2RRl$H_eFBQHBq+pzvS7?2)+c_KGL^w1T?UN!Ma!ZQ*Y25doJmMxC(exGT7TKfVo_-@ zs*0APDp*trjNTJkRV=CuM(+Ww4lJq#jGByA7Z%k9MomPk6N_pEqb8x%jYYMCQ4`QY zU{Me-N{JQOf0@MM=P@3R)^GN(M${&{ASiQZOoei8@s}gUQkvOqR}IvUCQMr8Af;oxx=3 z)L`TBo?2RJnUTBu`-%A%@2(l8D>>M3*ijoB%gU$g>#=IL`(~6bof=GXuYR|CqxSbN z%_v>T!MI1_{QR34x!c(}EAGiiS#mI%Ounnd#RW5(-NVD9YPEitQQ8^-*6VGlPG{AO zW|#V;+tvs$?)BooYna!{O4od%PRuB6jR2#0i+>Xqds}0`xZQGW_GHs*8EY4NujyMo zQ`HzS;;v`*Wfl!A_FdeS-IN*yMxYG_&)bac?XyN;c{{XgpZ68kjXl?)i002ovPDHLkV1g`+1Ec@| literal 0 HcmV?d00001 diff --git a/js/test/fixtures/plane-square-navy.png b/js/test/fixtures/plane-square-navy.png index 3e80ae6bc2ec84a25cb6910dbb891c517556a5aa..2360cb256d51aa064c8863f9d34dc4b0ca2cb71e 100644 GIT binary patch delta 1028 zcmV+f1pE7l2$~3xB!A&aL_t(|obB2#QyW1T$MIcPM{qcTDvr3~5>#b^s!ULo#8o!P zy5tp*$SM+9MR4GPql(~g1c#&h%gn=MLXX|&?slJr+3yUniMU=qv%P;=tyJ$>rJPG( z%n}&01ja0ZF-u^~5*V`t#w^6zRu+u0c`|He=>VEz7Ab$q;UMz0$TE>*Yt$Bg8Q0<2oC zEN9nSVV2G33p0`}3b5g@soL$D8D%#b%~Yo|F(cWc0DE}&@#@&M9*$v~o4?FRHgT|4 zYxm_c!WJ$w#((1*Gm=Xj?5%_SdM77S)$3iDkz85{gH5MxRj*f-OBWY&)o6^&NG>f6 z=5qX^aF)c`QZjL{=jWg1w|iQjnBQ{g1jb7zFkU)=@zM#5mrh{3bOPh0CBZtKnmRpg znvr|M;Y=MJP0UCpF|a&Xlq8x6SP?8r3N1KT8CaAAT7OWmGO?)8Xu-gIU{N8_f`IwN zqC%m02Md8kg+TKP77~l{jphwZ0*msA<^@a=i}Hn5I#>)?ln=C0!D7Oq%0?>i4gfD-JnfMPj%6MKznd=2gCm zpydK9f`5f&OIRO)Z?RF{N7PtT*6Y=+%cQc(i>A3?d9WTIpUiB!G(T6%WlO8&1*5Rc z05h)J-B%kMpUgY% z>+Aa$C|hNLt%iLC+}_@nd1Vj#>58UZz_0@@SbtcKgyYk6I#;gw0HYd>s@b2q({d_T zG_5RPE?78_V@Bm!Pwx|q!uo&AINJ%r1x?$)vOW+FgtboHL z$T~sTwy(KhfXX`K6~UsU(1L@NfkjE61qCY;iwcbv49o`>6%s87m`^M!6qxMIJQ16&?%{UbQ+f y@A%6**hedYF-u^~5*V`t#w>v`OJK|r7_)zZQfeQ42N+fW0000!>gq^cB~`@{M;)M$1yvbPl_au? zAgiDf1-RBFu2mp$1Xl;Lj_%70L*{6*`!BnZHCD{qv+R7(Q|fpFU-lwm5tJqjj;3cZL_iQ z&PLC6N*=`(mT%yng-F+~5DTQChMP_P=6ZC70r#g|L2q-E=yu zHkwYaH!$7q(0@j05tD(i!QhiwTWgtF42L7rZhyB?S|kTy)GzATET~`9vD9QC?BU_S ze!3Cq+0V2{5vD_mFdb5a>5w8!hZJEtqzKa?MVJmL!gNRxrbAK+W4g+eVrAux{m#wR z)t~Xyf+=h&k_qc{KG^9)+xZMxg=fnB{h!Guu#F2t5Pzd+W?=KcY>d%00-K~!VeNKn zoPtOT0kTNZ!C+*XLYfkGc(`u%_Ev2ao__z=I8B=%x-belDabJBlTEe)S zs8y*7dtKNNsBPSJb&xv3s8iG{>efqydPZHNzG+bjLlAcrJK-Y04$9qzKwrFtk$T7? z6&DFo6KSNX58@+?^b7qQczb(4{v`*4+wq0)fqxTgx0mf#%PcR4y>PzIqv8^FdHH$V zHq6VUAdXzwN9s&mxziCBQXp-|`}f~$G#zmX^F5g=vjkP~U1L&Ml~4j_tpO5Bm_|?) z-!)t(Gf7>6t~I2tuz3VknYKn1*U8MnSDng zad-%i+2+hbQV3(7hAif5bzpvlH0D`N)2J{6Aqrt3Kp5f>*c6QkLlUx(77}D34S7uv zT^NE8g|L_)3~>mINmOA-LKf1R09i;wUTZ9(3iIu+LKCxJlbnQ2j*y@_dhumpg-r_L zIIXv$XG5H?z=aX!E69aQ;ENSD8g<5E<`v|kDzMdx_m4p&rm#h%2vbsoDJjB~6k$q= gFeOEpk|IpWKmO!uA6P2B3IG5A07*qoM6N<$f@TldJpcdz From a4e22cadea1f66d5ca8c5512d2dcb187cf160d63 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Mon, 23 Mar 2026 08:54:36 -0400 Subject: [PATCH 24/32] translucency! --- js/examples/cafe-black-stroke.svg | 2 +- js/examples/translucent-cargobike.svg | 1 + js/icon.js | 9 +++++++++ js/test/examples.js | 9 +++++++++ js/test/fixtures/translucent-cargobike.png | Bin 0 -> 1990 bytes 5 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 js/examples/translucent-cargobike.svg create mode 100644 js/test/fixtures/translucent-cargobike.png diff --git a/js/examples/cafe-black-stroke.svg b/js/examples/cafe-black-stroke.svg index 14f4680b..5a58291c 100644 --- a/js/examples/cafe-black-stroke.svg +++ b/js/examples/cafe-black-stroke.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/js/examples/translucent-cargobike.svg b/js/examples/translucent-cargobike.svg new file mode 100644 index 00000000..2366350f --- /dev/null +++ b/js/examples/translucent-cargobike.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/icon.js b/js/icon.js index fc8abbb9..6ba71d37 100644 --- a/js/icon.js +++ b/js/icon.js @@ -159,8 +159,17 @@ export function getIcon(name, properties = {}) { } for (const path of paths) { if (!shape && strokeWidth) { + svg += minify` + + `; svg += minify` zN@Amu;V_iul85-piSK%Jo|DTtT;JcmKYhQi*XQ9+cxUZt&Sd6n@V)=5h=LKzd@L*4xMp+H=_F>y{_@=y8W=7SfoTYgti~T@} zsF4e{Y*#u|;B>^r1)G}cB;Rma4YPQosA^#}1C)93NDA6d!}kWp3h+KPziJu_UJ}o) zo>^)hl(oz36Qtziv4@DxtSgb-+xw9bQz>}EZiwRljw#Bdou6UqcTl&+t51$uZ-p}L z6)utdlsy1fqpDnSf_I*Sk z3P<*UvO4&bqs?2pDXVuI;qZbwD}{PPhfLYJXnNNgf3Vml!04Hf079#6Xw>Lj^2;rb zu+OxZ%q&YjH4xz%SeQoCACsV2#L+y0p-q}U{=ow7wf}{R-bS=i+2nxm7{4#iEy_Z4 z=-_V8LNL|2!rMTaw6iig_)^X4!v*Q_W6?behb#6Jd(0WsP+L4=%q@}ax|`c_;Z5vy#^r@< zx_S%c&54`JM z(xTJei(y5bq=*4Hrld6+C(wJ5zx)Us>(to}R=+cDxnv4BMA>#aWk@V)?K;U0x*S@Z z^{{_`fd>i*-J18hzqi7TozY2Y8Ov-+ayLPb*~12INNAK)l!}!oUCgTgXv88t<&&e7|oMrZQ4edJ8XOtpu2tH-pDTqE;odtaC9wf-Z3Cxo%<}jW&}5DJdfjTg(#F( zcaLH}9!Z}Ng}7Qs#YIm@Ip%ahCC8iuK3C());2{Z<|%eI_%F7RcSii4x}_}EsD>WX z)l;QQc49jEE`)r~_*_PJq44vze6f|m^;h<%vYN)HjReXo`-L_!eRB;qdMkSvm46Kh z3yI=i#aAGyleOvO)s^*R!R4c7ujSa3 zX56B7GYc_0N#*5w!X4u89!rEkJ>tFnU4G2P%oo!d!xH=5v!8w8H?gf9b@_@mw2Iq30<-wBU z*>~KSsWb$}ExThm;n6f*@UY-rC*c+c?w$XE=3iIQg*PV8Ne^OcbAl{Llk`Zt&Ooi_ z{7bKy+oRta8;1K6O{hnoN4sK;zW4W%4b%(lk;9g13mN4t??s8a)ZH<9763f?~i-bj8Q z$_k#*xbr3`kN#??tsoj0{Zl!oCI{YJ)LFA0cOcAGb@wepNOz8IGB|GAWGUf8?VF@$ za-f1=4CIxDqKTJ1cAxau@jX#z;|M+o@FwhOx_dlOxOB2!9m8pfq$**J(`l^yJZs|* z@HoYfiDOW2?u79~kbZg*$m)In-&{NJFoj**dm>pk)>@QOr*_|7P(H4rX=6@ONH(nx*g38+rkbFzGc^K*vzz*e)U-n>*|APb_q-Q E2V+r>1ONa4 literal 0 HcmV?d00001 From fc6cddbd51cf211a68fa718c3904eaec6a2b0642 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Mon, 23 Mar 2026 18:03:43 -0600 Subject: [PATCH 25/32] flip --- js/examples/cafe-black-stroke.svg | 2 +- js/examples/cargobike-stroke.svg | 71 ++++++++++++++++++ js/examples/plane-down.svg | 1 + js/examples/translucent-cargobike.svg | 2 +- .../upside-down-jeep-map_pin-stroke-1.svg | 1 + js/icon.js | 20 +++-- js/test/examples.js | 12 +++ js/test/fixtures/plane-down.png | Bin 0 -> 888 bytes .../upside-down-jeep-map_pin-stroke-1.png | Bin 0 -> 3833 bytes 9 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 js/examples/cargobike-stroke.svg create mode 100644 js/examples/plane-down.svg create mode 100644 js/examples/upside-down-jeep-map_pin-stroke-1.svg create mode 100644 js/test/fixtures/plane-down.png create mode 100644 js/test/fixtures/upside-down-jeep-map_pin-stroke-1.png diff --git a/js/examples/cafe-black-stroke.svg b/js/examples/cafe-black-stroke.svg index 5a58291c..7c882f58 100644 --- a/js/examples/cafe-black-stroke.svg +++ b/js/examples/cafe-black-stroke.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/js/examples/cargobike-stroke.svg b/js/examples/cargobike-stroke.svg new file mode 100644 index 00000000..10635037 --- /dev/null +++ b/js/examples/cargobike-stroke.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + diff --git a/js/examples/plane-down.svg b/js/examples/plane-down.svg new file mode 100644 index 00000000..e21e56e8 --- /dev/null +++ b/js/examples/plane-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/translucent-cargobike.svg b/js/examples/translucent-cargobike.svg index 2366350f..9c2e31b0 100644 --- a/js/examples/translucent-cargobike.svg +++ b/js/examples/translucent-cargobike.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/js/examples/upside-down-jeep-map_pin-stroke-1.svg b/js/examples/upside-down-jeep-map_pin-stroke-1.svg new file mode 100644 index 00000000..92867a19 --- /dev/null +++ b/js/examples/upside-down-jeep-map_pin-stroke-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/icon.js b/js/icon.js index 6ba71d37..e1139020 100644 --- a/js/icon.js +++ b/js/icon.js @@ -153,21 +153,27 @@ export function getIcon(name, properties = {}) { // Nothing to do when not drawing a shape break; } - let rotate = ""; + let extraTransform = ""; if (properties.rotate) { - rotate = ` rotate(${properties.rotate} 7.5 7.5)`; + extraTransform = ` rotate(${properties.rotate} 7.5 7.5)`; + } + if (properties.flip === "horizontal") { + iconOffset[0] += size; + extraTransform = `scale(-1 1)`; + } else if (properties.flip === "vertical") { + iconOffset[1] += size; + extraTransform = `scale(1 -1)`; } for (const path of paths) { if (!shape && strokeWidth) { svg += minify` - `; svg += minify``; } svg += minify``; diff --git a/js/test/examples.js b/js/test/examples.js index d1ce124d..dde5d277 100644 --- a/js/test/examples.js +++ b/js/test/examples.js @@ -106,4 +106,16 @@ export const examples = [ strokeWidth: 1, }, }, + { + name: "upside-down-jeep-map_pin-stroke-1", + icon: "jeep", + properties: { shape: "map_pin", strokeWidth: 1, flip: "vertical" }, + }, + { + name: "plane-down", + icon: "plane_up", + properties: { + rotate: 180, + }, + }, ]; diff --git a/js/test/fixtures/plane-down.png b/js/test/fixtures/plane-down.png new file mode 100644 index 0000000000000000000000000000000000000000..9efe93b85053a06bd318c6a28fba54397e5b504f GIT binary patch literal 888 zcmV-;1Bd*HP)AEtpIm`=^#`XG#t^)Djk;Hk7ZE3Z; zif<w;L6BQBG>fMa|>`~W+;`b-sk{Vfea;cl^-48DwLsgu5zOTTm>`K$dx}jz*RUy ztz7d*2e>L^sM!wjS5Hd6BEGIlwFk#qq*V1zRS)mGxxt?izlEMrqZTQ}JJsj|w(;=c zLI+77)&0OI*>L{U3?+NOM@egup~Z0xcKCz_xt*RrR=iVgD?DP075*z8e(6FDp41IL zXnvQ@zf`=_+}7%bx=w};8C>JPFQ3#6e?@97Qi^vFqZ4v86Dud zl3_}&+aDd^8p$v<*X@oDa81aNlI!+H2e>9?NX>OSqXS$C8Rq1A`_Tce#0+zDz1`>l z*NhA~xt1Rt;F_5sH`j8b16&yyO600PI>42gp;WGVqXS$8GL+0!esqAVP=?aE%8d?i z70gg0SN`Y#SK$n`a?Kwd;Hr?JX0G(n0j^3JYS$vAs&}e-c;EZrk*eOQ>fwFwgGZ`* zr>ckdy$>F#>Yb_{-gl7nJ*M;z)~cAHb}y2C!j%5Py((s?-45}ilPSfIX11u-5`#DR z6I1#HdsJ(Q!LbJ4;aXp?Le-WU{G{%~>}v(MDrZ=twYq=zxz-OjsB(s-+TjuB`h+d2 zx8&dz4*LJI`~_=u&w2F>OLm7BeH|Ug-mCkopha4GaO@6y#5Q=vV*Lvq@Ti%K=Fjo~ O0000GjSGf6;S+Lp=V)+a5^e>{(-{8v^U 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 literal 0 HcmV?d00001 From 155cebdd3970c4b3d0ded7ebde2e30666bcc8720 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Mon, 23 Mar 2026 18:04:04 -0600 Subject: [PATCH 26/32] fix weird bug with hole in halo --- js/examples/cafe-black-stroke.svg | 2 +- js/examples/translucent-cargobike.svg | 2 +- js/icon.js | 4 ++++ js/test/fixtures/translucent-cargobike.png | Bin 1990 -> 1975 bytes scripts/build_preview_docs.js | 5 ++++- 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/js/examples/cafe-black-stroke.svg b/js/examples/cafe-black-stroke.svg index 7c882f58..fff3ed15 100644 --- a/js/examples/cafe-black-stroke.svg +++ b/js/examples/cafe-black-stroke.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/js/examples/translucent-cargobike.svg b/js/examples/translucent-cargobike.svg index 9c2e31b0..330fa69f 100644 --- a/js/examples/translucent-cargobike.svg +++ b/js/examples/translucent-cargobike.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/js/icon.js b/js/icon.js index e1139020..bf552263 100644 --- a/js/icon.js +++ b/js/icon.js @@ -172,11 +172,15 @@ export function getIcon(name, properties = {}) { d="M${-strokeWidth} ${-strokeWidth}V${15 + strokeWidth}H${15 + strokeWidth}V${-15 - strokeWidth}Z M0 0 ${path}" /> `; + // linecap & dasharray are a hack to fix problems with self intersection strokes in holes + // https://stackoverflow.com/questions/69006152/svg-stroke-overlaps-disappear + // to see the problem, remove the lines and generate a cargobike icon with stroke=1 svg += minify`=#eHEeSw%)>vnMlF zR6>O$MK;$^D7oa4lH5YM?5RAR^ZxVx@t*g4KIi-S<9ohhrFx}YG(DNqUc|FF-yQwkNL#bcAxo0 zBx6%fq2@RA|LA`#;-2;J;4Wjtb-cj3(+ zB7U|_=q@JK2?RvBRqN|0diaw$Ss=?=7OuNMM>63mGf31QYN1*W5rDnG0V!^Nn2Mit+_oy2enFJF`ni z6W39)mXQgTB>X69NHb=|nWG~ZvtIo_Wn3lOEuOteciEeA-MX=dHAgCLk@sYB{OP;` zy^Pz(XHCfvV?Ct5`1hMV?U>3lDZ=IwDMz(4M_b~(aIjKTDJRe3KuAX}ygn|I_Hcd{UD6qUrN)2n(F@v%0y`ay{xTtc9%9zWYxs>oyu7T+>elTho zZvxq&Fqiba)Bv#t-Eb#m7lz{u^=xnUisP779d9#MY8B_HQ7hu=R|C`?RKf$e^J-)LT& z%T9m04{jb5E@@yy@J6FZR$)qP5`jv4(V4Z_QsiP{MM19Kzl)!S*A_ZL%H`yP^nF%- zniYkLebiS|^@Q{TH51;KX1^p(O&uy(ztpZ9al&C=%-70dfF(2JV)e2Aq>PRIAoL0A zb(82DI@r2Zh$<93vWj$a&+`orgll}aY+EHZmBX12y{~0jjnNV(!ltBRZT9$hhqdJn z!7LMX7Mcn>yt0Cu-}SZO^{yBgqD0KSh%wi+?pRmS!b5t%_97X6P3<8+{gEBGLNs~j zN$b*>1>yWC&29v1d*pz#gb={{;La7n9qt(e8ku!xiiB=#Mesj_9SI{ve&fa z>r;W<2}!ktkeSnShH8y4?$k0$lES;gW)^nRSArPz1d+4aUsfR4OHDsYl|=vYdXky3 z5l173{%uxuJ&7O~C`tJct*%&kZ9hjf_8?%>*feGTtj5IoXAcEbUOgZlT@N{jlCLiY zWVMBpv>2K1HSvQUUAB3*-keJIlRc}{Qf}n>wXCODdAhb<0r@ivOq;cz^;X;LGbjRI ze@8hpzjMDuL{IbUMD>qFk+f_F11_d3*KhbXy-f8-wFAM5brk;Scr~qb?P{Msl?2dW z63M+nzsj{D9o!7IFF}^5hW7~nAi90pjPM+zI5lh*Q@eouL#Zv-2gVerH=e+Lcz+fK z(g)>iTHqMQ4f|%L?ry*JCBGvN|G8y1RW8rP@hvRcz=-m{wb?2?yB`S30I+wv_$@sj z^?d3~a)?{nWa0U>?r+=iqx#(J!~vAtdY@?s{^R2E{srq;9Ui9Tmb4PJIYwH5`SkQ= zU~BSnK%?y9F<69;On^|{01}7DJ9=c%@6UxynO+Cedc$@%P2#>YK{|LGqoh}^XLL9U zS)6xYha$E`E>j5e$$O5%pMs*Nmgv*1i}TV0bxhiwrN?4Kp_984tkFzyExPdGsU=jn z#L#a^%!x-{9^yCW0qN{$)y#se%&2qE@%EE5GKVTPeu+Ff0=pvfUxStKE;tos^;U%e zWpCHeoetQmWlAq_tbR zgKd#gs1b33q;D1uli{Kwt80J;lVs~t`Ej4Jb{iNz+d!CnA0Vg;K{R}mW*|AVs)Twh zz2s_Wxy5aSi?$GHd-8|x56ETV)hL>SbSUdRG~(6s=)s;v9 delta 1976 zcmaLY`#;l*1IO_WXN)6va#?B37?s9m=pt-nLu28%o@_HLnQ|nl<0H9_9kb1yEE6M% zjY@`tp)@DC#8-~^uB+pooE!(|JRaYlzCV1w@5k%?H#{F{^lPw90Tv$T?3-A=SnP38 z=M1F3D_yg!8hz)WZ8>sXK|3pxzrM%PJS&CyP?@Be2eE8bK2YFv$i)Spmgb~TcTycU zf3t``H=L=Ob?Hzl)>qT}Ce8}VyLS{C@_=n6~mV;lBIzsy%!5BnhoB@NUsUfUk->=voxt<*A0XT%k;d z9O!?f{@dz=Rn4BSe2Z6?3u%2-%v=I@$QSz8GqY0>ffxeW9hTC@qa1GB*iK!(SC2#% z)LJPDjU2M%z^Zt1+nTt)*v8-ZxyVNqtG=dLt$W!wuQsj;Tl+&PBIvg zVpB-NY#$?=bU)(5Il>#iixu7VSmmk3 z0IaWWL#>S(lB_IVvV%x<&9!AM&)o7onWUHnMk?*B$beHXcOG#bl<=X3FvgmaSSeC| z1%A)6YQ4o!15~O@zDg%BPO9P4SCZd>N!L)XlCj9|xi>szMJK)H!;0DiBKnZHlI9$O zK>ua_;$ujxQ+q2!C0vvRJ0&F^+Fd`K-cN}L1T?sADe$=}=$5r(Y-I(=!u(QO8 zozhKh8Ip(AWrjNDK>~Ozf{r!Jz0EzUvitU6V4}_}-M?5JpOEOCm(m_E+GX^IBgOBs z#=WhH*~aTue^{Yo)}S}T`r;+V5q`ZF-a!xR)3|@$RJQJWcO29A__UC03&h&bH+wy9 zOconQUZ>YMx@bGVl1%kbF_I?szkSS)VaG0rO5)nGDmqSFvbc{@fRR_=@70=_4v7w| ze1RFh!ejDfyUxDx*dcWk)9h%By+Lz_eQ0kzR-^sK0f#08XKDa@^zMo(?QGDhqJ94S zFG+T9K_#BV*_2i=aeSu-=q_;@QYZRPrD+jly*VxG=040VZQSG+Mxo&_Tb8|U8)5FS z_Km;Z=EeI%KchID5Q^gAm9SYyfB#j^^YH2+!nDaOfx8i+SX$LNjQ?~fV@w?4Y9W&p zJq^e;rwb}s<^hoN)vin(I69H7)L!Q|-$LFR@_pu(I$ymv^a!A*zn3oEifQY)7;=j7 zrHt-E;juTo@s+{CtGg4~4WpCB0+prRLYtVLnK~Q&r5&8wzXn8wB*`z5tI)LZnhf&t z(h=LKO);Yo*C9uHTP1zX)JhuY_o~eM@C;ZDOeqQ>v%%ggme zTd+lk>}_lk5ISAC=w8Z*#+Nrd!q5t_^~@NLFCRnSwu4*x=yT{WE#b*$5z>dDkr|jfTRx<+Lp( zJf5Tr9u>TA_qokNdgXtl`PEi*5KYK4vi2N>F`9tuB(LcX#E|<4U`sdpb7) zfYT=LcOHlrMD@i{L5bZP4i)|%En-ifuaZLq$Gf{Q&3kVp8JF`O`fx9RON_-WKPd{# z-b*UOdv7e0pIp1|jX)oj44+G{Rn@?{1;kJ%`q;P7;fPEqerw{>XE4CC=0LKOt;mHX zjvTX)K~hpUIDtB{1VzSD2Zqtqsv026(98bSKvcr(qez-I^t$?6>2Cws!Bd)d-v+Vi zuLoKRqQTKWmb0pJk&Q*|)vIy)!ff|$zhj8#&e08qM{OG{r97B@gY0xJOz^Xzg7QE# z>5BXI)81O12j*Oy&sh{?9q}yVOgvb$a9pT?V>LxmmGLGSw3K|dwaG_h9H8_maRlbY z88aCRGRP=WwffNe7sn1VNMROt9|QDat;MOe>JRJ%<)dm^Hs%4!37ChVi5e@Zm@ojb zo-@=}|Dm@~tMq+FT{p-PA-nD&FC={@Z1c7Lc;`tSxHTtE>s-)F$gU~?a(QS?k%G`> z74RuOYcV&V!2oftQ=kBh;oM1GYf!SMYF+D|&T7>=+(n5Z#pi%DIH_>-wSvhr zjm9Qw7m&ZBRYuMR&)kaear_T?|HYm4|KV>}QKi_@% diff --git a/scripts/build_preview_docs.js b/scripts/build_preview_docs.js index 8ae39c1f..e2c48d22 100644 --- a/scripts/build_preview_docs.js +++ b/scripts/build_preview_docs.js @@ -12,9 +12,12 @@ copyFileSync('metadata/external_sources.json', "docs/external_sources.json"); ensureEmptyDir(`docs/v${majorVersion}`); execSync(`cp -r "dist/icons/" 'docs/v${majorVersion}'`); +ensureEmptyDir(`docs/js`); +execSync(`cp -r "js/" 'docs/.'`); + function ensureEmptyDir(dir) { if (existsSync(dir)) { rmSync(dir, { recursive: true, force: true }); } mkdirSync(dir, { recursive: true }); -} \ No newline at end of file +} From f59aaf1fc4ae660d8957c7227e2732ea56d4c6b3 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Mon, 23 Mar 2026 18:06:28 -0600 Subject: [PATCH 27/32] doc --- js/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/js/README.md b/js/README.md index f5c0bfb8..bac605e5 100644 --- a/js/README.md +++ b/js/README.md @@ -26,6 +26,7 @@ mapping platform. - **Icon Composition:** Layer any Pinhead icon onto background shapes. - **Smart Coloring:** Automatically chooses contrasting icon colors based on the background fill. - **Multiple Shapes:** Supports `circle`, `square`, `map_pin`, and `marker`. +- **Basic transforms:** Rotate and flip icons. - **CLI & API:** Use it as a command-line tool for batch processing or as a JavaScript library in your app. - **Custom SVGs:** Pass raw SVG strings to compose custom icons - **Migation:** A function is provided to simplify the usage of Pinehead's `changelog.json`. @@ -50,6 +51,8 @@ These options are common across both the CLI and API. | `shapeFill` | Fill color of the background shape | `black` | | `stroke` | Color of the stroke (applies to shape if present, otherwise icon) | Auto-calculated (contrasting or darkened/lightened) | | `strokeWidth` | Width of the stroke | `1` for `marker`, else `0` | +| `flip` | Flip the icon: `horizontal` or `vertical` | `none` | +| `rotate` | Rotate the icon | `none` | --- From 48aba627ae4fa815773d8e5a60a7b6648c597397 Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Wed, 25 Mar 2026 22:00:00 -0600 Subject: [PATCH 28/32] custom shapes! --- js/README.md | 71 ++++++++++++++---- js/examples/map-pointer-cargobike.svg | 3 + js/examples/png-map-pointer-cargobike.svg | 1 + js/icon.js | 27 +++++++ js/package.json | 1 + js/test/examples.js | 20 +++++ js/test/fixtures/map-pointer-cargobike.png | Bin 0 -> 8053 bytes .../fixtures/png-map-pointer-cargobike.png | Bin 0 -> 18223 bytes 8 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 js/examples/map-pointer-cargobike.svg create mode 100644 js/examples/png-map-pointer-cargobike.svg create mode 100644 js/test/fixtures/map-pointer-cargobike.png create mode 100644 js/test/fixtures/png-map-pointer-cargobike.png diff --git a/js/README.md b/js/README.md index bac605e5..80e74ff2 100644 --- a/js/README.md +++ b/js/README.md @@ -28,8 +28,9 @@ mapping platform. - **Multiple Shapes:** Supports `circle`, `square`, `map_pin`, and `marker`. - **Basic transforms:** Rotate and flip icons. - **CLI & API:** Use it as a command-line tool for batch processing or as a JavaScript library in your app. -- **Custom SVGs:** Pass raw SVG strings to compose custom icons -- **Migation:** A function is provided to simplify the usage of Pinehead's `changelog.json`. +- **Custom Icon SVGs:** Pass raw SVG strings as the icon name to use custom icons. +- **Custom Shapes:** Use custom SVG strings or PNG data URIs as background shapes. +- **Migration:** A function is provided to simplify the usage of Pinehead's `changelog.json`. ## Installation @@ -41,18 +42,18 @@ npm install @waysidemapping/pinhead-js These options are common across both the CLI and API. -| Option | Description | Default | -| :------------- | :---------------------------------------------------------------- | :---------------------------------------------------- | -| `cornerRadius` | Corner radius (applies to `square` only) | `4` | -| `fill` | Sets the fill color of the icon | `black` or `white` (auto-calculated from `shapeFill`) | -| `padding` | Internal padding between icon and shape edge | Varies by shape | -| `scale` | Scale factor for the output SVG dimensions | `1` | -| `shape` | Background shape: `square`, `circle`, `map_pin`, or `marker` | `none` | -| `shapeFill` | Fill color of the background shape | `black` | -| `stroke` | Color of the stroke (applies to shape if present, otherwise icon) | Auto-calculated (contrasting or darkened/lightened) | -| `strokeWidth` | Width of the stroke | `1` for `marker`, else `0` | -| `flip` | Flip the icon: `horizontal` or `vertical` | `none` | -| `rotate` | Rotate the icon | `none` | +| Option | Description | Default | +| :------------- | :--------------------------------------------------------------------------------------------- | :---------------------------------------------------- | +| `cornerRadius` | Corner radius (applies to `square` only) | `4` | +| `fill` | Sets the fill color of the icon | `black` or `white` (auto-calculated from `shapeFill`) | +| `padding` | Internal padding between icon and shape edge | Varies by shape | +| `scale` | Scale factor for the output SVG dimensions | `1` | +| `shape` | Background shape: `square`, `circle`, `map_pin`, `marker`, a raw SVG string, or a PNG data URI | `none` | +| `shapeFill` | Fill color of the background shape | `black` | +| `stroke` | Color of the stroke (applies to shape if present, otherwise icon) | Auto-calculated (contrasting or darkened/lightened) | +| `strokeWidth` | Width of the stroke | `1` for `marker`, else `0` | +| `flip` | Flip the icon: `horizontal` or `vertical` | `none` | +| `rotate` | Rotate the icon | `none` | --- @@ -89,6 +90,44 @@ const marker = getIcon("jeep", { | ![](./examples/ice_cream-circle-pink.svg) | `getIcon("ice_cream_on_cone", { shape: "circle", shapeFill: "pink" })` | | ![](./examples/rocket-map_pin-purple.svg) | `getIcon("rocketship", { shape: "map_pin", shapeFill: "purple" })` | +#### Custom Icon SVGs + +You can pass a raw SVG string as the icon name to use a custom icon. + +```javascript +import { getIcon } from "@waysidemapping/pinhead-js"; + +const customIcon = getIcon( + '', + { + shape: "circle", + shapeFill: "white", + }, +); +``` + +#### Custom Shapes + +You can provide your own background shapes as SVG strings or PNG data URIs. Use the `padding` option as an array `[x, y]` to precisely position the 15x15 icon within your custom shape. + +```javascript +import { getIcon } from "@waysidemapping/pinhead-js"; + +// Custom SVG shape +const customSvg = getIcon("bicycle", { + shape: + '', + padding: [5, 5], +}); + +// Custom PNG shape (base64 data URI) +const customPng = getIcon("bus", { + shape: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAAjCAYAAABhCKGo...", + padding: [5, 10], +}); +``` + #### Migrate an icon name ```javascript @@ -144,9 +183,9 @@ npx pinhead build-icons --config my-icons.json --outdir ./assets/icons --- -## Custom SVG icon requirements +## Custom Icon SVG requirements -To work with Pinhead JS, custom SVG strings must follow these constraints: +To work with Pinhead JS as a custom icon (passed as the `name` argument), custom SVG strings must follow these constraints: - Use only `` elements. - Path elements should only contain the `d` attribute. diff --git a/js/examples/map-pointer-cargobike.svg b/js/examples/map-pointer-cargobike.svg new file mode 100644 index 00000000..a30b5b6a --- /dev/null +++ b/js/examples/map-pointer-cargobike.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/js/examples/png-map-pointer-cargobike.svg b/js/examples/png-map-pointer-cargobike.svg new file mode 100644 index 00000000..ab890871 --- /dev/null +++ b/js/examples/png-map-pointer-cargobike.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/icon.js b/js/icon.js index bf552263..73672abd 100644 --- a/js/icon.js +++ b/js/icon.js @@ -1,5 +1,6 @@ import index from "@waysidemapping/pinhead/dist/icons/index.complete.json" with { type: "json" }; import tinycolor from "tinycolor2"; +import { imageSize } from "image-size"; import { getSvgPathStrings, minify } from "./util.js"; // Hard coding for 15x15 requirement for Pinhead icons @@ -83,6 +84,19 @@ export function getIcon(name, properties = {}) { height += 5; height = Math.ceil(height); break; + case shape.startsWith("data:image/png;base64,"): + case shape.includes("`; break; + case shape.includes("/, ""); + break; + case shape.startsWith("data:image/png;base64,"): + const buffer = Uint8Array.fromBase64(shape.split(",", 2)[1]); + const { height, width } = imageSize(buffer); + svg = minify` + + `; + break; default: // Nothing to do when not drawing a shape break; diff --git a/js/package.json b/js/package.json index c0be6134..d457b2f8 100644 --- a/js/package.json +++ b/js/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "@waysidemapping/pinhead": "^15.16.0", + "image-size": "^2.0.2", "tinycolor2": "^1.6.0" }, "devDependencies": { diff --git a/js/test/examples.js b/js/test/examples.js index dde5d277..d93d2e6f 100644 --- a/js/test/examples.js +++ b/js/test/examples.js @@ -118,4 +118,24 @@ export const examples = [ rotate: 180, }, }, + { + name: "map-pointer-cargobike", + icon: "cargobike", + properties: { + fill: "#58a2f0", + shape: ` + + `, + padding: [5, 5], + }, + }, + { + name: "png-map-pointer-cargobike", + icon: "cargobike", + properties: { + fill: "#58a2f0", + shape: `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAAjCAYAAABhCKGoAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAABQ9JREFUSImllm2IlFUUx3/3Ps/M7Mzsq6ZspZugVpZaIkFISil9UCMQWU3drcggiopCRIPQNfpQEBG9YC+IlbLVKqVgmiauikRoZK5o7yS66Zrp7s7O2z7Pc+/pw+ys47qzO+Z5OB+ee8/5/+855557rxIRismqXZkxxroLqqNqdlVETQu5Ui1CJZDIeKozFciPibS0aut/+frDsbPFcNRgJCu/9ibXxJzX7hmr5t47VuuKSNF10NEjHDsn5nC73dnda1e/MTd88iojEenXpibRK3b4a7adCALfyDVJ2hPZfiLwV+701zQ1iS7E7Y9k0RaciRX+x/VTnYbJtbp/ET1Z4ehpQ1u7oSNhSPRatBZqYpppdS4zx4eoiV22P3He0tJmWsKR0LK19xMAuPnJW+L+e/MnOQ1jqhRdGcEKfPd7wLF2wwMTLUunKyqiDq4TIjCW7rTlyBmfZ1syzJwQpn5aFEfDzZWKhZOdRVvb/IsQeqa/Jit2ZOdMrXW/nTFOKwAvgNaTAWOqDPOmuoRDoaI18byA9YdStP0Dz88qJxZWfRGJ7D8VzH1rfmS3VqDCjl4/cZRWXRnoTMOBk4ZxVVnmTXHQShEEQVHVGp6+L8rdow1vtia5lIauDNxUqdToqH4HQL24LTNz3A3OwUmjcnntuCRY32Pe7SnKy8uLb6sBkkwmeXW/Q2U8wqwJZQC0J4S2Dma4Vqkl5WHozArWwoUuYcGdPYDG9/2SSQCemt7LczuFW2vDRFxFxAXBLHWBKZ6BS2kh8BS1FT5lrsUYIQiCkgmMMdRE4a4bFcfafSaMztXRUXqqC0zsTFsAIoHD+LoskOsfz/NQSg1LkO8HgJl1hk3HfUZW5DauNXKba0VqOjM5oFoXRkTNoM6lSl2VIesLnemcn0GqXGNVKuVJGECFwFH2mkAHSiwEYoWU17+4pEbsn/kVGwueca6LpCurCDkaQSCnf2gr6mj+N2uF9kT4ukhOXHCJhjUIiIBY9ZMWJXukb6Dbt5zqLLsukh/aQ8QjmjymFfuNDmcye0QkKwg9gSHpOZztLn6MDCU//+uS9DXhsEJyXzqdie3Vby8bkRDkqxwrnE75HG6vxjN6eNQCSfuKrcdjRCOqPwoRtnzaSEoDWOu8n5/oCSxn0pZdv9aQ8Usj6vE06w+X4yuFci6TGK0+goKb8cktqQPArLzjyLDLmDKHO2qTTBqVYbCeFIHDf5dx8K8IKQRbYKOg9cP6+GwouE8wvCKavX0GXOwNSPqWbj/OsXNxqqM+I6I+8bAl0avoSLqcTbikfCHVt2Ep6FujZF0/YWFHL//iymjy4ihF3NWEVA4oEPCskBXJww+UfRsWxefkf9zCGSuyVilaB3oEInT7JZ8EorSsLRy4orIbHynfL8IXBbvjf6hs3lBfcagoCQCOekFEukQKDobSNREEzqqBkFeRbKyPd4hIk3Dlc6k05aVNDbFzw5IAZEKV74pw5FrShPB9xi3/YDC8QUla6jGB4lFB0sJQXz5NKo3DYy31mMHwirZ085KKX0RYPUyR+9Ss+GRxxW/FsAZ9C/dPglq6ObEbeLCoEexsbqh8SBi8YYaMBPquBMdpFOy5Ignr8ANn+VAEw5IANC+JnxdYKoIZkC6DsKzl8XjHcBglHbOfNVTvR9S6wjGBl5sbq/aV4j9kTa4wBLV4U/cWBQtBbf+8sXLBcGnKS8k3k4C42jwhsC3te42lEgD8BwbScHSrKCg+AAAAAElFTkSuQmCC`, + padding: [5, 5], + }, + }, ]; diff --git a/js/test/fixtures/map-pointer-cargobike.png b/js/test/fixtures/map-pointer-cargobike.png new file mode 100644 index 0000000000000000000000000000000000000000..d9275f08aa2d7904441ab186f6dbf49a6edd74db GIT binary patch literal 8053 zcmV-*ABy0KP) z34B!5y~qFe&Yqd9Y>_nqK@gBocUvWB)mpWXYAxVGuxhP7ORRQzU7n$@@ALZFQiHE` zY5hnL5ELy@>r$UB39Z%@5<#s98eBjT$i7Wx=Dgqk+?mXhnasVjBr~+%+%xBYXS?^D z-~Ipp=bRgYLK)6?pYC7a^NA8gQA$XN0zp(p8U-k2DA8y{X+y!L-j(Xl}3oztx6-eKiOX{6tNuUVw3_D33u|;Q3_2%nSjn`0a6VM#UkxH zDo_h4R0ORkb&62d{pwOnJq3H&KTpniWN@b>*1X442oWy$62qj41K=Jj;a;RoE>+LD(TW1py4mCh-&h~-f(uQD#c(?G8xV_&+XB~2?fGbr&E};2y)vW-OEwc zpcJ6U&i~4ZCg~B`1`G4rhd%miGX@9!ctWEUrE2vh)^(^;ih`0d{-viTOh{`MqLJu?nwHWtcja;N0@uyIxuQ zX+F=CAMjq%iC`5=GSI=2%xGn_H3+ z#2=!z!7M4fJno146{*1+N||Kx5f%mNgdusLz3D_3;8 z1%24!qa&R@I@~Gf?N7Xfivi19q}BKdt!E!+rZFys#^$P2GRj0QTf$8p684ZMkXv32 z=9X4fp@oTxlF(VgGb+*OwJ;?-jQ!e4CRnM11=xGYOM5Uc*z>6uVxX(h`vR|_lC=*8 z&ck2AZEUEyXwR37Qfc;hGtEJnJDvqfZAg6`f;{qu7nTM@SpiXM$smRWi4^1H2`YVc zjtN$3*!{7WcA>n|j0GqmH`%$^{55`L&7I9f>u{~W#`7$kw$CNHeeMhkol$C`GfS=b zL~3~ZkdGeQ9n33@tq0!l;yw?(@UDlt(HQg_4ub+i z)?Dy_4Yl?hKXB*g*}PO>_euLmw?ap|RoUU8oiA;?K46E`V_YhoH^WMcPO_!MYYujx zgI@DcFRmwvs#dK2vbheHE_4e5&nWZyf(g6~LM+0tHW_;R1--P-E5+ce3Vp==EDJwy z*Yk6E=G*cXO33A($ zeLHckWiV+RuFfT8X0n;FlK^eqf}Y*gPaj}nh3}Ldg3VtGnlsK#pD*>$q)}GN$;mM+ zKs;EW-rin%_h2_Y`L;sa_nV{;TG2$|a$>QGZYg)dBNIP9uOL7D4=vH7qZ?OWYMl>r zNbtU)x%EkR4Z3P2OewX!q{%@E6szKw?Zdt6Vppz%|(rHuR8HEnA+wFKZ z{je|xj&#%d*M0Qr$0~KgTUiVaV=NBe47>RpC!Jnm#WRWV(taPkh{B+=*Fcmim@&b7 zVgt`ujIY`XQ*y~H6K)d5H523&KV7m@m(^QRNv4Ka%OPh137`Am!5tpzxRki7AK-RU#Owmd!`rFc?nx zM4rQB=?F$0kOM!c@i4UrUNOCk#*NM=hr@wqGl!m@9y)lqo&IOLiFO|fPyCaLRQmCS zx#YAO7v=pHL4VpakWW_q`chjpMFFo3rV67f@`{}>B^S>$(QyXXr&x?_Z+hvd6axjV z;`;28F64`TDk>@>vl&WfARdp0jvhTqo8M6Ak=@}*e^Q}|esDozqCCIXOZy=VV>4Sz zqm8buXpoPo=wF4j1L}vK%_^1Y;vZw^v})629s7Jz1e`N!LFFvQm_9s1GE|N9Xv!!& z`f6!ufkpHe7DalBCJ2`wAaqSEG~-qMyoLGLTUf=GSgfTrA-N>1x2qoStHm*v!=%v= z;tM7EJI@2{6vk)kFm{Z(MXH7vO0XCbBoX*6@@*_!CR~Pm5X&2R9FPZ4i?{T8n zL@98n^t~_UlEXlf??)f;-Nt^@P$0}p!US3QcrOm2!Hb6z^YtHO-vJ+e)}{%8jf!$S zblIF%SQ?x=cr7X$;k7WHqcB~V&Hwm$Gwo~Pd5YH7&rjpkVq+Iu6Z-4JHvTlVuK9|s z5Vd4LkSff|g-_lABnxynZ@L+lK02^IwdW(89IF8=s*frlMy}28mY|@Z0FT*AYilbW zHQt99KZGbO4vWI&mhxO0pKr#i`snBr^g^@NKZ;5f@RB;1K|#L#cuy^MmlNQ7;*`6R&RT_)P}20|BF%z{iQGSN5A zGSr=V zX#UuFKv3-tpS*DuN)Dpm6>}B({1p8cPBL&F2^EC7;c<(+_rZd2S&sFtl2JG@o$STb z;G;m;T4a}DOA5l_%I6PPc6i;XlT5Jm@zFty*WtP>2sZlCj8@F*!+VbfNp$W}_0(W7 z0nVL_r1=_OM~TWHZd{o#4<5x(7w z50YqrAmy^>Z>bCB4^HM*9T7{w(~cZgAq4**ci25xPUo+Q4$ni_Q=}W1NHijI$v4l=Bb!-YTR-U#vVhjR7TXJzs;#(RuK0H13EUwgI$meE za`)h>?S>%uE|%ov37vG}I6N5xlgX5_)!p0wq{3r@4Zh)IAI&SqiKGc$XyjYJv3T+w zmF|B%ID=m}!$C8Pt#~Cq?HK+BR)9@Hlqv{UmAG`*Vjq(^Mcy;lLvOo+lf-}xHeW$s zKj|=8&Ab;IUdu`a>zh=1`QzY)bNU1yT|Eyjn1@)@RK0UJLh$Cd%@&^6{)b-h&|g8AXUy%D-*xts#Y0;&~)w z1;gI>f+*bfK0df6$jK8ru$mh?T!q4dM9U@I$s2c>!$MIg-09bS&O_6S_=rT`f8H|a zz0-?)G$B{eggg|~6HzAvcP8U3y z?4qC{O4QDZ#EgzmJrD#o&KdVQj#44kuJpp563M^+#WtveBVkeg`&=!#KD66HuN;W) zZO0d~NGK3zf=jFQqQ^4=ym?5WpUBY`K8H@3b)1c6Pq5*Y_4d%$Il<}*o*kf9o##FC@W6tqq$@GQquTgy<^Dt8-YcV;=qe!9Yv&g-2SYVM_3d) zMy%(~(wEpfG1B7SRYOafm7BN9SqZTqE}j2|VvMjz&tO4Hi+bqv$sO2>4KJYW{>&uH z-WJnD?IEDz@Pb1L--Z8bK1)q{L-q#i!EMKj!C$BV}= z{%V$^KNf_`g>&=?!vFNb?LW+bAQpsUn@Q`ap|HW#MyY0q0YR`odmOLqcEO6yu0tkz ze2;~W_CoO?C_*?|G^0Pv10HCyLd0N)&l%&PYv%=rp#0ry-$*QM>E+j7j6Oq;#+KbC zdU$tmK5}ZQ1EW@SY#jPl%Eq{5Y8W76h^uE51Jq<+JwH2ivw?* zV1*#Caa<~&b`%Seh%XV;yVSB1-C@QPdt#oBRhP!!q#{s_uJ3F%Bca~!pLt`eQ@YIz1U8^NI6hb)*nSdgFn%^sFBLc>rjB0HXQ%8$R$OCNVAwEE@nUpF0& zuyxz%J%&eGEXYp>1i{Fy*rqFo1V9TK*hA_G{feRsCwmCSbSwySPJ|$lai2Zo=99I# zLp*-_x-HC80$%c;5I8inr8{h~Cv!2%Yagq$`bD0LXgOrsI2*YP2m%`5Aw9MD>~ier zf(4nvg22Xc>6D3WR8kal?a0o#>Ezx)VJa3JzV3eA4of58Ssc>11W0GtN+eVT_`_Rf zdK5{WrvofVsbN9H8s(1(Q8;QhN7};Dr?l3v<)wdc!UVW@D3uo_*K9z=-~tInjaXg#L>} zRn!XQCfuMBKF?0<>t;?a!U_(`l8kbq5mQ_6tVX&7iOfx@4qKt;8tUb-U{Tn6L;3l3 zE4}quaHc+M68gyiiejEG<@?rS-T1hErsr5OtH)|DKIO1@=%Sg2@vKJZl^hr;8R16= zJ%Oc_(0I;QZRAo)s=`)isEQPxg*s;v7Mj5J5BWD1gBMGrm)`uOodW;z%5GI@@)!>7 z`nl(_9K;`R<73_R6l0_%-w;6wdLh?rl7g_8Tyo+e+=s^whzuR6Na_J1mr_ziAXG7- z-oau7ye2@X2Zwse;Vy-~_naMKuy+VK|0pgDxmx1W071%Sf8Y3r?i%##N|?GBV>Mwe z5*%rrF{OpZ=V>P%d4=)f;Y|^ZipAY%EH6F=2n>4zBpa5Z5!vaXAvf~)OyoHPLh1`2 znCOAmdC1Fx;4W%`neuXiZHWh5W6j-P&8d=td~0jhV&PM^!qf#XpUiL~vBTyvDZiUe znbZzTBXZsld8J1q(z9ZX&=L(x(FhGy0TLBt?G7tM;SrWLuG_hn>o2QQVI7&U^~oS`qIqLU};32G{c^90VZ3K;fur6bC_d@I|lLx-w>TNHoX1u zu&a*3nhK?Lpj9_M+SLf1=E2m(WaN=Ttl6_5T)t3p1UIXYdHuv%T;xO_xFUIe5xT?@ zfXDiQ#l_*K0ay9s5Ig{ja*jX0Y{zQ>F_=J7Z#rnAdtR~0JB92WId+9c=MQdsY(&$& zmpRK|;1c)q%}0?(NPix2VNL6Jr&Z<LnXKp^-6&!e1gR!4Ajpkdddh_&w!zfRY=kWf@(GJ#Qhjvc37-v&wgPbY zs>2Pc(3}zt&KZj%ZcvRBJjDJjMtiYDVKHvT=n+9e{v!DIo^|@Y1Ix7N(c)1-4~qrf zHh^j}&%e9E+6Xi3d3Z@@E1t@S8HKR7N_f?GoSoNhG%uMsrc*AZM1x~L-@@e~Wk4Jj zfrpNK`<7illVDNUyZM3&2*X00I^HudL}s2rczYTb9D4>Ar$eP&XLvpw8qs#&<<0^a zlCWMz9;wB5E{CZL9z>j!=O6)_MKPOvbis_lUUIC$qW$zQPU=F<9>R<+G{Z~KF*-qf zS$FT{&PwtV_Q@L_?OKd#D~fKWz;BDJNOaHu8=WwOmmF)~$5->VMV zd&CSeG(l`Oh3v>5MnJe)bKm8zDwqQd66B_>on;=6vJ<9mIG4B)a!-hqa4Q4~SWTqB8G!)z89UAY=z4uGy4%=KHknhEIvMK=W&m8S7> ziqHh%vT)jADsTixB*y{=F-rW+OD-t{dj~eMITSMK_$Bo)1U3Ec3TGLtFrt3T4O_aa zaHu*yNJRxzi6HGovmZRo(rvi&}cLdmem~PQiG_iezpSRTV4lz06fh z5kwV)*S5Mm{5!wK;Lvpf?k3v(_e!u)o(=hAYD?CO(%Y|K0Wk+2g5@GK1se&p*9`AS z1gAMiu3fs#}!_N1aq4IQ9=H*C_I~Lp z*&+%r@vmpk=!6(JWo)vbL*X7ncHIz&hTmV|DyJ9>`K{MK(piZ^uZ5WqEXtG|cnWN- zL72^2ymS^kCDTV=`&-PkWw#Ue_i^4hcZO>plDIHF!W{r}j};K4mSQlZAk1}-bT*U0 zA#f<+qOc@f!|ld*(DV$Qr~IwiPTNuVJh7qX^A_ZV5Q&K~(9Qerzao5Q9?8HvV{zS< z&MKVCoiGz77xxBAF-PG<&kgf$$SZCsOksw2%clrUf8OVi-ofW53qN3Cq@O(_Zf^|@ zK0c&(FnG_<@DRA8pS?uMrzEf<0rJGrj+)!Z4aFww>7%=7c5&B;$ z$V&z_3Tj@X>c@E{D+a!;&1G?wL>uacqU$i%J={@^am5`llV&1ThsWSPN}$2A;K7U= zTqQ+4G-FIRxvYW3?DW!x`!aw3z)rhAMM%XtVDs1bLEW!H43t#B|K$1pujf`%9CQgH zy~LfomjvM3zmqYmwOFIA%8N^2<`BM1MOzochpKBwV4s3LN5gT$Qt1ZMjtFU#7HG*oi6WSbPK{>(w;&u z3BbeoF%V@Giiw5cAM^*dh+1GZ*(d)%fz#q6r%k)xQiK74{2MFS`fy1f=|c9!PJ5q< zVxq%crVs)6TewE?*NhJy(TitT4F6LpM89`1=nnl{3opS(?w~Zl!tk#Z6i%k3| zyu&rBh&tkVE_mQ)wHn|78}{)W*Ie+>kB44}kscueh*b~h^bQ8ypr3SkNhJK6kp&PZ z2aA+8zQ=;+(08cuBWr)b12#P1d94bm@gr-0!2>qx_h|z3pw@gWortzP1KzV*I!g8#H|GW#D`;wmA71U_zv zYd3XP3Pr4id5ojKdj`Ez(256^x@yTFgT6;ED>k(^BXmuH`7a_7V*lEuc_owp2@CR# zhuatXgt8UpzlZ=UEer2onp;l^kgy<3<))5$e8U2mQAz5{vlT%NYnSGhQ-UPbXO)k1 zhW%ev10?mWnT64&*o3+C-ldLaN)V&R8${)%_F8<+ayphsyjRxLF3GJzoiK?X+D|2h z!PKjF7%R_VJbKCO)TTT$8}7hD7V%wan-;|ij)Y#AapfjHFyfl zj3;G{4$@S+G`9?O(xmL%Fybl~MB8Bg(}7N02tn#81>n0<2fU;fUa}nKKMhz_TUWa@ zuaZ(kY6am0-y=g;K6XbIEJ~&uh>?;+9!kp3y7Hm6Djz9#!W?!|-k3N*^A+%pT1p{l zX~Q)Swbv8T0+eH!h6k7Bl~amIiy%CM_j!CfVIH%nilwwRi5Yn;(jo|R&BnHB`1Bnx zk3ED~_28BH)szO(CI}~ZZ%=z84lxD0Ns+dbOpGRksxs7RA+2rYxrx_j?5{(Fk=C%vA_o6eV;G%fVBGNmUl6YI$TRhOJMoLg>O(mZhOfS+gO{p(?B-T(z;Sh79}# z)DcC{sts4?SJN;u?1C^?ZfM07r~mIe4mVn-h^7r!6_mjkR<-KvH)hrpa{{h;p&2N z8X-nhkjhOxC2ntDBTge9yJ;tWV-c#H`6;~vXq5Qpm}ah8-@e#KVk^w_V4#Jns#I)P zmRARJ#4vEW>0{Y?422E+5$80q=Ao>ct2Rg%u2OH(ovbL)>U& zgdof^426ZyQ;$>1Cxax{2+>BWx%{CK7~)1FxwfR3r5jqRNDS|9tSG8j@z9lpwUi-b zlpxHq4J~y-D2reY17h8#s|zbBgUC2RDz~;2^mP!zN64yhTFz25*<4g!TT$4Gos1zZ zO-+_%8(YeJUS((cPENEbCPLmQY{X7Rk-j!3$h$~Ie8qLgp$#Ma6aknpn9CJ1%|kFFf*F05QJH}zNH59la(+H12NV>j4GHJ zPu2*+z*8D=%JX0vKr~=PRZdw#Rte%y^36EyeA0#3k0f4}$w@qttP+G-CX>9L|8PdM zsTn%?8;YzGghA-4#Hq(g@|ipN8;YzHgc)kmm`zDMlB^Ykfu}U03G-lvkX=bUlB^bF zaFWlyBpyjt3&I4Ge0C=BNU~lK2BE8x076wQWjX&3Z~c&R$vEI>00000NkvXXu0mjf D3GX;) literal 0 HcmV?d00001 diff --git a/js/test/fixtures/png-map-pointer-cargobike.png b/js/test/fixtures/png-map-pointer-cargobike.png new file mode 100644 index 0000000000000000000000000000000000000000..b63c4e588ed3d3544d5305f01f900f514b7be907 GIT binary patch literal 18223 zcmV)bK&iipP)x>J5#?zBDikIG9~ST5_Tz4xK2DqvJ^WW9Tr79Fiv)nv^J8k_A+TFG-;xi^P!zbqTo4S9_lKeF3ym!W*5s^pk+nuCbk5|cGcv{o#!DTkP| z4$tB=HA&)g+fIyY#Hpz#si|^rHBQ|M*UNEgmngK@N!>ycyIJRg**J+8YOBrF;}00~yo)Gf!cSxTH+0F1f7rE_7JF2*Ka4eISiX>_=yI>(xZGN!W11^*qumNit;WJ~C+ZvO zv=S4KlmO^ECLRApdT<+oVhp#Jo*(~qGijtE+(zmSnw;rQS;%riNo&D@T>lK`k{oLilGIs;g0e9k?ziK_b+98X#HrPc zO{*Q7=2|ChG}@+K?U?Fn)2-CnW~th73(Zb26DR3RBTnWT&DQGD!r^f9(qa z6d5;6iakjaWGE)JQESbPOHog0yxFo|L9ZqM2GRVzZ$6_^H6L3&lW*M;N8?l+e=1tS7DdU1^0H0lJy5&xoTLs89 zfNNAsraeZL<~!JyTa=nx9@1QBgk*N&iTLYDRFtnv)xAc|Lt(C}*7IPuV?Z`*L zcHG!)dE8lS#vqKZ6a_}fnlfPY0R;EFp1Kz#aKOOe7aNSV2L^RzY7K$p8m-vWxWCel zy5a6Cq5xqb#?iq=5 z2njA3;E=E*rqv-&T#QOHF%Zn~L9E1HR$ zwfW}h%JR$O7FchdV8zW7V1&DKroR=mw&zl}r;rc#_7}rFg9W$0zi9Si8+MWp4;4+{ zaLGmk1sh_#BY=SYE<}|<#$v?1vEYM_B9Y%s0s~K26@QKv^XJNOm*t+NfRy4hV`EHQ zZJA`H={l<|R8$K?-%2l5TlNfw|4gmzrq^P7)~3mHsn(o*FE_aQ@-28^H_U@^Jeu=g zgrU1kZlqBwhyA%NIUDY>Ik&$Qg(SDHFYoqYr1zk{wjm({!+k+CTr#G=fbW`PB?ka% zknye)7krEuq2!t26I$BmS<*QlpNo|9fhfmuGpQX>4`@FFIMD>Sl;{uk&YzR{a@hf* zQa-+IB*Zijp<3G-q`?RjjQ%Q8GF$7|=~~mAZ6x*#YHh0Bc4wkobZ)I_7CZB1ZK`OH zHIv*ZIKAetOD}%G<=Wd!x!7zDm5bp>r5tSUFW5che7e6cZ}*lWvx|HOMthVrJkS^9 z23bvD>`e)|kXpJH`9U)saY4PhkUmFOlkiQ+#SksMnYFC)RGxF6!!#x8kn`k;cKBRk zFw*E5ugMV6k36m%w#?vkpgU#1$hnTVFJP`lsu@oVP>excnN?OX)kfOpd>KbzuF-K9 zQD+y(&mt*EOE6tsOJ{JO7urc{dDRTm0OW>L*fqLvCB66$TwhRYZet|7QAv9SN^Z}V zKC^eYsB^QkFK4#`#1JlQh4W&$Xal)j8mzo0va6DqDlj_Cz|5dJ5;>L@@5SKZpu}Ma z&pkN2pz?%5NZI=Wi0l%9foHCg)FNRRx=mkTekrenJ4mjCjWhI@XOz1JD5_f%!P^)5 zpnla`-c4=*Qf;lJwMx2x`ns^xFy|H z&wtxRA6}|$Ny5TDf`1Rg=-Ixz)?{Qum~U#(h}r}Itd63&@+g7 zdac1fpE71LP(~}J3yeCEBUZ(RLCOs!0SrlCWuRn$pw~Sh0)~$_%+yMv8QM|10t18P zC4zMhK)`$w&IXAa)2plKnbcQatfJgKjIe6F@MF#soJvZSn0k~J2S)uyQ~ zH_XC9Bb}LFv*%VC?o6|jo=TiO$?A6s=Vf91V?+$DL)|8?q;cHZS6F9^&Y9f4k|{^I z_I7OZLDm!>Dn{;6R^5At%WmgLDcCyP$LgDfUZogVxv@0PY}3G~2+}~5G#H;ah?$9cyP9&*)+oj2St)xBE ztW|1*qu7+^S*K``dKKz6aV5R@U6)%r4U_YYfx%ofQpp9oa9z-!3fbxm*%01(GN4ee?^SSiT z2?$b#bL!{RLve*2QQ{zG%8rP+8I!r@0UuJva7l-xNB}8$&;Uwc2`GW2aw4^rf^%XaTz!5oD72P;SiLBdfys(!>@EAE`4h|<#ng7Ny!GgePxpwx|K zyHru8l309kE|ny3WCtQ552~XOvC%;#NMlwHb@b}$5miFg=Srd;Al{;6AMRyIeUMbq z4JHPYfi^M3^o720t#-tGsWB#*8SLgm#F?eukDE903}NH3ut6E(X%0|21fB;h&=4D>F85FiRK zi6Ml2i^k9vvlP8gy)w2v6{s1`$JeVFu9q%v5hyqjFk%=PZ36=B`w2lGn6bF8>`>8< z1$IXkV2jRE!eL^%K)_umS+k`am=Vs0NRk$LYHCjPW|U0`0WGJbl~{JcOp3Z~)jMv5 zdM_^2%~{%*L~^F^y50lD(}gHM{kMO_EHe2yl{EfsSM0PKqph}m$cEu#rN|ya z8pcNY%x(<%_MxI31`wQwz!IZkHWP}avwS=;e&1>OnTa;jNX!r*H3ly*bS_aRJi&7q zP9446i>Isz7bEz9Xmyz1-0V1`h$skp!2_c(=u4f{!`N5=^ z1CZHTb}p%rmnC&IP(V@y4ALP`2FaOtOb$SyAHB7Ul87c~i*W*~qz!3tNJ$;{cyS&h zzfd#h2;onnqE8SzdpiiD(`zf!^MB{#dvTyWsU~`yop_QFKX%yI+C>u`+*7R#mJ2(h zAUqUU_f#QBAMY;&`Km*=xCAuxkRJRmg0$0v^d6GgGg`&zt8j1tWVyF-S#pY;HQYwbNfL z_ZNsknY|dg$Ma$EjB-}ts46=|D5OY=`iQTKiSP-M%2Cpp1||kDQ@VBnpWTBZ5X2;v zjgSa=G>U17@lqE`k_H)9)h$m)AZXx9@X3Q9Ks z>YvnDt;yal=K>9r;NO!p=IDfg*@EiY%08xYU31$L#UORY8ea3#)@XYp-vR9AYLf+I z?53F%$5Cl-)SJoi*^WIsJ=b0)_SM*F&Ue1}nGeGu)zGES|3g<;SX${T6$=9cQ93%* z7wsnm@&t@|rWm@z%!-`@MN`J82l(y|Rg&2ZYS6%Yq*D!Ev!u#YSZ32yYDzkmr$*{c zvtH65dohTvramtba!pmI7%oQe@rlcU?x`|KgeHl= z5f}^g6cv`5d2(hqATbV@t0~0z%_zJ3I|l=^om_opqWb<(B_&97ROhM}&{12?5^hNV zdkyVyp%vS=>#;k6R34jcBvbXeon0~Qm2!P??aQA!fJLPalBb{lBUi4q8$-D?+E$Fx z-2-`Z05x?Owe$owxGijBv z-@%T*2L!vKK1-cS#q}gu#87eLk;XLe;g)J6VG(KO*lRgO?CaQaJ$`$!WhWZ3osQ#p zwy#uL{N?USzFP%u?muyZQMJ7*h};-_dZuWJCeST3?`iW~u{U*CO*>aZo)6B?vi zQ%BnHuvY81y(`W1G#=Nf)n+n*-FnXjb|T8<&;4&dY*%=dH@ft5|G;f+yUxR*O`a%3 z;bWDYI|v|qhZRK3qc#gcpBk32^I!FV!G1DBGMv(zO+-8cK_V+PMSOvW5@bn~K#*H0 zH6;V7`eZjG`tn{f=@6T;+!KT2b6d#Q>JOJPh}!wcwrCPzj8Ru9#@?A3B*se%plHn^ zCP#=u5nyEGWlLmJj-PFB7wYv@L4i1VGMWt0wTsjyV?}D-=ub zj(^O~)0wPa&O_ZQ4(N)Ai?*S_$#n6WH9dAoL#~A7$}g6j)cuXUG=j$(nq*hYDyhe z9ejL#FUd2I#E0=QsZhJCjRbWSdtl~RKwhXCGgG%_rA5CP;UHt#)*s@1G06bLt6xA7 zpL&K~^@&HUy%c~%9h#)tc2(9yQ%K`$xZ;1D#^&|KR&#oGslQ=A_tk~HQKeiQh}`Zd z3ZH>bKM+Oc*<#36Y%wUYDrYl1vjJ*Eqz)qhz9A|!F<{7MNLOm%C$}=kI;R2{!=(g6 zn0osmy<5aBDS)__Hn<=hsP3MVMn-phQh(Y^-zG}F2ImMCA|a`n#_Ue7THK`o&r9|% zWNg?FB%g({VVU6VVgX=ygq@`_Nc^HI)K-An^LDBMzs}Oc>#+&{4ff@Yg+?^Z)Dy%w5M2id>F<*#o}yNr5NQ19OC9C-3F)~g5Q_GXk+Z;Md`f5O?}}B zEo+Ln#)HKK!zWO%A=nIk8!m_SM)y${l_}k4q#h9w^HQHA3BV*se&StV0W!5>krd0$ zAtkX98dDh{G}$C2O4wf`uMs{{n|K9(;8)v)s5@zrxC0>PS*9GpGyT)mwtaK8k)CJm z)3iVR`;9*rMYd4MJGNhg!>E#H@wpEdLbpR(f0>&LAc$YLeh&!!q_ZRef@G*s8F8tq z%yx7E0gOSCW{y_8VHh$F5@0BaHrk|nNgMm4q^}6;+jK2_*JY&SJSyw#vL(;dRud_a z>I%_KvWc0rWKx652M{WQ#2R^M31z7Y=O=dL7vbNrwK#o!wP}w5$i+1mHtpxWzV?Yi z5anUuD8ao)srYd~c_bgY(Xw1CDvbaH!09lxLsC})MBxyrC{U*J0D+n+Mu`WboKNsP zQf!D+7vT`rATopjfEb$r!U>7q-dEJ^Qx_1&U^{@K*cVY-47@S3Ea4U!)*JY0Tk1=a z4c#lhxa-kNOGUU0z}f0GS~z_6vEHe6NEwlW)5#_a><(0B!LoRA0#0=ha$o&tV^&Z^0+ig@eIWL z*u}-R03poB+GLu#CUJ$&HCkpFo6@mA_501=glQ2*ydU>;I})-RK(?c{Di~m*8B{^1 zMB*X+q@x)ivL9_g(YMKp05V*P&1kt}M#>o=nj!Q7FzF04NT8@w0z`Bpp!~Hz#07;3 z*P_q<8DpN=dHs5Bh6IL4YbpJjK?Yvu@|I$zxb|`8P+RXUT61FFnzO5cX{K6>uqN>m z5&{S=Fj~Qnow~>vI}G6}W(Ff6RRCe{oxKt^vIsirheRWWfSM4=H`GGyo{MXNN@&V~;eGv!(L~gv2yhk!TB^HTDhzDfBnlmebS&Ku$ zC$b+Qn_3|%OP&_1cQ9ZcGUiM}&=QgsBT=?S0CtX`<_w_Bt>IzQTw;Iv8_jP5WQe<~ zp9ioL7;Ht=Y@wpQ2m>rXLLKBoPw6B{IsHU3IN^qVyivlKZvl`IBxDO~k3InL>W+W@ z$bbdJ#{5?3jf=*70f2hchmHD`|CE#4Od4|r$a=1k@WgwUGU}6fT&ETSa|Q{ib;Nv( zIZ`4?QEW?qFwxNzFc?6xaWj!oFBo-&bCHkafIY=@TXe87EdZ`&I#$GxG$xC=z^urS+=}50bSuWCf zk2K&LrJw&Jg|*k;eet=}JiX(_-@h7NtuGq4&XsdQne0C5#(P+2StL#_1!lS$5T=*I z4=8}31*6mz9xHqR7!tR=drV8HKo3ZR{gGutD2wcyth8f`W9<_A`LDPB82$uETcMmI zvXQrgg~;_&SHZieuv6kne*lDpKdpYrJVhjdp+#W18zvb-N|dvw;zd=&P#Da5>H%`A z^pzul`IB?&b$tKs)coYrbnZ5jfDjkWzp?BwN(dD{n~6#yv%a9$PD z6@VZC1TWA_m`bj%>=Xb(n^k?Nt8Cqofg<~%XX}87cn>f(rdy>qXRP^uUq=^osf0K8 zzyC0LSzN(otRmTIAER7N}FZ?50J?Bkl!q6tUc1Xk|%O?Rf*Y+D=rYMH|~mUEpoot(3-^ZjERgR zQzE1y_m|~F1_)9kl7z5d(qSFaBoUdh4I<2hA8l&?>NlHb)C_<`EC}=Yz!h1<;X=Cz zpO=pCQD^ukKoAV<2Ko^Rs9+}sSbIn^1cp}V0z>;B)JLtfPI*3_dd%w%d?YNWmrhspvFZ8a#q1#dS<2%#n%R3aQm3ClV5N{|?dxU+xt8_gLEf+g4& zGV607naf$1LqfQPn&O{60z%s-`b}~KAY#1KlMK0HRsux#@=Ab_c~}|o>LpU0n@RWn zhkstUE+AV7>DfgT zMIvP27Xacs3q-Bn=aG~S`{@}V^13+3u%n51oA;>zt(9Pl6lCmlQbJp))wg3EHl(=( zv!(I^JOM!Q_DErvp2qS-84re}Bgca>j294cbp?u4l?*vCFb@V2M#+qL^$HRQ8|l&E?dlKS?ek6zxM?==T8 z4i&>52N7QsjXbV-B;?FW;MXP!r8EE}&tgtsNJ8ZL!c!^$5Gs(p$UxDugdh#q)NEK7 zHX3RKhGT{Ze*uc5g>&w%leDz}pAOGsp*V#qFFE7k>??55kQ=mj;(#H%*h3aS62Xmv{9KcUpE|VllkqqpeqALJ_;G2wc1`6{>n^giti^Nl;Y1Ech?m5&=g;l!%!8UuQ zio|%o!T}c&%S2uBk4K7S>2G}i1xXVd>F~KOIZ-jrkPcj3P6{ObC}$uKAV`LSiCan? z0FkSktvv*Yq~c$k-N3mn4I>F#N>mETJCT>NnSN+*$NbnKbQZLk;s3?q)@5qsRzSZ| z`tt`nKJ}>A3-unm?2}ZN0FnEv$nU$@!m}%(sRD?Mu>c`P$Yh1F8Y=Jb;a#|HkPPQY z079UQ8%ELIDi)izgo(qmVv-{;0LJUwhPcl{Ff-;-S7pZU0YN%ONnS!&i%3GGs$?rR ztCEMX6&jp8oQJM`n$R-K-zh2Gz6{|=F{C`C4+zf40 zKU$>y-1j%V@&8}`wYs!UcS3Ek4`hCx_Iggot9W^GOytIj)Sr*yaC{i)MEUy#UK>F4 z10boaNdS2dqb@JY0|LJUNQr4K36aMn5+GO$q{IIqHT`wFH4p$+f>WRU+S==!P|^~I zaAgvj)0QcW>XKREO5y`JQdx>R3XsgG3y=-#5LW7coz-)XZbn`8XtUbtxt+iKZ;O|J z7TpQ8rG?-}_r<6nV}9fH;w2z9lYsfmGfliGg;2B($!y3O0GU9_^`6{btviYU(nt86 z-Egk6`eZI3F{Wm%T~&?{04soe?rUrRk`t9AQe^unKEye>DQ*TNuLh{J2Z(kC<@#!+ zu2_};`GxPStB%_V$mTW4?N(bG`}!+?a&0hrB-Qp=$_u=z2L#DjVR4AMy2!-I03mz= z7}^in30jkUd5K_Hsk6(EgoQ3horHD!m$M<_sUN-PtBgS&{B0P$`w759<) z0c5xr%K{hyqTu3helPN&1Ccx>sVvoTqx1`?o?d80gYM~b4g43r)5qC5-7dAYQB`dQ zUO@fOUerDGFV04nO_m=+qGTWSr8!B6Hmc5{t`y5!qaOmG02m4^t^eznv_nN-~ z!*kBs0Uqq6PNd^h(5Nj0}re>Ps+e@4Dd=&-jD7j7(Lo3CD$Su^dEUB_96G_)sV@hT|$@*k37L) z?Pb8CE*JtufUw`5v^67VVd+VVA0@PS66Wf+F=xo2LN&pf^sgD6x-q^mZF_f?jrm? zjwF4LiGGZn>rM+VtNyGDY>{B_E?EB*{Qn4bJVgyVgD4LmELW14_o<5m9{`apK@Vf8 zBqai55J0ph+0q3>9+v=7hxC%d5DLr7rR;$t5VY#m_U&d}ey>TONabom^?=d3=s6se zkL*nR7|yZ?oCFZ5EPd02y>SnQR`3D?HmxWAVFEY-kWLGuY|pWa{wDMMPkD3%C3M~f zu4#YjYfJmnq*P38yazD84gV`Qq1bia_ASr`YNgPh=d>qKj0%t zIdwo>84Dn~kA&c6*|TV!*IAqYv9-zT9hXjbOs-*Hd}(noGWil*-Ww<8!zOS)h|ql) z&g~;w=~rnE_%>2VBXWTyAp%5SbCh8p!tE6p`fjiGM~bK|ePJ$Qg6W$myyl6qJrLyk z%RzuB%r5Uq-km}va&gyJ=ArklQg|Zih~P(W5^f|JbybGeX>c_K%-^NZzjU44cRNAu zEDI9=`NDT|h4O(?Gsy1^a`rji_(9(MFp@DwVa2pysZqc9@RS69z1mV-tK`Ng#CTx1muIg;fT(NK^+1sN zE7Go@;%fq8kwu`s2q!=UgcgDdwaeXwF{mum7GMYvc*xrtuOD#C9d-!QCJz?5Imr}# zjYt0$Am3Oq#dG@GzW$BzZ@XxArLnW!>O2_)5mJJuW!wQ?+)8BwxN1f6g5vw+v`bYK z%3+|q3(SQ;(Yi#EQX%l@7mA8=p}qu+zC;(GwnS9%KoCRoq_6wbun}63U#x~cW+kI8 zX%SE@G`iLvForQ^fWVsWf$>Vj(0DvvgQqJP^EqqX1e`qLtb5Jb;Mhu1x&R=^A@ssa zFNMS5k8E>E@Thh1)71Dlj6Y!UwBY?#_%}eYJ|qZca^+wyZ{k&Fl0v&$y?Z0?)fX=y z6k=qhmq=|1%m;ybz0`1JJ5~YUA~r*7jthXIA9~83V4Z#06n-!`_?_7gf6fHv z2)jVcST>>@VRkdeqjN00C$J{Cy}{AgCGUA6O5a@jf z)*V34?c?b#TG>jWeqvi(QVk90MQur{Nr56axIhOoNs-qDAcRu%1&Bcmy}l+SwI$;o z@Nz(ujO@N72ZyyIxY4VUx{`F91CW_o=%s_zgwY_pASfs4piBo%U;&hRFV$c-%p5nS z;ouaHCwO)uN!`gp)HnI-KW-LzMvGng?TTGA=eoqg8>XXd>r>X2i6d{z7w}=OB zAQD3DJ)%zlf;|BYNr^yFtgA>8Ff#T6MYg2(lQ&6=qy#DTo#6F*N*WFylQMn9Z2inY z`y5hJk_xTF^$kTm)0#x;j1L{>LsEVjaWjyF07w$IHAq4u?j3G^kDEuio=9SQ(NzkI zwVKiYp1|6K*GQgTc**65%~C0C7yE5K-Ac#CoH0+s?B~!ek5kz$DrcYFfC)Y75@mFY zBAG4mppeU^cyI&?Nj78%n<7K6wTjj#S~N;ZC`LcYeLeJ@>pMZcYSm|-I518U3}#4T zvOFpoYe|BDxQM+_%xV?YB{e47qCgZ`Rw3zH{e@o~)lS-^{CTA2D3km(+B?E{C)1pr zZ&jPMc796}KqN-0E+e_VlanSgX1QpJ#UV_=KIQ`Mul)!$KShx}B)?E3sY6+7UDAaZ z+mjSF%J9Af3@VI=eW)+l6m9n^S7=2)U(k2Ca)2QZOn(icx2oqgI!E`ZS7)0kyMJxE z@{Qg-)tP~y?^LxBb}?G11rP#5DoKE7d%&wJRG03_W(W*V-Ap+{&Zw7D4gy+#8gswu z?T58TFafU;raBT7b5n(|xJ;4O(UM7C`IwD4r)ZbP#~rLN=Gb8q4PcX9THg!6Pl4)3 zaKfav_QN23`Ju=?7K#pm;0`x<<}1>*DhbG0u)<&gBPqeAz;NxI=yxSrt4L+(OA!Tf z{lOZ6qi?O~Ck3MxVZr7I@mzP%dL$<-YPY zSS7%~uG!eIY~*0l+Q$x8Akz8uQCAw7J!mTh=9@J zu}CiUCA;DiV3aWMk`|pyjVTu}k{VvW?W8KDb~-RvK#2KTX;+bmRahsVdOzfdIS)9WgiBuj$#UVTYIeBy@kb%UP) zB9F@h(an|gO!h_g#8(D&Gnyv(R>(QuO#TT6K3QT~is+^%xy&vTF7~{6_YJavaq08RL)A0?}!*^2V4htAbOGE_% z24BsCqCedvQ1n+Ibgngu_PV9EGC)*^qu;bf-n&-Lu(_ve8tampS0B<#DYF#6+Dl`kSslt*!2ea{0YDx(DF;K^oH*@1wl5kBW!cQRKD& zVf7+TY#_>bX3^ zbY_%dyJ+SBtv$~5lMMV(ifp^ux@2R8{+t6JjJ>7PFqmARcu65iQe9ed!{lK%ozE|=u72?|yViK?3hEB!O8WdibcJdQ8@#qW z=mI;+@W*KDDPx0Y>D}WL*~zRZlG83D>C&|-)O{dPzfC|c#>=J%6oDf!1Qe$}>D)7( zB$rs{GW3^#;kllvtfWN@^MC+=n5?uJjP<1FNDz|<0r!$1Adz(sgs!>tsdu~)LW6Lm zFU~2%ex8$KT)oC5e$xiYq_u^)AZb<$7Z;kp_A`&PD0~GmoU7CLx80@xzJfQ0gP^d_ z+2m=u_YodH$(wsV2Qz5ieR{s zBtdxu;{}3p)HUyrQ;v^sQGh53jM#O70RYYXOW<%WF!W4iVP5twN9DYh<)q&6+C*Vb zzMQJi`zSsmbu>-)-lW!78P<^`jnCx6p`~_xvNbt27QfP6cWkC!jaSe(>yl%KUuKE^ zLO2(h{(7Un7m0idSp*P=8;l(>W{AYPB-_9eLq!_owMZpYPPv#VY3b$M=Eyk*C4nSK zQZ7&gfX*cmdLF<5z0)HxxR-jy+pC{$?tviJwIqVirM~Luldlgpa=adC93s`hSxgpl ztHG^hZcLFMp)cQMpZ!hj?DVRcu04MIum@s%JX?2cCQG_5ebTQ>TDwfpeuA~gbFdI# z+!!75>k>L&qM|F-CD$U#dZP23>mRuekg|Xa)g-28K!7;soKg-jl#_JeqbBvq5(kac z=OnL!$dv?wS5w`4e7Z!RIreDd`TG&$*O9hTj;6(X4f0iP`gI3geUr9c4})v3J2n&V z-vHse`E^Omx+Dmn;>Hs&{Q&*h$G-#quDo+Aget{aD|a!zTVPm~n-knPLJMyNVK9+8 zcOfj;h5lA~_3w1|JFZ7JI8xRnzxr;W+P}4Ai-mp@w?}Oh>_>eO4K~m6n01NWNk__X zkBqKygY@q+^~Q)}=IWfDXV}Nkv#(<+-eguz#X&NkR7R@pnz^*!aXnInH$=~~E*YCN zkr`sYq*;`^yO-$llXT}fV^igO0Am1SmM2H;JEl*sdiDM0=(0C1ySPE>HGLI&pGViB z-kZKd3Db@pSkvNig?rOzileDXUUxxo)Y@4$u7Be`Z^hyD7RQK;W?b#8;~$B^!<4^Y`7#@dHLU>`-c;HQV2^GUgU@QLwou=*MlTFERLhnlkHB;!8Gq@3X4WEv#X zz_8!&k@!oJc@XM-_p+!J*8N#+HcT4p;oBu`PzD2@<=d63D6 z*I^ms(Pkpj;Z6X=5rR2yAq*4Dyo)xn3$8w2{qhZjNv=k>%pv`Z!R&f2XR6h{W?;4i zX|NyT_B7K+-%^oX*=-yKo^g=qKW#nOfG`FP0FgJQ&ZIlXc;D2wNVt9@4bmxABa8k0 z{cGm@YnT4B;hQBIxHTHb>pFJW*xJQY(a_fI{e>vllg8=O=+ckS>1XKoKB^yN3(0>lzTS~ZYp@C)0&k$dG19$&b)?-IHuJ7#fys1`pnE3JI#@(n_ zwVBl1EfVvCzzrwtE}rje)%$j`h4dKm{47HKB$2!^N^GOVptH6QpnNpg@vl3;`=979 z{boohy>@K|T<7KrPi7g!6r(tbL4P9%^2gKm^2M+GrD&u^Z1@q^QgUvRm2 zV16`qg@bhEG4$$(7|EDT-CkPQ!DvQMz_`KaV8D=(XCS7urhzAmAZ)}oSc98}nvD5_zjZF>! z#6tiv#%Oj^jOP(p0SGhB@bLbo3=jd6{V5DW>$tewKF#@i2*HF6(uuSaOy!G}^K62w z5FU|Q`#^MC0D-yo(_fmeq|xV@;SF?fH7@f@PSL6Y`Oh(225kdOy~7z4cb z6Nm7Ko99emyv_3J9cR+Xz=qRl5X>}8b@Anok4l|Ut13PZZO^k#bsh!)z*T zXCV6-&tVK6(O?hC4tiTA4@hb_`!K@$i?Tk^D?Xmsi8pm7*G2Moxc#QVpGizQ#rkWu zl(Wlym4W)Ltje!Ov~hdr`ER>ubk5|6bCp;&4OtZtMxLps zzY)wGNNVhUpiTWrxPc0iF_$5C)7Zzi2q}Iqu))zZ7oANyt>t`surWFHN_?Zi!|PGj zgWExl?n2MMV#2Y><%%swyRd~%q{cnR?WailnR8pnB_#z?7JTGpmjU;jGC(e=R2gVy zy{&P3fqqVLjaOw~H(~y*W>7yrcYL`1$}a+(h?OD74@{PH+v)jn7wtRH*zR)4Ale_XD&hj|)(2KQNS-X^et#+$Vq-CWsh<0N+?oAl~6Eu4=N#av!U~hPj{ATHn0rHc7&y|y~xizw#T}hhm!~H!F z;B}#%9%Fz9D6$ndShfT}62p;OcbY8e>SXmV2sr@|{ogm)x7JUS-eyJqHuoo-Nzd9a zf5A0Z7QXVAci>gse!6xKKOpI=s29(#mdoY*Ks#-3LphD1f1gAtJOlF&)3o+ZO0dCn zb@pANYY$mAjlLliY&;ovfwSX*)s`%_!vvE_+Mc7->d5fm+U@eHHq*6x_W?;>*BD#A z%VxP)ESjOngnI=D1APtzo&<IWkpgT?$Kr#tQ6Jk}?EOBQ# zHR);k^&ZbB$R}+sm|~%O@zBoxA8-{&#*JPbh~0z*CX}l zuFzlj)prY(9oqn)-grmZQMQ?F5E+FSk(e2{HhWhYf zb>xn=W2-CzFVNr#Zm}A7Z=|z_ZS&$CC;D@DNFn-i-Q?Q&75!iM zLy{7jKkRJ!jB*&U2SA3w0x2P^VgSbD0$lvl=K~Q{p^`X|vN~htdz)KpRLvsKO_PpO z<_J=996+Ym+Gb_%_Myhbnu!4Pp)FjnO`f<%H%}n8Z9|MRZX~&#s z4E0|qw9J|Z!qGEt%cUx!VK+@CLjLFr3 z{`3F;kJ>6F?>cpRc=t))>#CdjP(EiyolSPRG#JAHW#Q=b6NiUU5Zh26C34HGg@0Tb zortpI^+|82bXFco3_i3_s#{Vv|XehUbIG&J|kal`ntFN~PU(qF?uh z^u>rq$>gJ=>FcywTROJ6KX3uIB>6r#$4cJF=vUyNq(uG941gzCdP8(sd8r?r=!cnA za5)Fq`q}d9X=;zs_L*jA7b~VxFEvd2i=VL_%HDPA_4uxnLMy%Rb&U>guXIej+XUg` z$iuU+O);;1bZ>;bjB{F`r#V(~ya`X?<_!^?=Roub>TV#TVQ7ea;wU2RojC?m8+(tKLPfD4M=dSmX zt_KS0ij}x>Z^KjnWBw&xo?~C^9IsEJ1mC7MBD(HWCpR?v^2ZhBrA331WM*)3*NKMj zJv~okc;|)ckmEbZkfi7**pI;3Q6wbd8s0MFZ-4}d2nk^k=;i?76nBpyA;%5glugs~ogkg- z?+hiU3jJG4#bTkK(8_2a8{(|~pff9Zh3(nUGXBfkfTC^_T4AcA zdLVy*xaI=_9Aw0$cF05Vb!|7&8-M=p= z3$0A%md#MHFK-6ZG~I1%?nBJ%rvQb}ir>(}$eS`uEufGha&BP-P-7ygCl1-Qb1}DY zU2To9)gt*>(%bmZ81nc;;OseKG|L2;oAt8k+z-+rVnp`|vCbH$KgZb6%5_%Ct{rc8 zQSKogJO+Oc!RP(Pn4J_E1)!lI2nsUVoTI`30MfN=hpqvo$)ja1XL)oUGd+c2KN;HK z9hcbCapGocYqRyrj)&uyUO<6iTqt+X>HYy^1L<3kakOnXO=C8&*fR-?fEh#Oj2Zvy z-|Sl}*a%Aw0NLc`T1T#9$AcB#y$F}jn7~Z%?j*pTwl1E?MQ%EZYfE4L)L4u16uEzh ze&2_LwEV>Hn10t;-JV9lZkxLOctc}w{2`u_urJ1Jfs-W$fcApjxKKLf8Mkq9S!+;! ziu>>K@D!pm>5}v;h0jG%GWR<_v8BpA%HCh35!{y~DOm}(4&;p)Nlmgda=~6C$nL;eeXmQ=;X@SJL-#TADWF(z^V9*T8iQH@kZHU$ zcK?E-&cpx`OvkBRic0-!lxyEFMtn2T_xqGkTjrOHeQ~D=XYI#m6z6ZJ4nV9qX$tYhxzXYJE6-=&8leorbN>Moc3;5LzSYc$A)xxzz1LT6R!i5C)U^-;Q78*;(+J#0x$_nRi<3b$tCN z@6G(JgzAdr&CF2 zr%a)B@#T+it5MB;PYmM0AQq1KD6S5$y}HGwtWm;nH%f<)O8N-HKuSawH#a@S-pac{ zXs6Llvr%Xkmgi@x-@3+M0lnYofdNEDestjBTyA@R5h)l*+Ud?9mpjCY{xE~Uz;oB= z&s+qG-oDdrrx(-EtPHUdznwp*xzog09uRU;NdWUpuY|iFeyY#eXnUG!FJ8aW@_!-K zATd*kb8ne2INoZvXNZjvFf=UN^^m6z0Qo^aXr%4mw*DGX92`a(o=mOX&uuVsQ}|kM m8kZceEf3G!uWOC>qkji5*RVsY5zY$$0000 Date: Wed, 25 Mar 2026 22:02:13 -0600 Subject: [PATCH 29/32] reuse height&width --- js/icon.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/icon.js b/js/icon.js index 73672abd..9473649e 100644 --- a/js/icon.js +++ b/js/icon.js @@ -168,7 +168,7 @@ export function getIcon(name, properties = {}) { break; case shape.startsWith("data:image/png;base64,"): const buffer = Uint8Array.fromBase64(shape.split(",", 2)[1]); - const { height, width } = imageSize(buffer); + ({ height, width } = imageSize(buffer)); svg = minify` Date: Wed, 25 Mar 2026 22:06:50 -0600 Subject: [PATCH 30/32] fix bug with no shapes --- js/icon.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/js/icon.js b/js/icon.js index 9473649e..c253dd77 100644 --- a/js/icon.js +++ b/js/icon.js @@ -84,8 +84,8 @@ export function getIcon(name, properties = {}) { height += 5; height = Math.ceil(height); break; - case shape.startsWith("data:image/png;base64,"): - case shape.includes("`; break; - case shape.includes("/, ""); break; - case shape.startsWith("data:image/png;base64,"): + case shape?.startsWith("data:image/png;base64,"): const buffer = Uint8Array.fromBase64(shape.split(",", 2)[1]); ({ height, width } = imageSize(buffer)); svg = minify` Date: Wed, 25 Mar 2026 22:12:21 -0600 Subject: [PATCH 31/32] node25 --- .github/workflows/test-js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index d16ec01c..03f575fc 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20.x' + node-version: '25.x' - run: npm --prefix js i - run: npm --prefix js test From a6b43d0e01706f81e05a0097e575a3776e7c8c2a Mon Sep 17 00:00:00 2001 From: Daniel Schep Date: Wed, 25 Mar 2026 22:21:42 -0600 Subject: [PATCH 32/32] nodeversion pkgjson and versionsync --- js/package.json | 7 +++++-- package.json | 3 ++- scripts/update_js_version.js | 12 ++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 scripts/update_js_version.js diff --git a/js/package.json b/js/package.json index d457b2f8..ef994af3 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,9 @@ { "name": "@waysidemapping/pinhead-js", - "version": "15.16.0-dev", + "version": "1.20.0", + "engines": { + "node": ">=25.0.0" + }, "type": "module", "main": "index.js", "description": "Quality public domain sprites for your map", @@ -51,4 +54,4 @@ "pngjs": "^7.0.0", "prettier": "^3.8.1" } -} +} \ No newline at end of file diff --git a/package.json b/package.json index a66da3b7..7545b9cf 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "check_icons": "node scripts/check_icons.js", "fetch_wikidata_info": "node scripts/fetch_wikidata_info.js", "serve": "node serve.js", - "sync_to_commons": "node scripts/sync_to_commons.js" + "sync_to_commons": "node scripts/sync_to_commons.js", + "update_js_version": "node scripts/update_js_version.js" }, "devDependencies": { "archiver": "^7.0.1", diff --git a/scripts/update_js_version.js b/scripts/update_js_version.js new file mode 100644 index 00000000..1755e3f3 --- /dev/null +++ b/scripts/update_js_version.js @@ -0,0 +1,12 @@ +import { readFileSync, writeFileSync } from 'fs'; + +const changelogs = JSON.parse(readFileSync('dist/changelog.json')) + .toSorted((a, b) => parseInt(a.majorVersion) - parseInt(b.majorVersion)); + +const packageJson = JSON.parse(readFileSync('js/package.json')); +const majorVersion = parseInt(packageJson.version.split('.')[1]); + +const jsPackageJson = JSON.parse(readFileSync('js/package.json')); +jsPackageJson.version = `1.${majorVersion}.0`; + +writeFileSync('js/package.json', JSON.stringify(jsPackageJson, null, 2));