@orbat-mapper/tactical-notes
v0.2.0
Published
Map-agnostic Notes for tactical maps: content model, frame presets and browser-based rasterisation.
Readme
@orbat-mapper/tactical-notes
Map-agnostic Notes: framed boxes of wrapped text that a tactical map pins to the
ground. The package turns a note's Markdown content into HTML, lets the browser lay it
out, and rasterises the result through an SVG foreignObject (ADR-0067). The map, image
export and the tactical map sheet all use that raster, so line breaks agree between them
on one machine.
The browser handles bidi, shaping, line breaking and font fallback, so right-to-left and
complex scripts work. It has no dependencies and no map engine or tactical-draw
dependency. Rendering needs a browser; the content helpers don't.
Installation
pnpm add @orbat-mapper/tactical-notesUsage
import { createNoteCapability } from "@orbat-mapper/tactical-notes";
// No fonts are bundled. Notes default to Open Sans; omit `fonts` to use system fonts.
const notes = createNoteCapability({
fonts: {
"Open Sans": {
regular: "/fonts/OpenSans-Regular.ttf",
bold: "/fonts/OpenSans-Bold.ttf",
italic: "/fonts/OpenSans-Italic.ttf",
boldItalic: "/fonts/OpenSans-BoldItalic.ttf",
},
},
images, // optional, for Notes with images
});
const td = new TacticalDraw(adapter, { notes });
notes.subscribe(() => td.render(graphics));A Note looks like this:
const note: NoteModel = {
id: "orders",
width: 24, // em
frame: { preset: "plate" }, // optional
content: "# Orders\n\nHold the **ridge**.\n\n- Dig in\n- Report",
};frame and its preset are optional. Without them a Note uses the basic preset
(DEFAULT_NOTE_FRAME_PRESET): a rounded white box with no border or header band. Its text
is black or white, whichever contrasts with the background, so a background is all a basic
Note needs: frame: { tokens: { background: "#558b3a" } } gives white text.
A preset may leave out textColor and accentText; they then come from
contrastingText(background) and contrastingText(accent), which return "#ffffff" when
white has at least 3:1 contrast against the colour, else "#000000". Only basic does
this, so set textColor too when you change another preset's background.
Content
content is a string in a small Markdown subset. Every string is valid.
| Syntax | Result |
| --------------------------------------- | --------------------------------------------------- |
| # Title, ## Title | Heading. ### and deeper render as level 2. |
| Consecutive lines | One paragraph, with a line break at each new line. |
| A blank line | Separates blocks. |
| - a, * a, + a, 1. a | List, one level deep. Indented items flatten to it. |
| **bold**, __bold__ | Bold. |
| *italic*, _italic_ | Italic. Bold and italic nest. |
| <u>underline</u> | Underlined. Combines with bold and italic. |
| \* | The literal character. Works for ASCII punctuation. |
| a `code` span in other text | Its plain text. |
|  alone on its line | Image filling the content width, aspect ratio kept. |
Unclosed markers are literal text, and so is everything else, such as tables,
links, blockquotes, fences and other HTML; inline markers inside them still apply. A
break on a block's last line is ignored, and its \ stays literal. Colours come
from the frame, not the content.
The headingAlign frame token ("start", "center" or "end") aligns headings,
and bodyAlign aligns paragraphs. Lists stay at the start.
bodyStyle sets paragraphs and lists "regular", "italic", "light" (weight 300)
or "caps" (capitals); headings keep their own style. Light text uses the family's
light and lightItalic files when listed, else its regular ones.
headingScale, headingLetterSpacing and headingSmallCaps set their size and
spacing: a # heading is 1.5 em × headingScale and a ## 1.2 em × headingScale.
headingBold: false sets headings in regular weight; heading1Bold and heading2Bold
set one level, and heading1Color and heading2Color colour one level outside the
header band. paddingX and paddingY set the padding of one axis. These follow
headingBold, textColor and padding unless set, and overriding one of those shared
tokens resets a preset's more specific ones. A border with style: "double" draws two
rules, and "dashed" a dashed one. The friendly-solid and enemy-solid presets are
atlas tags: white text on the side's colour, tight top and bottom.
To put host data, such as a unit or file name, into content, escape it with
escapeNoteText(text). Otherwise 1. Prepare becomes a list and my_file_v2 can turn
italic. The result is one line, with whitespace collapsed, and stays plain text in a
paragraph, heading, list item or image alt text. noteImageLine(resourceId, alt)
builds an image line with its alt text escaped, and throws for a resource id the
syntax can't carry: empty, or holding whitespace or parentheses.
const content = [
`# ${escapeNoteText(unit.name)}`,
escapeNoteText(unit.remarks),
noteImageLine(resource.id, file.name),
].join("\n\n");noteImageResourceIds(note) lists the image resources a Note's content refers to,
once each, in reading order. A resource images can't resolve keeps the Note
"pending" in a capability, and rejects in renderCanvas. So does one that can't
decode, which a capability also reports through onError.
Rendering
createNoteCapability({ fonts, images }) is a note rasteriser for
@orbat-mapper/tactical-draw. resolve(note, pxPerEm) returns a PNG data URL raster, or
"pending" while the Note's fonts load, an image is unavailable, or its first raster is
being drawn. Drawing happens in the background: when it ends the capability bumps
generation and calls its subscribe listeners, so the host can render again. Meanwhile
a Note with an id keeps its previous raster, so zooming and editing don't flash the
placeholder.
Rasters are cached by content and density, so moving or rotating a Note reuses its raster.
measure(note) returns a Note's height in em synchronously, or undefined while its fonts
load. It lays the Note out without drawing a raster. TacticalDraw sizes the box from it,
so while a width drag rewraps the text the box already has its new height, and the last
raster is drawn at its own size from the top-left corner instead of being stretched.
Rasters draw one at a time, returning to the event loop in between so the map stays responsive, and a Note waiting in the queue draws only its latest request. Listeners are called at most about every 50 ms while a queue draws, and once when it ends.
notes.renderCanvas(note, { pxPerEm }) draws a Note at an explicit density, for export,
from the capability's fonts and images. It waits for fonts, applies no fade and no size
cap, and returns the canvas with the Note's exact heightEm. The map sheet's
prepareMapSheetNotes(request, { notes }) uses it, so printed Notes break their lines as
the map does. renderNoteCanvas(note, { fonts, images, pxPerEm }) does the same without a
capability.
Layout and fonts
Every Note is laid out at one fixed font size and only scaled afterwards, because changing the font size can change where lines break.
fonts lists font files by family. Each is a URL, a URL or an ArrayBuffer of TTF or
OTF bytes, fetched once when a Note first needs it. A missing face is synthesised by the
browser from the family's others, so regular alone works, and a family that isn't listed
falls back to the system's fonts. A CSS generic family, such as serif or system-ui,
names a system font and needs no file. For files that aren't at a URL, pass a FontProvider
instead: (family, weight, style) => Promise<ArrayBuffer | null>, where null means no
file for that face. A file that fails to load keeps its Notes pending and reports through
onError.
Presets don't choose a family. defaultFonts: { heading?, body? } sets the families of
Notes whose frame doesn't name its own fonts; a role left out keeps DEFAULT_NOTE_FONTS
(Open Sans), and an empty name throws. List the families' files in fonts.
renderNoteCanvas takes the same option, and resolveFrameTokens(frame, defaultFonts)
gives the families a Note will use. A Note without its own fonts takes on the defaults of
whichever host opens it, so name families in the frame when a Note must look the same
everywhere.
Font bytes are measured in the document and inlined into each raster, because an SVG image
can't reach the page's fonts. Without fonts, and for glyphs a font lacks, the system's
fonts are used. Those differ between browsers and machines, so a Note relying on them can
wrap differently, and be a different height, for another user. Supply fonts for the scripts
your users rely on if Notes must match between them. Note heights aren't stored.
Content and frame tokens are treated as untrusted: text is escaped, and a colour that isn't
a plain colour value paints nothing. Images must be self-contained data: URLs.
Text layout limits
Lines break where the browser breaks them, and a word wider than the line is split, with no hyphenation. Text in a Note can't be selected in a printed sheet or PDF.
