Documentation
¶
Overview ¶
Package graph defines flowgo's in-memory graph model and the .flowgo text-format parser/serializer.
The package is intentionally small: types with their JSON tags (which must match the wire format the editor consumes over /state and /save), Parse, and Serialize. Anything richer (validation, HTTP handlers, MCP tools) lives in the upstream binary or in downstream consumers.
Pure hex-lattice math for hexagon-mode boxes — the Go port of src/graph/hex.ts. Keep the two in lockstep: same constants, same axial conventions, same never-overlap invariant. The editor uses the TS side for live drag-snap; this side lets the MCP tools honour the identical contract when an agent (not a pointer) places hexes.
Hexagons are uniform, flat-top, and never resizable: HexW × HexH CSS pixels. The lattice is implicit and LOCAL: there is no world origin — snapping anchors the lattice at the centre of the nearest existing hexagon. Axial coordinates (q, r) follow the redblobgames flat-top convention.
Key invariant: HexSnapRadius > HexW. Inside the radius a hex snaps onto a free cell (so it cannot overlap); outside it, its centre is more than a full hex width from every other centre, which makes overlap physically impossible. Either way two hexagons never overlap.
Index ¶
- Constants
- func HexesOverlap(a, b HexPoint) bool
- func NormalizeLabel(raw string) string
- func Serialize(g Graph) string
- func Validate(g Graph) []error
- func ValidateWritable(g Graph) []error
- func WorldToAxial(origin HexPoint, p HexPoint) (q, r float64)
- type Axial
- type Box
- type Edge
- type Graph
- type HexPoint
- type Image
- type Line
- type NamedMap
- type Stroke
- type Text
Constants ¶
const ( // HexW / HexH: fixed footprint of every hexagon (flat-top; // H = W·√3/2 rounded to a whole pixel — see hex.ts). HexW = 240.0 HexH = 208.0 // Centre-to-centre lattice steps: +q is 0.75·W right and half a // row down; +r is one full row straight down. HexCol = HexW * 0.75 // 180 HexRow = HexH // 208 // Magnetic range: snapping engages when a proposed centre comes // within this distance of another hexagon's centre. HexSnapRadius = HexW * 1.01 // 242.4 )
const MaxLabelLen = 500
MaxLabelLen mirrors MAX_LABEL_LEN in the JS editor. Box and text labels are clamped to this length on every entry path that produces new graph data — JS finish(), MCP add_box / update_box / add_text, any future ingestion. The validator flags anything longer in already-stored data.
Variables ¶
This section is empty.
Functions ¶
func HexesOverlap ¶ added in v0.3.19
HexesOverlap reports whether two fixed-size flat-top hexagons centred at a and b overlap. Closed-form separating-axis result: the Minkowski sum of the hex with itself is the same hexagon scaled 2×, so the centres overlap iff their difference lies strictly inside that 2× hexagon. Exact edge-to-edge contact (adjacent lattice cells) counts as NOT overlapping.
func NormalizeLabel ¶ added in v0.0.17
NormalizeLabel collapses every run of non-newline whitespace to a single space, trims each line, drops fully-blank leading / trailing lines, and hard-caps to MaxLabelLen. Newlines are preserved — they render as hard line breaks in the editor and round-trip through the .flowgo file as a `\n` escape inside a quoted label. Mirrors normalizeLabel() in src/graph/label.ts.
The .flowgo text format is line-based, so a literal newline inside a label would corrupt the file; the serializer in graph.go handles the escape and the tokenizer decodes it on the way back in.
func Serialize ¶
Serialize emits the .flowgo text format. Empty maps are dropped — they get re-created on demand if a consumer navigates back to them.
When g.Version is non-empty, a `version <semver>` directive is emitted as the first line so consumers (older flowgo binaries, tools) can detect what wrote the file. Callers stamp the field at save time.
func Validate ¶ added in v0.1.3
Validate runs semantic checks the .flowgo parser doesn't perform. Returns every violation it finds rather than stopping at the first one, so a single CI run surfaces all problems at once.
It is a superset of ValidateWritable: everything that would corrupt the file, plus complaints about graphs that persist perfectly well but don't mean anything sensible (orphaned submaps, edges pointing at deleted nodes, out-of-range styles).
func ValidateWritable ¶ added in v0.3.7
ValidateWritable returns only the violations that would damage the .flowgo file itself — fields whose bytes do not survive a Serialize → Parse round-trip. Every path that writes a caller- supplied graph to disk must run this first.
The distinction from Validate matters: a mid-edit document can legitimately fail Validate (a submap outliving the node it hung off, an edge whose target was just deleted) while serializing and re-parsing perfectly. Gating saves on the full validator would lock the editor out of persisting documents it is allowed to produce, which is a worse failure than the corruption being prevented. This subset only rejects input that cannot be written down at all.
The invariant it buys: for any g with no ValidateWritable errors, Parse(Serialize(g)) succeeds and returns the same elements.
func WorldToAxial ¶ added in v0.3.19
WorldToAxial converts a world point to fractional axial coordinates on the lattice anchored at origin. Fractional on purpose: feed through AxialRound to get the containing cell.
Types ¶
type Axial ¶ added in v0.3.19
Axial is a lattice cell in axial coordinates.
func AxialRound ¶ added in v0.3.19
AxialRound rounds fractional axial coordinates to the nearest cell via cube rounding: round all three cube coords, then fix the one with the largest rounding error so q + r + s stays 0. Plain independent rounding picks the wrong cell near boundaries.
func NearestCell ¶ added in v0.3.19
NearestCell returns the lattice cell nearest to world point p on the lattice anchored at origin.
func NearestFreeCell ¶ added in v0.3.19
NearestFreeCell finds the free cell nearest to world point p on the lattice anchored at origin. A cell is free when a hexagon centred there would overlap none of the occupied centres. Searches outward ring by ring up to maxRings; within the first ring with any free cell, picks the one closest to p. Returns (Axial{}, false) when every cell inside maxRings is blocked.
type Box ¶
type Box struct {
ID string `json:"id"`
Label string `json:"label"`
X float64 `json:"x"`
Y float64 `json:"y"`
Palette int `json:"palette,omitempty"`
Font int `json:"font,omitempty"`
// Anchor marks this node as the map-level recenter target. At most
// one node per map carries Anchor=true; the parser/serializer enforce
// the invariant. Persisted in the .flowgo text format as a separate
// per-map `anchor <id>` directive rather than a positional token.
Anchor bool `json:"anchor,omitempty"`
// W/H, when both > 0, pin the node to an explicit on-canvas size in
// data pixels (the user resized it in the editor). Zero means
// auto-size: the node hugs its label like it always has. Persisted
// as a separate `nodesize <id> <w> <h>` directive (legacy spelling
// `boxsize` still parses) — the positional slots on the `node`
// line are all claimed by back-compat baggage.
// Ignored for special shapes (Shape!=0), which have fixed sizes.
W float64 `json:"w,omitempty"`
H float64 `json:"h,omitempty"`
// Shape selects the render silhouette: 0 (default) is the classic
// auto-sized rectangle, 1 hexagon, 2 circle, 3 triangle (all fixed
// uniform size, never resizable; hexagons additionally lattice-
// snap). 4-9 are reserved. Persisted as a separate
// `nodeshape <id> <shape>` directive after the node block (legacy
// spelling `boxshape` still parses) — the positional slots on the
// `node` line are all claimed by back-compat baggage (mirrors the
// linestyle precedent for lines).
Shape int `json:"shape,omitempty"`
}
Box is a node on a map. JSON tags are part of the public contract: they're consumed by the editor and any other process that exchanges graphs as JSON. (The Go type keeps its historical Box name — the text format's canonical directive is `node`, with `box` as the deprecated legacy spelling; renaming the public type would break downstream consumers for no wire-level gain.)
The `.flowgo` text format keeps a vestigial "sides" slot between the y coordinate and the palette token (always emitted as 4) so old node directives like `node b1 hi 0 0 3 5` still parse positionally — the polygon feature is gone, but the wire layout is preserved.
type Edge ¶
type Edge struct {
From string `json:"from"`
FromHandle string `json:"fromHandle,omitempty"`
To string `json:"to"`
ToHandle string `json:"toHandle,omitempty"`
Palette int `json:"palette,omitempty"`
// Label is the text drawn at the edge midpoint — "depends on",
// "triggers", "owns". Empty means unlabelled.
//
// Persisted as the FIFTH positional token on the `edge` line,
// after the palette: `edge <from> <to> <palette> <label>`. It has
// to go after, not before: slot 4 has been the optional palette
// since the format existed, and Parse reads it with strconv.Atoi,
// so a label there would either be misread as a palette (label
// "3") or hard-error every existing reader. A label therefore
// forces a palette token to be emitted; when the edge has no
// palette of its own the default sentinel `1` fills the slot,
// exactly like the `line` directive does to park its mid
// coordinates in a stable position. Parse ignores palette 1.
//
// Unlabelled edges emit no extra token at all, so every document
// written before edge labels existed keeps its exact bytes.
//
// Compatibility (measured against 0.3.12, not assumed): older
// binaries do NOT reject the five-token line — their edge case
// stops reading after the palette — so an old flowgo opens a
// labelled map fine. It drops the labels when it next WRITES the
// file. Gentler than the `defaultshape` break in #208, but still
// one-way: don't round-trip a labelled map through an old binary.
//
// Unlike a node, an edge has no id, and the .flowgo format does
// not stop a hand-written file from carrying two `edge a b` lines
// — so a side-table directive (the `nodesize` / `nodeshape`
// pattern) could not say WHICH edge it labelled. The positional
// token has no such ambiguity.
Label string `json:"label,omitempty"`
}
Edge connects two nodes within the same map.
type Graph ¶
type Graph struct {
Version string `json:"version,omitempty"`
Maps []NamedMap `json:"maps"`
// DefaultShape records the shape a canvas double-click creates in
// this document: 0 (absent) rectangle, 1 hexagon, 2 circle,
// 3 triangle. Persisted as a document-level `defaultshape <n>`
// directive right after `version`; zero is never emitted. The
// legacy `hexagons on` directive still parses (as DefaultShape=1)
// but is no longer written — the per-browser hexagon setting it
// backed was retired in favour of this per-file default.
DefaultShape int `json:"defaultShape,omitempty"`
}
Graph is the full document — every map keyed by its path.
Version records the flowgo binary version that last wrote this graph. It's stamped at save time and surfaced for tools that need to gate behaviour on the writer's version. Empty Version means the file pre-dates the directive (older flowgo) and should be treated as "unknown".
type HexPoint ¶ added in v0.3.19
HexPoint is a world-space point (a hexagon centre, mostly).
func AxialToWorld ¶ added in v0.3.19
AxialToWorld converts a cell to its world centre, for a lattice anchored at origin (itself cell (0,0)).
func SettleHexCenters ¶ added in v0.3.19
SettleHexCenters is the invariant repair for centres that arrived without live snapping (raw imports, set_state): walk the centres in order and relocate any hex that overlaps an earlier (already settled) one to the nearest free cell on the lattice anchored at the hex it collided with. Returns a new slice; non-colliding centres pass through untouched.
Unlike the magnetic snap, repair must not give up just because the neighbourhood is crowded — a stress import can stack hexes inside a blob far wider than the snap's 6-ring window — so the search depth scales with the population (settleRings).
func SnapHexCenter ¶ added in v0.3.19
SnapHexCenter is the magnetic snap: given a proposed hex centre and the centres of every OTHER hexagon on the map, returns the snapped centre and true — or false when the hex is out of magnetic range (or no nearby cell is free) and should stay exactly where it was proposed. The lattice is anchored at the nearest other hex, so the snapped hex always lands flush against it.
type Image ¶ added in v0.1.8
type Image struct {
ID string `json:"id"`
Src string `json:"src"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
Image is a raster asset placed on a map. Src is a path relative to the .flowgo file (e.g. "flowgo-media/<hash>.png"); the binary lives in the flowgo-media/ sibling folder, never inline in the text file. Width/Height are the on-canvas display size in data pixels.
type Line ¶
type Line struct {
ID string `json:"id"`
X1 float64 `json:"x1"`
Y1 float64 `json:"y1"`
X2 float64 `json:"x2"`
Y2 float64 `json:"y2"`
Palette int `json:"palette,omitempty"`
Style int `json:"style,omitempty"`
Mids [][]float64 `json:"mids,omitempty"`
}
Line is a static segment that runs through its endpoints and any intermediate Mid control points. Style governs how each pair of consecutive points is drawn: 1 (or 0/unset) renders straight segments (sharp polyline), 2 renders a smooth quadratic-bezier chain, 3 renders right-angle elbows (orthogonal).
type NamedMap ¶
type NamedMap struct {
Path string `json:"path"`
Boxes []Box `json:"boxes"`
Edges []Edge `json:"edges"`
Texts []Text `json:"texts,omitempty"`
Lines []Line `json:"lines,omitempty"`
Strokes []Stroke `json:"strokes,omitempty"`
Images []Image `json:"images,omitempty"`
}
NamedMap is one canvas at a given path. Submap paths are slash- separated node ids: "/A/B" hangs off node A on "/" and node B on "/A".