diff --git a/.github/workflows/check-format-js.yml b/.github/workflows/check-format-js.yml new file mode 100644 index 0000000..8e590c9 --- /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 run check-format + diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml new file mode 100644 index 0000000..03f575f --- /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: '25.x' + - run: npm --prefix js i + - run: npm --prefix js test + diff --git a/.gitignore b/.gitignore index 59d6699..84897b7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ font/package.json font/pinhead.css font/pinhead.ttf font/preview.html -qgis_resources_repo \ No newline at end of file +qgis_resources_repo +node_modules/ +package-lock.json +js/test/fixtures/*-diff.png diff --git a/js/README.md b/js/README.md new file mode 100644 index 0000000..80e74ff --- /dev/null +++ b/js/README.md @@ -0,0 +1,223 @@ +# 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. + +![](./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 + +- **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 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 + +```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`, `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` | + +--- + +## Usage + +### JavaScript API + +#### Create Icons + +Ideal for dynamic icon generation in the browser or on the server. + +```javascript +import { getIcon } from "@waysidemapping/pinhead-js"; + +// Simple icon +const svg = getIcon("cargobike"); + +// Icon with background and custom colors +const marker = getIcon("jeep", { + shape: "map_pin", + shapeFill: "#6486f5", + strokeWidth: 1, +}); +``` + +##### Examples + +| 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" })` | + +#### 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 +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 + +Outputs the SVG string directly to `stdout`. + +```bash +npx pinhead get-icon cargobike --shape=square --shapeFill='#6486f5' > icon.svg +``` + +#### 2. Batch build from configuration + +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-icons --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 Icon SVG requirements + +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. +- 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 = getIcon("greek_cross", { 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 0000000..04959d7 --- /dev/null +++ b/js/cli.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +import fs from "fs"; +import { parseArgs } from "util"; +import { getIcon } from "./index.js"; + +const commands = { + "get-icon": { + 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(getIcon(positionals[0], values)); + return 0; + }, + }, + "build-icons": { + 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`, + getIcon(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/beer-marker-amber.svg b/js/examples/beer-marker-amber.svg new file mode 100644 index 0000000..3c39b6f --- /dev/null +++ b/js/examples/beer-marker-amber.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/bike-circle-green.svg b/js/examples/bike-circle-green.svg new file mode 100644 index 0000000..348e9d4 --- /dev/null +++ b/js/examples/bike-circle-green.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/burger-marker.svg b/js/examples/burger-marker.svg new file mode 100644 index 0000000..66903d2 --- /dev/null +++ b/js/examples/burger-marker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/bus-circle-blue.svg b/js/examples/bus-circle-blue.svg new file mode 100644 index 0000000..b80fc47 --- /dev/null +++ b/js/examples/bus-circle-blue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/cafe-black-stroke.svg b/js/examples/cafe-black-stroke.svg new file mode 100644 index 0000000..fff3ed1 --- /dev/null +++ b/js/examples/cafe-black-stroke.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/camera-marker-darkgrey.svg b/js/examples/camera-marker-darkgrey.svg new file mode 100644 index 0000000..b0f4c4c --- /dev/null +++ b/js/examples/camera-marker-darkgrey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/cargobike-square-blue.svg b/js/examples/cargobike-square-blue.svg new file mode 100644 index 0000000..5101260 --- /dev/null +++ b/js/examples/cargobike-square-blue.svg @@ -0,0 +1 @@ + \ 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 0000000..1063503 --- /dev/null +++ b/js/examples/cargobike-stroke.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + diff --git a/js/examples/cargobike.svg b/js/examples/cargobike.svg new file mode 100644 index 0000000..6401048 --- /dev/null +++ b/js/examples/cargobike.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/dot.svg b/js/examples/dot.svg new file mode 100644 index 0000000..6429b52 --- /dev/null +++ b/js/examples/dot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/generateExamples.js b/js/examples/generateExamples.js new file mode 100644 index 0000000..9acc501 --- /dev/null +++ b/js/examples/generateExamples.js @@ -0,0 +1,11 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +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 = getIcon(example.icon, example.properties); + writeFileSync(join("examples", `${example.name}.svg`), Buffer.from(svg)); +} diff --git a/js/examples/ice_cream-circle-pink.svg b/js/examples/ice_cream-circle-pink.svg new file mode 100644 index 0000000..e9c111c --- /dev/null +++ b/js/examples/ice_cream-circle-pink.svg @@ -0,0 +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 new file mode 100644 index 0000000..1468c92 --- /dev/null +++ b/js/examples/jeep-map_pin-stroke-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/map-pointer-cargobike.svg b/js/examples/map-pointer-cargobike.svg new file mode 100644 index 0000000..a30b5b6 --- /dev/null +++ b/js/examples/map-pointer-cargobike.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/js/examples/pinstash.svg b/js/examples/pinstash.svg new file mode 100644 index 0000000..328d24b --- /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 0000000..ef24719 --- /dev/null +++ b/js/examples/pizza-square-red.svg @@ -0,0 +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 0000000..478fa7c --- /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-down.svg b/js/examples/plane-down.svg new file mode 100644 index 0000000..e21e56e --- /dev/null +++ b/js/examples/plane-down.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 new file mode 100644 index 0000000..4c1af70 --- /dev/null +++ b/js/examples/plane-square-navy.svg @@ -0,0 +1 @@ + \ 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 0000000..ab89087 --- /dev/null +++ b/js/examples/png-map-pointer-cargobike.svg @@ -0,0 +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 new file mode 100644 index 0000000..7e06411 --- /dev/null +++ b/js/examples/rocket-map_pin-purple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/sun-square-yellow.svg b/js/examples/sun-square-yellow.svg new file mode 100644 index 0000000..e045463 --- /dev/null +++ b/js/examples/sun-square-yellow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/examples/tent-square-brown.svg b/js/examples/tent-square-brown.svg new file mode 100644 index 0000000..fc2ac59 --- /dev/null +++ b/js/examples/tent-square-brown.svg @@ -0,0 +1 @@ + \ 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 0000000..330fa69 --- /dev/null +++ b/js/examples/translucent-cargobike.svg @@ -0,0 +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 new file mode 100644 index 0000000..17b3dd0 --- /dev/null +++ b/js/examples/tree-map_pin-green.svg @@ -0,0 +1 @@ + \ 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 0000000..92867a1 --- /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 new file mode 100644 index 0000000..c253dd7 --- /dev/null +++ b/js/icon.js @@ -0,0 +1,224 @@ +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 +const size = 15; + +const defaultPadding = { + map_pin: 4, + circle: 2, + square: 2, + marker: 5, +}; + +export function getIcon(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; + case shape?.includes("/, ""); + break; + case shape?.startsWith("data:image/png;base64,"): + const buffer = Uint8Array.fromBase64(shape.split(",", 2)[1]); + ({ height, width } = imageSize(buffer)); + svg = minify` + + `; + break; + default: + // Nothing to do when not drawing a shape + break; + } + let extraTransform = ""; + if (properties.rotate) { + 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` + + `; + // 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``; + } + svg += minify``; + } + svg += ""; + return svg; +} diff --git a/js/index.js b/js/index.js new file mode 100644 index 0000000..b433ed6 --- /dev/null +++ b/js/index.js @@ -0,0 +1,2 @@ +export { getIcon } from "./icon.js"; +export { migrateName } from "./migrate.js"; diff --git a/js/migrate.js b/js/migrate.js new file mode 100644 index 0000000..d88ecfb --- /dev/null +++ b/js/migrate.js @@ -0,0 +1,87 @@ +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 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; + for (const change of changelog.filter( + ({ majorVersion }) => parseInt(majorVersion) > version, + )) { + for (const icon of change.iconChanges) { + if (icon.oldId === resolvedName) { + resolvedName = icon.newId; + } + } + } + return resolvedName; +} + +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 (icon.oldId === (resolvedName || name)) { + // migrate! + 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/package.json b/js/package.json new file mode 100644 index 0000000..ef994af --- /dev/null +++ b/js/package.json @@ -0,0 +1,57 @@ +{ + "name": "@waysidemapping/pinhead-js", + "version": "1.20.0", + "engines": { + "node": ">=25.0.0" + }, + "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": "env NODE_ENV=test node --test test/*.test.js", + "format": "prettier --write *.js test/*.js README.md", + "check-format": "prettier --check *.js test/*.js README.md" + }, + "dependencies": { + "@waysidemapping/pinhead": "^15.16.0", + "image-size": "^2.0.2", + "tinycolor2": "^1.6.0" + }, + "devDependencies": { + "@resvg/resvg-js": "^2.6.2", + "pixelmatch": "^7.1.0", + "pngjs": "^7.0.0", + "prettier": "^3.8.1" + } +} \ No newline at end of file diff --git a/js/test/examples.js b/js/test/examples.js new file mode 100644 index 0000000..d93d2e6 --- /dev/null +++ b/js/test/examples.js @@ -0,0 +1,141 @@ +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_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", + 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" }, + }, + { + name: "translucent-cargobike", + icon: "cargobike", + properties: { + fill: "rgba(255,0,0,0.5)", + stroke: "rgba(0,0,255,0.5)", + 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, + }, + }, + { + 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/beer-marker-amber.png b/js/test/fixtures/beer-marker-amber.png new file mode 100644 index 0000000..0c8353d Binary files /dev/null and b/js/test/fixtures/beer-marker-amber.png differ diff --git a/js/test/fixtures/bike-circle-green.png b/js/test/fixtures/bike-circle-green.png new file mode 100644 index 0000000..2c3a426 Binary files /dev/null and b/js/test/fixtures/bike-circle-green.png differ diff --git a/js/test/fixtures/burger-marker.png b/js/test/fixtures/burger-marker.png new file mode 100644 index 0000000..39325ca Binary files /dev/null and b/js/test/fixtures/burger-marker.png differ diff --git a/js/test/fixtures/bus-circle-blue.png b/js/test/fixtures/bus-circle-blue.png new file mode 100644 index 0000000..c49000c Binary files /dev/null and b/js/test/fixtures/bus-circle-blue.png differ diff --git a/js/test/fixtures/cafe-black-stroke.png b/js/test/fixtures/cafe-black-stroke.png new file mode 100644 index 0000000..15d02e5 Binary files /dev/null and b/js/test/fixtures/cafe-black-stroke.png differ diff --git a/js/test/fixtures/camera-marker-darkgrey.png b/js/test/fixtures/camera-marker-darkgrey.png new file mode 100644 index 0000000..7a4e7f5 Binary files /dev/null and b/js/test/fixtures/camera-marker-darkgrey.png differ diff --git a/js/test/fixtures/cargobike-square-blue.png b/js/test/fixtures/cargobike-square-blue.png new file mode 100644 index 0000000..4f74b2e Binary files /dev/null and b/js/test/fixtures/cargobike-square-blue.png differ diff --git a/js/test/fixtures/cargobike.png b/js/test/fixtures/cargobike.png new file mode 100644 index 0000000..4f10e01 Binary files /dev/null and b/js/test/fixtures/cargobike.png differ diff --git a/js/test/fixtures/dot.png b/js/test/fixtures/dot.png new file mode 100644 index 0000000..1b7af9d Binary files /dev/null and b/js/test/fixtures/dot.png differ 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 0000000..cd7e8ee Binary files /dev/null and b/js/test/fixtures/ice_cream-circle-pink.png differ diff --git a/js/test/fixtures/jeep-map_pin-stroke-1.png b/js/test/fixtures/jeep-map_pin-stroke-1.png new file mode 100644 index 0000000..73ff5d8 Binary files /dev/null and b/js/test/fixtures/jeep-map_pin-stroke-1.png differ diff --git a/js/test/fixtures/map-pointer-cargobike.png b/js/test/fixtures/map-pointer-cargobike.png new file mode 100644 index 0000000..d9275f0 Binary files /dev/null and b/js/test/fixtures/map-pointer-cargobike.png differ diff --git a/js/test/fixtures/pizza-square-red.png b/js/test/fixtures/pizza-square-red.png new file mode 100644 index 0000000..88461e0 Binary files /dev/null and b/js/test/fixtures/pizza-square-red.png differ 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 0000000..fb89522 Binary files /dev/null and b/js/test/fixtures/plane-down-square-navy.png differ diff --git a/js/test/fixtures/plane-down.png b/js/test/fixtures/plane-down.png new file mode 100644 index 0000000..9efe93b Binary files /dev/null and b/js/test/fixtures/plane-down.png differ diff --git a/js/test/fixtures/plane-square-navy.png b/js/test/fixtures/plane-square-navy.png new file mode 100644 index 0000000..2360cb2 Binary files /dev/null and b/js/test/fixtures/plane-square-navy.png differ 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 0000000..b63c4e5 Binary files /dev/null and b/js/test/fixtures/png-map-pointer-cargobike.png differ diff --git a/js/test/fixtures/rocket-map_pin-purple.png b/js/test/fixtures/rocket-map_pin-purple.png new file mode 100644 index 0000000..af0d264 Binary files /dev/null and b/js/test/fixtures/rocket-map_pin-purple.png differ diff --git a/js/test/fixtures/sun-square-yellow.png b/js/test/fixtures/sun-square-yellow.png new file mode 100644 index 0000000..3cd3a2e Binary files /dev/null and b/js/test/fixtures/sun-square-yellow.png differ diff --git a/js/test/fixtures/tent-square-brown.png b/js/test/fixtures/tent-square-brown.png new file mode 100644 index 0000000..1676ea5 Binary files /dev/null and b/js/test/fixtures/tent-square-brown.png differ diff --git a/js/test/fixtures/translucent-cargobike.png b/js/test/fixtures/translucent-cargobike.png new file mode 100644 index 0000000..42735e9 Binary files /dev/null and b/js/test/fixtures/translucent-cargobike.png differ 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 0000000..2b5cfad Binary files /dev/null and b/js/test/fixtures/tree-map_pin-green.png differ diff --git a/js/test/fixtures/upside-down-jeep-map_pin-stroke-1.png b/js/test/fixtures/upside-down-jeep-map_pin-stroke-1.png new file mode 100644 index 0000000..b6620db Binary files /dev/null and b/js/test/fixtures/upside-down-jeep-map_pin-stroke-1.png differ diff --git a/js/test/generateFixtures.js b/js/test/generateFixtures.js new file mode 100644 index 0000000..9753988 --- /dev/null +++ b/js/test/generateFixtures.js @@ -0,0 +1,15 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getIcon } 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 = getIcon(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/getIcon.test.js b/js/test/getIcon.test.js new file mode 100644 index 0000000..6d7ac68 --- /dev/null +++ b/js/test/getIcon.test.js @@ -0,0 +1,64 @@ +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 { getIcon } from "../icon.js"; +import { Resvg } from "@resvg/resvg-js"; +import pixelmatch from "pixelmatch"; +import { PNG } from "pngjs"; + +import { examples } from "./examples.js"; + +for (const example of examples) { + 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 + 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 0000000..17c1008 --- /dev/null +++ b/js/test/getSvgPathStrings.test.js @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { getSvgPathStrings } from "../util.js"; + +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", + ); +}); diff --git a/js/test/migrate.test.js b/js/test/migrate.test.js new file mode 100644 index 0000000..01f88b3 --- /dev/null +++ b/js/test/migrate.test.js @@ -0,0 +1,178 @@ +import { test } from "node:test"; +import assert from "node:assert"; +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"); +}); + +// 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 -> 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"); +}); + +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", + ); +}); + +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 0000000..cf6a5bd --- /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 new file mode 100644 index 0000000..fc306df --- /dev/null +++ b/js/util.js @@ -0,0 +1,27 @@ +export function getSvgPathStrings(iconSvg) { + const paths = []; + for (const { groups } of iconSvg.matchAll( + /]+?d="(?[^"]+?)"/gm, + )) { + paths.push(groups.path); + } + 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; +} diff --git a/package.json b/package.json index a66da3b..7545b9c 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/build_preview_docs.js b/scripts/build_preview_docs.js index 8ae39c1..e2c48d2 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 +} diff --git a/scripts/update_js_version.js b/scripts/update_js_version.js new file mode 100644 index 0000000..1755e3f --- /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));