@tingard/ictk
v0.1.2
Published
Composable, interactive map-marker icons for MapLibre GL and deck.gl, generalized from NATO's own guidance for map icons.
Maintainers
Readme
ICTK
ICTK (ICon ToolKit) is a React component library for composing map-marker icons — frame, fill, center icon, modifiers, and amplifiers — loosely generalized from NATO's own guidance for map icons. It's built for one specific job: dropping real, interactive icons onto WebGL map libraries like MapLibre GL JS and deck.gl as live DOM markers, not rasterized bitmaps.
import { Icon, SquareFrame } from '@tingard/ictk';
import '@tingard/ictk/style.css';
<Icon
frame={<SquareFrame fill="#c0392b" />}
icon={<span>+</span>}
modifierTop={<span>A</span>}
modifierBottom={<span>2</span>}
amplifiers={{ T: <span>Alpha-1</span> }}
/>;Is ICTK for you?
Use it if you want composable, interactive map-marker icons — real DOM elements with real click/hover handlers — on MapLibre GL or deck.gl's DOM-overlay path, and you're fine building on a small set of primitives rather than a complete symbol set.
Consider something else if:
- You need full NATO/military symbology fidelity (echelon marks, the complete shape vocabulary, standard affiliation colors).
milsymbolimplements the actual standard properly; ICTK deliberately doesn't. - You need thousands of markers rendered performantly. DOM markers (what both MapLibre's
Markerand deck.gl's DOM-overlay path use) don't scale the way a WebGLIconLayerdoes — for large point clouds, a rasterized icon layer is the right tool. - You just need one simple marker. A plain
maplibregl.Markerwith your own<div>is less machinery than pulling in a whole composition system.
Testing honesty: every CSS layout invariant (box size, centering, overflow behavior, amplifier positioning) is verified in real Chromium via Playwright, not jsdom — but only Chromium so far. Cross-browser behavior (Firefox, Safari/WebKit) is currently unverified; treat it as such until that's added.
Contents
- Is ICTK for you?
- Why real DOM markers
- Concepts
- Sizing and anchoring
- Built-in frames
- Extending: custom frames
- Map integration
- Fail-loud, not silent
- API reference
- Development
- Status and non-goals
Why real DOM markers
Both MapLibre and deck.gl support mounting arbitrary DOM content as map markers — maplibregl.Marker({ element }) and deck.gl's @deck.gl/react DOM-overlay children both position a real DOM node over the map using the same projection math the map itself uses. That's different from something like a WebGL IconLayer, which needs a pre-rasterized bitmap in a texture atlas.
Because ICTK targets that DOM-marker path specifically, <Icon /> is just a normal React component. Mount it into whatever element you hand the map library, and it works — no SVG-to-bitmap pipeline, no texture atlas, and pointer events (click, hover) work exactly as they would anywhere else in your app, because they are anywhere else in your app.
import { createRoot } from 'react-dom/client';
import { Marker } from 'maplibre-gl';
import { Icon, SquareFrame } from '@tingard/ictk';
const el = document.createElement('div');
createRoot(el).render(<Icon frame={<SquareFrame fill="#c0392b" />} icon={<span>A</span>} />);
new Marker({ element: el, anchor: 'center' }).setLngLat([2.35, 48.86]).addTo(map);See examples/maplibre for a complete, running example with multiple markers, click handling, and notes on a couple of maplibre-gl v6 + Vite integration gotchas that had nothing to do with ICTK itself but are worth knowing about.
Concepts
An icon is composed from five independent pieces:
- Frame — the overall backdrop shape (square, circle, hexagon, ...). A frame is a pure backdrop: it never receives or renders icon/modifier content, so every frame behaves identically regardless of shape.
- Fill — the frame's background: a solid CSS color or a gradient. (Image/texture-atlas backgrounds are deliberately out of scope — see
Fill.) - Icon — the central glyph. Always rendered exactly centered in the frame, regardless of which modifiers are present.
- Modifiers — small content above (
modifierTop) and below (modifierBottom) the icon, still inside the frame. - Amplifiers — supporting text or sub-icons in 13 fixed positions around the frame's edges, keyed by position code.
T
L0 R0
L1 R1
L2 frame R2
L3 R3
L4 R4
B0
B1Amplifiers are positioned via CSS Grid and are allowed to overflow outside the icon's own box — they never affect its size (see Fail-loud, not silent).
Sizing and anchoring
<Icon size={n} /> sets the icon's overall footprint in pixels (default 32, chosen to sit closer to conventional map-marker sizing — Leaflet/Mapbox/Google's default pins run roughly 25–41px — than a larger default would). Icon/modifier/amplifier font sizes scale proportionally with size; there's deliberately no minimum-legibility clamp, since a consumer who needs a guaranteed text size for specific content can just style that content directly (every content prop accepts arbitrary ReactNode).
The frame always renders at exactly size x size, anchored at its own center — amplifiers can spill outside that box, but never change its dimensions. That makes the center a stable anchor point for maplibregl.Marker's default anchor: 'center' and deck.gl's DOM-overlay positioning. There's currently only the one anchor mode; alternatives (e.g. a flagpole-style bottom anchor) aren't implemented until a real use case needs one.
Built-in frames
| Component | Shape | Notes |
| --- | --- | --- |
| SquareFrame | Square | |
| CircleFrame | Circle | |
| HexagonFrame | Flat-top hexagon | |
| DiamondFrame | Diamond | An SVG polygon, not a transform: rotated square, so content inside stays upright |
| CupFrame | Flat top, rounded bottom | The sea-subsurface silhouette from NATO's own guidance |
| CapFrame | Rounded top, flat bottom | The air silhouette from NATO's own guidance for map icons; the inverse of CupFrame |
Each accepts fill and stroke props (a Fill — solid color or gradient — and a Stroke; see the API reference below).
ICTK intentionally stops there. NATO's own guidance for map icons defines a much larger vocabulary of frame shapes — diamonds with corner ticks, houses, inverted houses, quatrefoils ("clovers"), and more (see static/app6d-standard-identifiers.png for the full reference table) — but ICTK isn't aiming for full parity with it. If you need full-fidelity military symbology, a dedicated library like milsymbol is a better fit. ICTK's value is being a lightweight, generalized composition system — and since a frame is just a component, nothing stops you from building the shapes you need yourself (see below).
Extending: custom frames
A frame is any component that renders something carrying the ictk-frame class — that's the only real requirement, and it's a runtime/CSS contract, not something TypeScript enforces. FrameBase is the supported way to satisfy it without needing to know that detail. Frame shapes are real SVG (rect/circle/polygon/path), not CSS clip-path on a div — a CSS border never follows an angular clip-path correctly (it's drawn on the element's original rectangular border-box regardless of the clip), while SVG stroke follows any shape's actual outline:
import { FrameBase, type FrameBaseProps } from '@tingard/ictk';
export function HouseFrame({ fill, stroke }: Pick<FrameBaseProps, 'fill' | 'stroke'>) {
return (
<FrameBase
fill={fill}
stroke={stroke}
shape={{ kind: 'path', d: 'M0,40 L50,0 L100,40 L100,100 L0,100 Z' }}
/>
);
}FrameBase applies the ictk-frame class (grid placement and sizing within <Icon />), resolves fill into an SVG paint (a color, or a generated <linearGradient> for a gradient fill), and renders shape — a normalized 0-100 viewBox coordinate space, so it scales correctly regardless of the icon's actual size. shape accepts { kind: 'rect', rx? }, { kind: 'circle' }, { kind: 'polygon', points }, or { kind: 'path', d }, matching the underlying SVG elements directly. This is deliberately the whole contract: it depends on nothing from <Icon /> internals, so a set of custom frames (a full NATO-shape-vocabulary pack, a client-specific icon set, whatever) is easy to publish as its own separate package that depends on @tingard/ictk for FrameBase/FrameBaseProps and nothing else. fill/stroke are just FrameBase's convenience, not something <Icon /> requires — a custom frame is free to skip FrameBase entirely and render its own SVG/DOM, as long as it carries the ictk-frame class.
For any regular polygon (pentagon, octagon, whatever NATO shape you're missing), skip hand-plotting points and use nGonPoints, the same helper SquareFrame/HexagonFrame are built on:
import { FrameBase, nGonPoints, type FrameBaseProps } from '@tingard/ictk';
const PENTAGON_POINTS = nGonPoints(5, { rotation: -90 }); // point straight up
export function PentagonFrame({ fill, stroke }: Pick<FrameBaseProps, 'fill' | 'stroke'>) {
return <FrameBase fill={fill} stroke={stroke} shape={{ kind: 'polygon', points: PENTAGON_POINTS }} />;
}nGonPoints(n, { cx, cy, radius, rotation }) places n vertices around a center (defaulting to the middle of the 0-100 box, radius 50 — touching the box edges), rotation in degrees along SVG's angle convention. It's worth noting radius is the distance to each vertex (the circumradius), not to an edge's midpoint — that's why SquareFrame uses radius: 50 * Math.SQRT2 to reach the corners of an axis-aligned box. SquareFrame doesn't do rounded corners — a plain polygon can't round them the way <rect rx> can — so use { kind: 'rect', rx } directly if you need that.
Fail-loud, not silent
Oversized content (a modifier string too long for its icon, an amplifier that doesn't fit) stays visible and overflowing, rather than being clipped. The layer holding icon/modifier content uses min-width/min-height: 0 rather than overflow: hidden specifically for this reason: silently clipping content would hide a misconfigured marker from the developer, which is the opposite of fail-safe. If something looks broken, it's supposed to look broken.
API reference
<Icon />
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| frame | ReactElement | required | A built-in frame or any custom component satisfying the ictk-frame contract. |
| icon | ReactNode | — | Centered content. |
| modifierTop | ReactNode | — | |
| modifierBottom | ReactNode | — | |
| amplifiers | Partial<Record<AmplifierPosition, ReactNode>> | — | Keyed by position code (T, L0–L4, R0–R4, B0, B1). Only present keys render. |
| size | number | 32 | Overall footprint in pixels. |
| className | string | — | |
| style | CSSProperties | — | |
Fill
type Fill =
| string // any CSS color
| { type: 'gradient'; stops: { offset: number; color: string }[]; angle?: number };Deliberately solid-color/gradient only — image and texture-atlas backgrounds aren't in scope for FrameBase. Neither needs any of FrameBase's machinery: an image-backed frame is just an <img>/<canvas> positioned under a transparent frame, or a fully custom frame (see Extending: custom frames) rendering its own <pattern> fill with whatever cropping/aspect-ratio behavior it needs.
Stroke
interface Stroke {
color: string;
width: number; // a fixed pixel width regardless of icon size
}FrameBase
interface FrameBaseProps {
fill?: Fill;
stroke?: Stroke;
shape: FrameShape;
}
type FrameShape =
| { kind: 'rect'; rx?: number }
| { kind: 'circle' }
| { kind: 'polygon'; points: string }
| { kind: 'path'; d: string };Development
This is a pnpm workspace: the library lives at the repo root (ictk), with runnable examples under examples/.
pnpm install
pnpm --filter @tingard/ictk typecheck
pnpm --filter @tingard/ictk lint
pnpm --filter @tingard/ictk test # vitest (jsdom component tests) + real-Chromium
# Playwright tests for every CSS Grid layout
# invariant — box size never grows with
# content, amplifiers never overlap, the icon
# stays centered regardless of modifiers.
# jsdom alone can't catch any of these; it
# doesn't do real layout. (Chromium only for
# now — see "Is ICTK for you?" above.)
pnpm --filter @tingard/ictk build
pnpm --filter @ictk/example-maplibre dev # live example at localhost:5173Dependency versions are gated by a 14-day minimumReleaseAge policy in pnpm-workspace.yaml (a cheap, high-leverage defense against supply-chain attacks — most malicious npm releases are caught and yanked within days), and install/postinstall scripts are denied by default except for an explicit allowBuilds allowlist.
Status and non-goals
- Pre-1.0, not yet published to npm. License not finalized (leaning MIT).
- Not aiming for full parity with NATO's own guidance for map icons — see Built-in frames. This is a deliberate, standing scope decision, not a "not implemented yet."
- Tested in real Chromium via Playwright, not yet cross-browser — see Is ICTK for you?.
- One anchor mode only (frame center) — see Sizing and anchoring.
- MapLibre GL integration is verified working end-to-end (real map, real markers, real clicks — see
examples/maplibre). deck.gl's DOM-overlay pattern is understood conceptually but not yet built or tested against a real deck.gl app.
