ธีม
2026-08-15 (late) — the org chart becomes a reporting chart
Archived from STATE.md, which had grown to 350 lines against a ~200 target. This is the reference for how /team is built after the ผังรวม session; the one-paragraph version that stayed in STATE.md points here.
Shipped this session, all deployed and verified from the served artifacts: migrations 0151 (two kinds), 0152 (ฝ่าย colour), 0153 (ระดับ). Commits 2b01b44 · cb11323 · 7c5cd26 · e3fe236 · 31ec4b3 · 6423d4c · f73a6a4.
What the owner asked for, in the order they asked
- "ฝ่าย / แผนก / ตำแหน่ง should be left only ฝ่าย and ตำแหน่ง" → 0151.
- "Navigate to role at that level first, then ฝ่าย will be under it" → read as ORDER, shipped as a sort. Wrong. They meant RANK, and said so twice more ("one line to อุปนายก then three lines to ฝ่าย PR, ComArt, IT"). See the write-up in
docs/mistakes/frontend-ui.md; the reusable half is that "under", from someone describing a drawing, means BELOW and not AFTER. - "every ฝ่าย got the same color … it's dizzy" → depth grading, rails removed.
- "make admin can custom the color also" → 0152.
- "ฝ่ายวิชาการ inside ฝ่ายรังสีเทคนิค shows different color" → derived colour is ROOT-ONLY.
- "without having to put Role สมาชิกฝ่าย IT inside หัวหน้าฝ่าย IT" → 0153.
The invariants, in full
1b. The public org chart (/team) — how it is built
FOUR views. รายการ + แผนผัง share ONE renderer and ONE markup; only CSS differs. The wrapper carries data-view, and the toggle flips it WITHOUT re-rendering so open ตำแหน่ง and scroll position survive. Scope every rule on [data-view=…], never on a width. แผนผัง fits 400 people via ONE SECTION PER ฝ่าย, BRANCH SIDEWAYS ONCE, and a BOUNDED wrapping row (flex-wrap alone did nothing — .org-tree is width: max-content; justify-content: safe center, because plain center makes the start-side overflow unreachable). The คณะกรรมการ grid is GONE on purpose: rank is position in the chart, not card size.
ผังองค์กร + ผังรวม share ONE SEPARATE renderer — src/js/org-graph.js, d3-org-chart (MIT) on a zoom/pan SVG canvas, differing ONLY in grouping. The face element both renderers draw lives in src/js/org-face.js. Why the library, the measured widths, the three portrait bugs: docs/state-archive/2026-08-15-org-chart-views.md.
THREE display rules the chart applies that the STORED tree does not (all in src/js/org-rung.js, applied once in org-chart.js's index() so all four views AND the search read one structure; guarded by org-rung.test.js):
chartParentage()— sub-ฝ่าย hang off the ฝ่าย's HEAD seat, not beside it. Reported twice: four lines out of ฝ่ายดิจิทัล said the อุปนายก and ฝ่าย PR/ComArt/IT were peers. First =position0 = the head. NOT applied when the parent is a ตำแหน่ง (หัวหน้าฝ่าย PR holds a seat AND ฝ่าย Media management; those are peers).team_nodes.parent_idis untouched.sortSiblings()— ตำแหน่ง before ฝ่าย, stable, sopositionstill orders within each group.- The "แสดงถึง" rungs are a KIND, not a depth — ฝ่ายหลัก / ฝ่ายย่อย / ตำแหน่ง / ทั้งหมด, measuring 14 / 136 / 290 / 298 cards. A number could not express it: depth 2 is a หัวหน้า in สำนักนายกฯ and a ฝ่าย in ฝ่ายดิจิทัล. A kind predicate is NOT ancestor-closed the way
depth <= nwas, soapplyRungwalks up; the ladder is asserted to be nested.
TWO KINDS ONLY — ฝ่าย and ตำแหน่ง (0151 folded 78 แผนก into ฝ่าย; all were containers). src/js/node-kind.js still reads a stray department as a ฝ่าย — old bundle, old export, hand-edited import. node-kind.test.js keeps the <select> and every writer at two. The CHECK constraint making department unwritable was deliberately NOT shipped with 0151 and is still owed — it is safe to add now that the new bundle is served.
COLOUR (0152). A DERIVED colour is a ROOT-ONLY answer — tintColor(node, isRoot), isRoot defaulting to FALSE so a forgetful caller inherits rather than guesses. Applied at every depth it made ฝ่ายวิชาการ inside ฝ่ายรังสีเทคนิค blue; 27 of the 29 name-matching non-root nodes hit their own root's colour BY COINCIDENCE, which is why it survived review. team_nodes.color, null = derive from the name via the shared src/js/dept-tint.js. Constrained to a hex literal in the DB and by isHexColor() in JS, because the value lands in style="--org-tint: …" on an anonymous page — dept-tint.test.js reads the regex OUT of the migration so the two cannot drift. Both read paths carry it: get_public_team_chart has a published-snapshot branch AND a live branch, and the CURRENT year reads the snapshot once published, so publishing would otherwise revert every colour. The 20 [data-tint="x"] CSS rules are gone; both renderers set --org-tint inline from tintColor().
Six things in the d3 renderer will still bite you, all guarded by org-graph-metrics.test.js:
- NOTHING inside a card may be
positioned. WebKit paints a positioned element in a<foreignObject>WITHOUT the ancestor SVG transform.getBoundingClientRect()reports the box CORRECT while this happens; only a decoded screenshot can see it (skills/drive-the-browser.md). initialExpandLevelis NOT the depth control — consumed once, then reset to 1 by the library. Visibility is_expandedon the data rows.frameChart()replacesfit()and inherits its obligations, including zeroingcenterG.sizesmust beportrait width × max zoom, and zoom must be capped.srcsetresolves ONCE from the LAYOUT size.- เต็มหน้าจอ is a CSS overlay, never the Fullscreen API — iOS only honours
requestFullscreen()on<video>. - d3 is dynamically imported — a static import put d3-zoom in the ENTRY bundle, +13.6 KB gz for everyone.
1c. ระดับ (team_nodes.tier, 0153) — rank without nesting
The tree means CONTAINMENT; tier means RANK. Seats on one tier draw on one row; tier k+1 hangs off the FIRST seat of the tier above (position 0 = the head). NULL means 1, so nothing was backfilled. A gap (1 and 3, no 2) closes rather than orphaning — rungs[i-1], never i-1.
The eight pre-existing seat-under-seat nestings were converted and the drawing did not change: tools/team0153-tier-parity.mjs runs the committed pre-migration snapshot (tools/fixtures/team-tree-before-0153.json) and the live tree through the SAME chartParentage() the page uses and compares the DRAWINGS. Deliberately NOT in npm run proofs — the snapshot is a moment, so it will legitimately go red once the tree is edited, and a proof that cries wolf teaches people to ignore proofs.
Nesting still works. This does not forbid a seat under a seat; it removes the need for one. chartParentage reads both, and the ระดับ control is hidden where nesting is already saying it.
1d. OPEN — asked, not yet answered
Nothing is outstanding from the org-chart work. Two things the owner has ruled on that a future session should not re-raise:
- The
mastergrants that reach สมาชิกฝ่าย IT are INTENTIONAL — confirmed again 2026-08-15 when the tier proposal cited them as a side effect. They are not an argument for anything. Do not raise them a third time. - แผนผัง and รายการ read the STORED tree; only the canvas views re-parent. The owner's asks have all been about ผังรวม. Do not "unify" the four views.
What a later session should know about the guards
org-rung.test.js(32) — the rungs, the re-parenting, the tier rules, the meta wording. Every assertion in it was falsified by reintroducing the bug before being committed; two fixtures had to be rewritten because they could not reach the branch they claimed to cover.dept-tint.test.js(13) — root-only derivation, the call-site check, and a differential that reads the hex regex OUT of 0152 rather than retyping it.node-kind.test.js(12) ·team-state-specificity.test.js(4).tools/team0153-tier-parity.mjs+tools/fixtures/team-tree-before-0153.json— one-shot, deliberately NOT innpm run proofs.