diff --git a/packages/map-styles/CHANGELOG.md b/packages/map-styles/CHANGELOG.md index 5e98e42f..ef5ebfc5 100644 --- a/packages/map-styles/CHANGELOG.md +++ b/packages/map-styles/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.3] - 2025-11-02 + +- Added `setupStyleImageManager` function and associated utilities to manage + style images +- Began adding more streamlined code for map symbol management +- Added utilities for loading SVG-based map symbols + ## [1.2.2] - 2025-10-05 - Add `pointSymbolIndex` to allow custom loading of symbols by external code diff --git a/packages/map-styles/package.json b/packages/map-styles/package.json index 3a4025d3..f5f598b6 100644 --- a/packages/map-styles/package.json +++ b/packages/map-styles/package.json @@ -1,6 +1,6 @@ { "name": "@macrostrat/map-styles", - "version": "1.2.2", + "version": "1.2.3", "description": "Utilities for working with Mapbox map styles", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -28,6 +28,7 @@ "dependencies": { "@macrostrat/color-utils": "workspace:*", "axios": "^1.7.9", - "mapbox-gl": "^2.15.0||^3.0.0" + "mapbox-gl": "^2.15.0||^3.0.0", + "textures": "^1.2.3" } } diff --git a/packages/map-styles/src/index.ts b/packages/map-styles/src/index.ts index 07bb73ab..9c9a6707 100644 --- a/packages/map-styles/src/index.ts +++ b/packages/map-styles/src/index.ts @@ -2,3 +2,4 @@ import "./images.d.ts"; export * from "./layer-helpers"; export * from "./styles"; +export * from "./style-image-manager"; diff --git a/packages/map-styles/src/layer-helpers/index.ts b/packages/map-styles/src/layer-helpers/index.ts index ff19c30a..3ae3198e 100644 --- a/packages/map-styles/src/layer-helpers/index.ts +++ b/packages/map-styles/src/layer-helpers/index.ts @@ -10,6 +10,7 @@ import mapboxgl from "mapbox-gl"; import "mapbox-gl/dist/mapbox-gl.css"; import { lineSymbols } from "./symbol-layers"; import { loadImage } from "./utils"; +export * from "./svg-patterns"; export interface LayerDescription { id: string; diff --git a/packages/map-styles/src/layer-helpers/pattern-fill.ts b/packages/map-styles/src/layer-helpers/pattern-fill.ts index 20f9a057..39f19623 100644 --- a/packages/map-styles/src/layer-helpers/pattern-fill.ts +++ b/packages/map-styles/src/layer-helpers/pattern-fill.ts @@ -1,10 +1,10 @@ -interface PatternFillSpec { +export interface PatternFillSpec { color: string; patternURL?: string; patternColor?: string; } -function loadImage(url): Promise { +export function loadImage(url): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = "anonymous"; @@ -14,7 +14,7 @@ function loadImage(url): Promise { }); } -function recolorPatternImage( +export function recolorPatternImage( img: HTMLImageElement, backgroundColor: string, color: string, @@ -51,7 +51,7 @@ function recolorPatternImage( return ctx.getImageData(0, 0, img.width, img.height); } -function createSolidColorImage(imgColor) { +export function createSolidColorImage(imgColor) { var canvas = document.createElement("canvas"); var ctx = canvas.getContext("2d"); canvas.width = 40; @@ -62,7 +62,7 @@ function createSolidColorImage(imgColor) { return ctx.getImageData(0, 0, 40, 40); } -async function createUnitFill(spec: PatternFillSpec): Promise { +export async function createUnitFill(spec: PatternFillSpec): Promise { /** Create a fill image for a map unit. */ if (spec.patternURL != null) { const img = await loadImage(spec.patternURL); @@ -72,4 +72,3 @@ async function createUnitFill(spec: PatternFillSpec): Promise { } } -export { recolorPatternImage, createUnitFill }; diff --git a/packages/map-styles/src/layer-helpers/svg-patterns.ts b/packages/map-styles/src/layer-helpers/svg-patterns.ts new file mode 100644 index 00000000..55c46261 --- /dev/null +++ b/packages/map-styles/src/layer-helpers/svg-patterns.ts @@ -0,0 +1,95 @@ +export async function renderTexturesPattern( + spec: any, + options?: RasterizeSVGOptions, +) { + /** Render pattern defined by a riccardoscalco/textures spec to ImageData */ + // Create SVG and render pattern + + const d3Sel = await import("d3-selection"); + + const sel = d3Sel.select(document.body); + + const svg = sel.append("svg"); + + svg.call(spec); + + const pattern = svg.select("pattern"); + const width = pattern.attr("width"); + const height = pattern.attr("height"); + + svg.attr("width", width); + svg.attr("height", height); + + // Apply the pattern to a rectangle + svg + .append("rect") + .attr("width", width) + .attr("height", height) + .attr("fill", spec.url()); + + const el = svg.node(); + + const data = await rasterizeSVG(el, options); + + svg.remove(); + + return data; +} + +// From https://observablehq.com/@mbostock/saving-svg +const xmlns = "http://www.w3.org/2000/xmlns/"; +const xlinkns = "http://www.w3.org/1999/xlink"; +const svgns = "http://www.w3.org/2000/svg"; + +function serializeSVG(svg: SVGElement) { + svg = svg.cloneNode(true); + const fragment = window.location.href + "#"; + const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT); + while (walker.nextNode()) { + for (const attr of walker.currentNode.attributes) { + if (attr.value.includes(fragment)) { + attr.value = attr.value.replace(fragment, "#"); + } + } + } + svg.setAttributeNS(xmlns, "xmlns", svgns); + svg.setAttributeNS(xmlns, "xmlns:xlink", xlinkns); + const serializer = new window.XMLSerializer(); + const string = serializer.serializeToString(svg); + return new Blob([string], { type: "image/svg+xml" }); +} + +export interface RasterizeSVGOptions { + pixelRatio?: number; +} + +async function rasterizeSVG( + svg: SVGElement, + options?: RasterizeSVGOptions, +): Promise { + const pixelScale = options?.pixelRatio ?? 4; + return new Promise((resolve, reject) => { + const canvas = document.createElement("canvas"); + const image = new Image(); + image.onerror = reject; + image.onload = () => { + const rect = svg.getBoundingClientRect(); + const ctx = canvas.getContext("2d"); + ctx.drawImage( + image, + 0, + 0, + rect.width * pixelScale, + rect.height * pixelScale, + ); + const data = ctx.getImageData( + 0, + 0, + rect.width * pixelScale, + rect.height * pixelScale, + ); + resolve(data); + }; + image.src = URL.createObjectURL(serializeSVG(svg)); + }); +} diff --git a/packages/map-styles/src/style-image-manager/index.ts b/packages/map-styles/src/style-image-manager/index.ts new file mode 100644 index 00000000..1f21d77d --- /dev/null +++ b/packages/map-styles/src/style-image-manager/index.ts @@ -0,0 +1,206 @@ +import { + createSolidColorImage, + loadImage, +} from "../layer-helpers/pattern-fill"; +import { createPatternImage } from "@macrostrat/ui-components"; + +export interface StyleImageManagerOptions { + baseURL?: string; + pixelRatio?: number; + resolvers?: Record; + throwOnMissing?: boolean; +} + +type PatternResolverFunction = ( + key: string, + args: string[], + options: StyleImageManagerOptions, +) => Promise; + +type ImageResult = + | HTMLImageElement + | ImageData + | { url: string; options?: AddImageOptions } + | null; + +interface PatternResult { + image: ImageResult; + options?: AddImageOptions; +} + +interface AddImageOptions { + sdf?: boolean; + pixelRatio?: number; +} + +export function setupStyleImageManager( + map: any, + options: StyleImageManagerOptions = {}, +): () => void { + const { throwOnMissing = false } = options; + const styleImageMissing = (e) => { + loadStyleImage(map, e.id, options) + .catch((err) => { + if (throwOnMissing) { + throw err; + } + console.error(`Failed to load pattern image for ${e.id}:`, err); + }) + .then(() => {}); + }; + + // Register the event listener for missing images + map.on("styleimagemissing", styleImageMissing); + return () => { + // Clean up the event listener when the component unmounts + map.off("styleimagemissing", styleImageMissing); + }; +} + +async function loadStyleImage( + map: mapboxgl.Map, + id: string, + options: StyleImageManagerOptions = {}, +) { + const { pixelRatio = 3 } = options; + const [prefix, name, ...rest] = id.split(":"); + + const { resolvers = defaultResolvers } = options; + + // Match the prefix to a resolver function + if (prefix in resolvers) { + const resolver = resolvers[prefix]; + const result = await resolver(prefix, [name, ...rest], options); + + let image: ImageResult = null; + let addOptions: AddImageOptions = { pixelRatio }; + + if (result != null) { + if ("options" in result) { + addOptions = { ...addOptions, ...(result.options ?? {}) }; + } + if ("image" in result) { + image = result.image; + addOptions = { ...addOptions, ...(result.options ?? {}) }; + } else { + image = result; + } + } + if (image == null) { + throw new Error(`No image returned by resolver for pattern: ${id}`); + } + if (typeof image === "object" && "url" in image) { + await addImageURLToMap(map, id, image.url, addOptions); + } else { + addImageToMap(map, id, image, addOptions); + } + return; + } +} + +enum SymbolImageFormat { + PNG = "png", + SVG = "svg", +} + +function resolveLineSymbolImage( + id: string, + args: string[], + options: StyleImageManagerOptions = {}, +) { + return resolveSymbolImage( + "geologic-symbols/lines/dev", + args, + SymbolImageFormat.PNG, + options, + ); +} + +function resolvePointSymbolImage( + id: string, + args: string[], + options: StyleImageManagerOptions = {}, +) { + return resolveSymbolImage( + "geologic-symbols/points/strabospot", + args, + SymbolImageFormat.PNG, + options, + ); +} + +function resolveSymbolImage( + set: string, + args: string[], + format: SymbolImageFormat = SymbolImageFormat.PNG, + options: StyleImageManagerOptions = {}, +) { + const { baseURL = "https://dev.macrostrat.org/assets/web" } = options; + const [name, ...rest] = args; + const lineSymbolsURL = `${baseURL}/${set}/${format}`; + return { url: lineSymbolsURL + `/${name}.${format}`, options: { sdf: true } }; +} + +export function addImageToMap( + map: mapboxgl.Map, + id: string, + image: HTMLImageElement | ImageData | null, + options: AddImageOptions, +) { + if (map.hasImage(id) || image == null) return; + map.addImage(id, image, options); +} + +export async function addImageURLToMap( + map: mapboxgl.Map, + id: string, + url: string, + options: AddImageOptions, +) { + if (map.hasImage(id)) return; + const image = await loadImage(url); + addImageToMap(map, id, image, options); +} + +async function resolveFGDCImage( + key: string, + args: string[], + options: StyleImageManagerOptions, +): Promise { + const { baseURL = "https://dev.macrostrat.org/assets/web" } = options; + const [name, color, backgroundColor = "transparent"] = args; + + const num = parseInt(name); + let patternName = name; + if (num == NaN) { + throw new Error(`Invalid FGDC pattern name: ${name}`); + } + if (num <= 599) { + // FGDC 1-599 are fill patterns + // Check if pattern ID has a suffix, or if not add one + patternName = `${num}-K`; + } + + const image = (await createPatternImage({ + patternURL: `${baseURL}/geologic-patterns/png/${patternName}.png`, + color: backgroundColor, + patternColor: color, + })) as ImageData; + + return image; +} + +async function resolveSolidColorImage( + key: string, + args: string[], + options: StyleImageManagerOptions, +): Promise { + return createSolidColorImage(args[0]); +} + +export const defaultResolvers: Record = { + fgdc: resolveFGDCImage, + color: resolveSolidColorImage, + "line-symbol": resolveLineSymbolImage, + point: resolvePointSymbolImage, +}; diff --git a/packages/map-styles/src/style-image-manager/pattern-images.ts b/packages/map-styles/src/style-image-manager/pattern-images.ts new file mode 100644 index 00000000..a649f8ed --- /dev/null +++ b/packages/map-styles/src/style-image-manager/pattern-images.ts @@ -0,0 +1,27 @@ +/** Todo: integrate this with Macrostrat web components */ + +export async function mapLoadImage(map, url: string) { + return new Promise((resolve, reject) => { + map.loadImage(url, function(err, image) { + // Throw an error if something went wrong + if (err) { + console.error(`Could not load image ${url}`); + reject(err); + } + // Declare the image + resolve(image); + }); + }); +} + +export function createTransparentImage() { + var canvas = document.createElement("canvas"); + var ctx = canvas.getContext("2d"); + canvas.width = 40; + canvas.height = 40; + ctx.globalAlpha = 0; + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, 40, 40); + return ctx.getImageData(0, 0, 40, 40); +} + diff --git a/packages/static-map-utils/CHANGELOG.md b/packages/static-map-utils/CHANGELOG.md index 685d94f7..883b6d44 100644 --- a/packages/static-map-utils/CHANGELOG.md +++ b/packages/static-map-utils/CHANGELOG.md @@ -4,7 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0] - 2025-06-29 +## [1.0.1] - 2025-11-02 + +- Move `setupStyleImageManager` function and associated utilities to + `@macrostrat/map-styles` package. +- Improve inset map examples. + +## [1.0.0] - 2025-10-29 - Initial release of the `@macrostrat/static-map-utils` library - Utilities for calculating the bounds of tiled maps diff --git a/packages/static-map-utils/package.json b/packages/static-map-utils/package.json index c80413df..619960c5 100644 --- a/packages/static-map-utils/package.json +++ b/packages/static-map-utils/package.json @@ -1,6 +1,6 @@ { "name": "@macrostrat/static-map-utils", - "version": "1.0.0", + "version": "1.0.1", "description": "Utilities for working with Mapbox maps", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/packages/static-map-utils/src/map-scale/index.ts b/packages/static-map-utils/src/map-scale/index.ts index 06724f0f..6111ce0f 100644 --- a/packages/static-map-utils/src/map-scale/index.ts +++ b/packages/static-map-utils/src/map-scale/index.ts @@ -1,11 +1,9 @@ import hyper from "@macrostrat/hyper"; import { scaleLinear } from "@visx/scale"; import styles from "./index.module.sass"; -import { createElement, useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import classNames from "classnames"; -console.log(styles); - const h = hyper.styled(styles); /** diff --git a/packages/static-map-utils/src/style-images/index.ts b/packages/static-map-utils/src/style-images/index.ts deleted file mode 100644 index 6ab7be84..00000000 --- a/packages/static-map-utils/src/style-images/index.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { createSolidColorImage, loadImage } from "./pattern-images"; - -export function setupStyleImageManager( - map: any, - pixelRatio: number, -): () => void { - const styleImageMissing = (e) => { - loadStyleImage(map, e.id, pixelRatio) - .catch((err) => { - console.error(`Failed to load pattern image for ${e.id}:`, err); - }) - .then(() => {}); - }; - - // Register the event listener for missing images - map.on("styleimagemissing", styleImageMissing); - return () => { - // Clean up the event listener when the component unmounts - map.off("styleimagemissing", styleImageMissing); - }; -} - -async function loadStyleImage( - map: mapboxgl.Map, - id: string, - pixelRatio: number = 3, -) { - const [prefix, name, ...rest] = id.split(":"); - - //console.log("Loading style image:", id, prefix, name, rest); - - if (prefix == "point") { - await loadSymbolImage( - map, - "geologic-symbols/points/strabospot", - id, - SymbolImageFormat.PNG, - pixelRatio, - ); - } else if (prefix == "line-symbol") { - // Load line symbol image - await loadSymbolImage( - map, - "geologic-symbols/lines/dev", - id, - SymbolImageFormat.PNG, - pixelRatio, - ); - // } - //else if (prefix == "cross-section") { - // TODO: better resolver for symbols - // Load cross-section specific symbols - // if (name in crossSectionSymbols) { - // const imgURL = crossSectionSymbols[name]; - // if (imgURL == null) { - // console.warn(`No image data found for cross-section symbol: ${name}`); - // return; - // } - // await addImageURLToMap(map, id, imgURL, { sdf: false, pixelRatio }); - // } - } else { - // Load pattern image - await loadPatternImage(map, id, pixelRatio); - } -} - -enum SymbolImageFormat { - PNG = "png", - SVG = "svg", -} - -async function loadSymbolImage( - map: mapboxgl.Map, - set: string, - id: string, - format: SymbolImageFormat = SymbolImageFormat.PNG, - pixelRatio: number = 3, -) { - const [prefix, name, ...rest] = id.split(":"); - const lineSymbolsURL = `https://dev.macrostrat.org/assets/web/${set}/${format}`; - await addImageURLToMap(map, id, lineSymbolsURL + `/${name}.${format}`, { - sdf: true, - pixelRatio, - }); -} - -async function loadPatternImage( - map: mapboxgl.Map, - patternSpec: string, - pixelRatio: number = 3, -) { - if (map.hasImage(patternSpec)) return; - const image = await buildPatternImage(patternSpec); - if (map.hasImage(patternSpec) || image == null) return; - - map.addImage(patternSpec, image, { - pixelRatio, // Use a higher pixel ratio for better quality - }); -} - -export function addImageToMap( - map: mapboxgl.Map, - id: string, - image: HTMLImageElement | ImageData | null, - options: any, -) { - if (map.hasImage(id) || image == null) return; - map.addImage(id, image, options); -} - -export async function addImageURLToMap( - map: mapboxgl.Map, - id: string, - url: string, - options: mapboxgl.AddImageOptions = {}, -) { - if (map.hasImage(id)) return; - const image = await loadImage(url); - if (map.hasImage(id) || image == null) return; - map.addImage(id, image, options); -} - -async function buildPatternImage( - patternSpec: string, - scale: number = 4, -): Promise { - const [prefix, ...rest] = patternSpec.split(":"); - if (prefix == "fgdc") { - const [name, color, backgroundColor] = rest; - - const urlParams = new URLSearchParams(); - urlParams.set("scale", scale.toString()); - if (backgroundColor) { - urlParams.set("background-color", backgroundColor); - } - if (color) { - urlParams.set("color", color); - } - - const url = `/styles/pattern/${name}.png?${urlParams.toString()}`; - return await loadImage(url); - } else if (prefix == "color") { - // Create a solid color image - const color = rest[0]; - return createSolidColorImage(color); - } - return null; -} diff --git a/packages/static-map-utils/src/style-images/pattern-images.ts b/packages/static-map-utils/src/style-images/pattern-images.ts deleted file mode 100644 index 9f54658c..00000000 --- a/packages/static-map-utils/src/style-images/pattern-images.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** Todo: integrate this with Macrostrat web components */ - -interface PatternFillSpec { - color: string; - patternURL: string | null; - patternColor?: string; -} - -export function loadImage(url): Promise { - return new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = "anonymous"; - img.addEventListener("load", () => resolve(img)); - img.addEventListener("error", (err) => reject(err)); - img.src = url; - }); -} - -export async function mapLoadImage(map, url: string) { - return new Promise((resolve, reject) => { - map.loadImage(url, function (err, image) { - // Throw an error if something went wrong - if (err) { - console.error(`Could not load image ${url}`); - reject(err); - } - // Declare the image - resolve(image); - }); - }); -} - -function recolorPatternImage( - img: HTMLImageElement, - backgroundColor: string, - color: string, -) { - // create hidden canvas - var canvas = document.createElement("canvas"); - - img.width *= 40; - img.height *= 40; - - canvas.width = img.width; - canvas.height = img.height; - - var ctx = canvas.getContext("2d"); - ctx.drawImage(img, 0, 0, img.width, img.height); - - //ctx.fillStyle = imgColor; - //ctx.fillRect(0, 0, 40, 40); - - // overlay using source-atop to follow transparency - ctx.globalCompositeOperation = "source-in"; - //ctx.globalAlpha = 0.3; - ctx.globalAlpha = 0.8; - ctx.fillStyle = color; - ctx.fillRect(0, 0, img.width, img.height); - - ctx.globalCompositeOperation = "destination-over"; - - //const map = ctx.getImageData(0, 0, img.width, img.height); - - ctx.globalAlpha = 0.5; - ctx.fillStyle = backgroundColor; - ctx.fillRect(0, 0, img.width, img.height); - - //ctx.putImageData(map, 0, 0); - - // replace image source with canvas data - return ctx.getImageData(0, 0, img.width, img.height); -} - -export function createTransparentImage() { - var canvas = document.createElement("canvas"); - var ctx = canvas.getContext("2d"); - canvas.width = 40; - canvas.height = 40; - ctx.globalAlpha = 0; - ctx.fillStyle = "#000000"; - ctx.fillRect(0, 0, 40, 40); - return ctx.getImageData(0, 0, 40, 40); -} - -export function createSolidColorImage(imgColor) { - var canvas = document.createElement("canvas"); - var ctx = canvas.getContext("2d"); - canvas.width = 40; - canvas.height = 40; - ctx.globalAlpha = 1; - ctx.fillStyle = imgColor; - ctx.fillRect(0, 0, 40, 40); - return ctx.getImageData(0, 0, 40, 40); -} - -async function createUnitFill( - spec: PatternFillSpec, - createSolidColorImages: boolean = false, -): Promise { - if (spec.patternURL != null) { - const img = await loadImage(spec.patternURL); - return recolorPatternImage(img, spec.color, spec.patternColor ?? "#000000"); - } else if (createSolidColorImages) { - return createSolidColorImage(spec.color); - } - return null; -} - -export { recolorPatternImage, createUnitFill }; diff --git a/packages/static-map-utils/src/tiled-map.ts b/packages/static-map-utils/src/tiled-map.ts index 3cc79c63..59013791 100644 --- a/packages/static-map-utils/src/tiled-map.ts +++ b/packages/static-map-utils/src/tiled-map.ts @@ -1,6 +1,5 @@ import maplibre, { PaddingOptions } from "maplibre-gl"; import { SphericalMercator } from "@mapbox/sphericalmercator"; -import { setupStyleImageManager } from "./style-images"; import { StrictPadding } from "@macrostrat/ui-components"; import { ReactNode, useEffect, useRef } from "react"; import hyper from "@macrostrat/hyper"; @@ -138,9 +137,7 @@ export function TiledMapArea({ type MapInitFunction = (mapOptions: maplibre.MapOptions) => maplibre.Map; function defaultInitializeMap(mapOptions: maplibre.MapOptions): maplibre.Map { - const map = new maplibre.Map(mapOptions); - setupStyleImageManager(map); - return map; + return new maplibre.Map(mapOptions); } export async function renderTiledMap( diff --git a/packages/static-map-utils/stories/static-map.stories.ts b/packages/static-map-utils/stories/static-map.stories.ts index fbb31774..ec3a5112 100644 --- a/packages/static-map-utils/stories/static-map.stories.ts +++ b/packages/static-map-utils/stories/static-map.stories.ts @@ -10,20 +10,39 @@ import { } from "../src"; import { Map } from "maplibre-gl"; import maplibre from "maplibre-gl"; +import { + setupStyleImageManager, + StyleImageManagerOptions, + renderTexturesPattern, +} from "@macrostrat/map-styles"; +import { mergeStyles } from "@macrostrat/mapbox-utils"; +import textures from "textures"; const mapboxToken = import.meta.env.VITE_MAPBOX_API_TOKEN; -function InsetMap({ bounds, className }: { bounds: any; initializeMap: any }) { +interface InsetMapOptions { + bounds: any; + className?: string; + onInitializeMap?: (a: Map) => void; + style?: any; + metersPerPixel?: number; +} + +function BaseInsetMap({ + bounds, + className, + onInitializeMap, + metersPerPixel = 200, + style, +}: InsetMapOptions) { const tileBounds = computeTiledBoundsForMap(bounds, { - metersPerPixel: 120, + metersPerPixel, tileSize: 512, padding: 20, }); const transformRequest = useMapboxRequestTransformer(mapboxToken); - const style = useInsetMapStyle(mapboxToken); - if (style == null) return null; return h( @@ -35,11 +54,13 @@ function InsetMap({ bounds, className }: { bounds: any; initializeMap: any }) { tileBounds: tileBounds, style, initializeMap(opts: maplibre.MapOptions) { - return new Map({ + const map = new Map({ ...opts, transformRequest, - pixelRatio: 8, + pixelRatio: 2, }); + onInitializeMap?.(map); + return map; }, }, h(Scalebar, { @@ -51,6 +72,23 @@ function InsetMap({ bounds, className }: { bounds: any; initializeMap: any }) { ); } +function InsetMap({ + bounds, + className, + onInitializeMap, + metersPerPixel, +}: Omit) { + const style = useInsetMapStyle(mapboxToken); + if (style == null) return null; + return h(BaseInsetMap, { + style, + bounds, + className, + onInitializeMap, + metersPerPixel, + }); +} + // More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export const meta: Meta = { title: "Static map utils/Inset map", @@ -65,3 +103,273 @@ export const Default: StoryObj = { bounds: [-118.67, 33.7, -117.5, 34.34], }, }; + +export function WithOverlay() { + const baseStyle = useInsetMapStyle(mapboxToken); + + const overlayStyle = { + sources: { + faults: { + type: "geojson", + // Major strike-slip faults in the San Francisco Bay Area + data: { + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { + type: "LineString", + coordinates: [ + [-122.85, 38.1], + [-122.35, 37.5], + ], + }, + properties: { + name: "San Andreas Fault", + }, + }, + { + type: "Feature", + geometry: { + type: "LineString", + coordinates: [ + [-122.48, 38.1], + [-121.9, 37.5], + ], + }, + properties: { + name: "Hayward Fault", + }, + }, + ], + }, + }, + // Pull-apart basin geometry + // Trapezoidal area between the two faults + basin: { + type: "geojson", + data: { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { + name: "Pull-apart basin", + }, + geometry: { + coordinates: [ + [ + [-122.25822428421804, 37.54229311032287], + [-122.00528127518058, 37.56808407036215], + [-122.25821273730716, 37.82895125974261], + [-122.53, 37.8], + [-122.25822428421804, 37.54229311032287], + ], + ], + type: "Polygon", + }, + }, + ], + }, + }, + }, + layers: [ + { + id: "fault-basin-fill", + type: "fill", + source: "basin", + layout: {}, + paint: { + "fill-color": "#888888", + "fill-opacity": 0.5, + "fill-pattern": "fgdc:406:#ff0000:transparent", + }, + }, + { + id: "fault-lines", + type: "line", + source: "faults", + layout: {}, + paint: { + "line-color": "black", + "line-width": 4, + }, + }, + { + id: "fault-line-symbols", + type: "symbol", + source: "faults", + layout: { + "symbol-placement": "line", + "icon-image": "line-symbol:right-lateral-fault", + "icon-size": 2, + "symbol-spacing": 200, + "icon-allow-overlap": true, + }, + }, + { + id: "basin-labels", + type: "symbol", + source: "basin", + layout: { + "text-field": ["get", "name"], + "text-font": ["PT Sans Bold"], + "text-size": 16, + "text-letter-spacing": 0.1, + "text-allow-overlap": true, + }, + paint: { + "text-color": "red", + "text-halo-color": "white", + "text-halo-width": 2, + }, + }, + { + id: "fault-labels", + type: "symbol", + source: "faults", + layout: { + "text-field": ["get", "name"], + "text-font": ["PT Sans Bold"], + "text-size": 16, + "symbol-placement": "line", + "text-rotation-alignment": "map", + "text-letter-spacing": 0.1, + "text-allow-overlap": true, + "text-offset": [0, 1], + }, + paint: { + "text-color": "black", + "text-halo-color": "white", + "text-halo-width": 2, + }, + }, + ], + }; + + const style = baseStyle == null ? null : mergeStyles(baseStyle, overlayStyle); + + return h(BaseInsetMap, { + // San Francisco + bounds: [-123.17, 37.48, -121.75, 38.17], + onInitializeMap(map) { + setupStyleImageManager(map); + }, + style, + }); +} + +export function WithTexturesResolver() { + const baseStyle = useInsetMapStyle(mapboxToken); + + const textureStyle = { + sources: { + squares: { + type: "geojson", + data: { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { + name: "t0", + }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-122.5, 37.7], + [-122.3, 37.7], + [-122.3, 37.9], + [-122.5, 37.9], + [-122.5, 37.7], + ], + ], + }, + }, + { + type: "Feature", + properties: { + name: "t1", + }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-122.7, 37.7], + [-122.5, 37.7], + [-122.5, 37.9], + [-122.7, 37.9], + [-122.7, 37.7], + ], + ], + }, + }, + ], + }, + }, + }, + layers: [ + { + id: "square-textures", + type: "fill", + source: "squares", + layout: {}, + paint: { + "fill-color": "#ffffffaa", + "fill-pattern": ["concat", "textures:", ["get", "name"]], + }, + }, + ], + }; + + const style = baseStyle == null ? null : mergeStyles(baseStyle, textureStyle); + + return h(BaseInsetMap, { + // San Francisco + bounds: [-123.0, 37.6, -122.2, 38.0], + onInitializeMap(map) { + setupStyleImageManager(map, { + pixelRatio: 8, + resolvers: { + textures: texturesResolver, + }, + }); + }, + style, + }); +} + +async function texturesResolver( + id: string, + args: string[], + options: StyleImageManagerOptions, +) { + const name = args[0]; + // Construct a texture pattern image + + let spec = null; + if (name === "t0") { + spec = textures + .circles() + .size(8) + .radius(2) + .fill("red") + .background("#ffffff88"); + } else if (name === "t1") { + spec = textures + .lines() + .orientation("6/8") + .size(8) + .strokeWidth(1) + .stroke("green"); + } + + if (spec == null) { + throw new Error(`No texture pattern found for name: ${name}`); + } + + return await renderTexturesPattern(spec, { + pixelRatio: 5, + }); +} diff --git a/packages/ui-components/CHANGELOG.md b/packages/ui-components/CHANGELOG.md index 8a0f9fe8..599200b3 100644 --- a/packages/ui-components/CHANGELOG.md +++ b/packages/ui-components/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [4.5.1] - 2025-11-02 + +Export `PatternFillSpec` type + ## [4.5.0] - 2025-10-29 Allow `SizeAwareLabel` to be rotated diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index 9717a380..fccbb3cd 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -1,6 +1,6 @@ { "name": "@macrostrat/ui-components", - "version": "4.5.0", + "version": "4.5.1", "description": "UI components for React and Blueprint.js", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/packages/ui-components/src/patterns/composite-image.ts b/packages/ui-components/src/patterns/composite-image.ts index 35b62228..3482b850 100644 --- a/packages/ui-components/src/patterns/composite-image.ts +++ b/packages/ui-components/src/patterns/composite-image.ts @@ -1,4 +1,4 @@ -interface PatternFillSpec { +export interface PatternFillSpec { color: string; patternURL?: string; patternColor?: string; diff --git a/yarn.lock b/yarn.lock index 1543cf3c..de8ae32a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2534,6 +2534,7 @@ __metadata: axios: "npm:^1.7.9" mapbox-gl: "npm:^2.15.0||^3.0.0" parcel: "npm:^2.16.0" + textures: "npm:^1.2.3" ts-node: "npm:^10.7.0" languageName: unknown linkType: soft @@ -13307,7 +13308,7 @@ __metadata: languageName: node linkType: hard -"textures@npm:^1.2.0": +"textures@npm:^1.2.0, textures@npm:^1.2.3": version: 1.2.3 resolution: "textures@npm:1.2.3" checksum: 10c0/a2088599e6b95ed42fb96b3a38e5a2b67f2e3da0d4a8ba661bb9549e4ea249f97cb315c9ab2b2e0a3005db4f35702eeda57c3f06b3cb0d46639031c44b449f86