Optional point symbols ​
Point symbols share TacticalDraw's render, draw, edit, pick, and group-transform APIs with control measures. Rendering them is optional, so @orbat-mapper/tactical-draw does not depend on milsymbol.
pnpm add @orbat-mapper/point-symbols milsymbolnpm install @orbat-mapper/point-symbols milsymbolimport { TacticalDraw } from "@orbat-mapper/tactical-draw";
import { milsymbolPointSymbols } from "@orbat-mapper/point-symbols";
import type { PointSymbol } from "@orbat-mapper/tactical-draw";
const pointSymbols = milsymbolPointSymbols({ maxCacheEntries: 1024 });
const td = new TacticalDraw(adapter, { pointSymbols });
const unit: PointSymbol = {
id: "unit-1",
kind: "point-symbol",
sidc: "130315003611010300000000000000",
position: [10.75, 59.91],
rotation: 0,
size: { value: 32, unit: "pixels" },
};
td.render([unit, controlMeasure]);
await td.editMany([unit, controlMeasure]);Draw a new symbol with the same draw() method as control measures. It commits on one click:
const { graphic } = await td.draw({ kind: "point-symbol", sidc: "130315003611010300000000000000" });Pixel-sized symbols stay fixed on screen; meter-sized symbols use the adapter's current resolution. Individual and group transforms preserve the stored unit. Rotation is measured clockwise in radians from geographic north.
The position comes from milsymbol's getAnchor(). For headquarters symbols, that is the end of the staff. Individual rotations and scales keep the anchor fixed, while a group transform moves it around the shared pivot.
Use the typed textAmplifiers, graphicModifiers, and style fields for common authoring needs. For lower-level renderer settings, use rendererOptions.milsymbol, milsymbolOptions, or resolveOptions(symbol). Canonical fields have the highest priority.
Capability options ​
milsymbolPointSymbols(options?) accepts:
| Option | Default | Description |
|---|---|---|
defaultSize | { value: 25, unit: "pixels" } | Size for drafts drawn without one. |
defaultRotation | 0 | Rotation for drafts drawn without one. |
maxCacheEntries | 1024 | Size of the shared LRU render cache. 0 disables caching. |
milsymbolOptions | none | milsymbol options applied to every symbol, below per-symbol settings. |
resolveOptions | none | Function that computes extra milsymbol options per symbol, below rendererOptions.milsymbol. |
For one-off rendering outside TacticalDraw, such as a legend or a map sheet, call renderPointSymbol(symbol). It returns the same SVG resource synchronously, without the cache.
Custom capabilities must return safe SVG
The bundled capability emits SVG produced by milsymbol. A custom PointSymbolCapability must return trusted or sanitized SVG, because the Leaflet adapter inserts it into the live DOM.
Symbol style ​
PointSymbolStyle supports colors, monoColor, opacity, overallOpacity, outlineWidth, fill, frame, and icon. opacity ranges from 0 to 1 and applies to the whole SVG, so overlapping parts do not become darker.
td.render([{ ...unit, style: { opacity: 0.4 } }]);Control measures expose the same field through a different render path; see Graphic opacity.
Cache and invalidation ​
Create the point-symbol capability once and reuse it. Its LRU cache shares canonical SVG resources across positions, rotations, sizes, TacticalDraw instances, and adapter layers. After changing global milsymbol configuration, invalidate the capability and render again.
invalidate() increments the capability's generation. TacticalDraw checks that value before reconciling the graphics layer and clears cached renders when it changes. Even unchanged graphic objects will then use fresh symbols.
// After a change of the global milsymbol configuration:
pointSymbols.invalidate(); // generation increments
td.render(graphics); // same objects, new symbolsA custom capability may omit generation, but then TacticalDraw cannot detect global renderer changes. Include the counter when your renderer has mutable global configuration.
Offset placement ​
Offset placement separates the authoritative position from the displayed body. Screen-relative placement keeps a CSS-pixel vector in viewport axes; ground placement keeps a geographic display position. Icon size remains independent.
const offsetUnit: PointSymbol = {
...unit,
offset: {
placement: { mode: "screen-relative", offset: [80, -100] },
connector: "auto",
},
};
td.render([offsetUnit]);
await td.edit(offsetUnit, {
modes: ["transform", "offset"],
onSession(session) {
if (!("offset" in session) || !session.offset) return;
// These commands preserve visible placement and participate in undo/cancel.
session.offset.setPlacement("ground");
session.offset.setConnector("auto");
// session.offset.align(); // Explicitly align placement for indicator-only.
// session.offset.reset(); // Clear all authored offset presentation.
},
});When editing a symbol with an existing offset, the default modes are ["transform", "offset"]. Symbols without an offset default to ["transform"]. Explicit modes and subsequent mode changes take precedence.
To temporarily activate offset placement with a key, opt in when constructing TacticalDraw:
const td = new TacticalDraw(adapter, {
pointSymbols: milsymbolPointSymbols(),
offsetDragModifier: "alt", // "alt" | "shift" | "ctrl" | "meta" | false
});
td.setOffsetDragModifier("shift"); // Also affects subsequent drags in an active edit.
await td.edit(unit, { offsetDragModifier: false }); // Disable for this edit.The shortcut is disabled by default. Hold the configured key before starting a body drag. It activates offset placement for that drag without changing session modes, and can create an offset on a unit that has none. Releasing or pressing a key mid-drag does not change the operation. Explicit offset mode still works without a key. Rotation, resizing, and delete mode retain their usual behavior.
In offset mode, dragging the body changes display placement. The actual-location handle moves position while preserving the displayed body and authored junction. The junction handle adjusts the indicator length along its permitted direction, keeps the connector on Auto, and saves the adjusted length. Transform handles still resize and rotate the body while keeping both locations fixed. Group transforms move actual and display locations together, including rotating/scaling screen-relative vectors.
auto selects an indicator when exactly aligned, otherwise an indicator plus extension. Body dragging snaps with 6px acquisition and 10px release thresholds. Auto uses the renderer's default indicator length until the junction is adjusted; then it uses the saved indicatorLength ratio at the current symbol size. Direct retains that manual length for returning to Auto. An aligned connector omits the extension without discarding its settings. Only Reset offset clears them.
direct is an explicit single connector without the indicator direction constraint. All valid symbols rendered by the built-in capability offer both connector options, including control measure points and circular surface units. Framed symbols use their frame attachment; unframed control measure points use their ordinary placement anchor for both indicators and Direct. Its availability does not assert doctrinal compliance for every supported symbology publication.
Set offset.anchorPadding to leave a gap at the symbol attachment end of the connector, measured along the line in fixed CSS pixels. It defaults to 0 and does not move the anchor, symbol body, or edit handles. Padding that consumes the whole connector hides the line. Map-sheet exports preserve the same gap.
Set offset.style to override the connector independently of the body:
offsetUnit.offset!.style = {
strokeColor: "#e63946",
opacity: 0.8,
strokeWidth: 3,
strokeDash: [8, 4],
};Each omitted property inherits the existing symbol styling: renderer frame color, symbol opacity, scaled renderer stroke width, and a solid line. Override width and dash/gap lengths use fixed CSS pixels, independent of symbol size and zoom. Use strokeDash: [] for solid, or omit it to inherit. These settings also apply to map-sheet exports.
Renderer capabilities expose optional PointSymbolRender.offset body and attachment metadata. Existing capabilities remain valid. Unsupported authored presentation falls back to ordinary placement at the actual location with unsupported-presentation status, retaining the saved offset for restoration. Map-sheet export uses the same layout and emits a warning for unsupported presentation. Screen-relative sheet offsets use 96 CSS pixels per inch; export never changes the source symbol.
The milsymbol capability retains ordinary HQ rendering and supplies a separate staff-free body for external connector composition. A per-instance numeric-zero compatibility shim suppresses the staff without changing milsymbol's global configuration. Ordinary rendering, including recursive custom symbol parts, retains its normal staff and registration.
Viewport-pinned placement, shared staffs, brackets, and automatic decluttering are outside this release. Leaflet refits after its native zoom animation settles; OpenLayers uses completed render frames, and MapLibre uses continuous view events unless its adapter was configured with viewChangeMode: "settle".
Try it in the preview app
Contributors can exercise offset placement in the repository's preview app: run pnpm --filter preview-app dev, open the TacticalDraw view, and choose Load demo scenario. It includes screen-relative and ground offsets, a circular surface unit, and several control-measure point symbols.
External rendering and export ​
Import resolvePointSymbolOffset and scalePointSymbolLayout from @orbat-mapper/tactical-draw. The public layout contract selects the ordinary resource or detached body, resolves inherited connector styling, and computes padding and indicator geometry. Exporters should use this result instead of selecting render.offset.body themselves.
const intrinsic = capability.render(unit);
const layout = resolvePointSymbolOffset(unit, intrinsic, projection);
if (layout) {
const output = scalePointSymbolLayout(layout, 25.4 / 96); // CSS pixels → mm
// Paint connector first: output.path and output.connectorStyle.
// Paint output.resource.svg at output.imageAnchor, registering its canonical
// resource.anchor, with output.scale, output.rotation and output.bodyOpacity.
}projection satisfies PointSymbolProjection: getPixelFromCoordinate, getBearing (clockwise radians), and getResolution (Web Mercator meters per CSS pixel, as returned by MapAdapter.getResolution()). Both resolvePointSymbolOffset() and pointSymbolFootprint() require only this forward projection contract, including for ground offsets. Editing helpers use PointSymbolView, which additionally permits inverse projection; ground placement requires it at runtime. Native map painting also requires inverse projection to turn displaced pixels into geographic features, and falls back to ordinary placement if that conversion is unavailable. Read-only footprints describe the projected presentation independently of that native painting fallback.
The layout returns null if the actual location cannot be projected. Unsupported offset presentations return ordinary geometry and resource with status: "unsupported-presentation"; inspect this status to report degraded output. resource contains the selected SVG, identity, dimensions, anchor and core rectangle in canonical SVG units. render is the selected renderer output. scale converts canonical units to CSS pixels, rotation is clockwise radians in the projection plane, and imageAnchor is where the resource anchor is painted. path is a separate connector polyline; an empty path means there is no connector to paint.
scalePointSymbolLayout(layout, factor) returns a new layout in output units. It scales all pixel positions, the body scale, connector width and dash lengths. Offsets and padding are already included in the geometry. Canonical resource metadata, rotation, direction vectors and opacity remain unchanged. The factor must be positive and finite. This is a uniform conversion about the projection origin; resolve layout in CSS pixels before converting, and do not scale authored symbols as well. The map-sheet exporter uses this same helper.
Fading the complete symbol ​
Use style.overallOpacity (0–1, default 1) to multiply both presentation parts:
- Body opacity:
(style.opacity ?? 1) * (style.overallOpacity ?? 1). - Connector opacity:
(offset.style.opacity ?? style.opacity ?? 1) * (style.overallOpacity ?? 1).
For example, body opacity 0.4, connector opacity 0.8, and overall opacity 0.5 produce effective opacities 0.2 and 0.4. Explicit zero is preserved. These effective values are exposed as bodyOpacity and connectorStyle.opacity and used by native adapters and map-sheet export. Apply them once; do not also multiply the exported result by overallOpacity. Alpha embedded in a connector color remains an additional factor. The multiplier does not change the SVG resource or its cache identity. This applies opacity to each part separately; it does not promise offscreen group compositing at body/connector overlaps.
Elevated billboard presentation ​
On MapLibre GL JS 6, billboard pins raise symbols above the map on screen-vertical or world-vertical stems, with terrain occlusion and per-view or per-unit styling. Pin settings are view preferences, stored separately from authored point symbols.