tilemon 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -0
- package/board.js +346 -0
- package/dashboard.html +67 -0
- package/package.json +14 -0
- package/server.mjs +164 -0
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Tilemon
|
|
2
|
+
|
|
3
|
+
A priority board where **importance is space**. The canvas is fixed, so making one
|
|
4
|
+
thing bigger shrinks everything else — a zero-sum map that won't let you pretend ten
|
|
5
|
+
things are all urgent. Hand tasks to your agents; when one gets stuck, it glows.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npx tilemon ./state.json
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
…boots a local server, serves the board at `http://localhost:4000`, and reads/writes
|
|
12
|
+
your JSON file. Point an always-on monitor at it. Agents flag status over HTTP; you own
|
|
13
|
+
the weights by dragging tiles.
|
|
14
|
+
|
|
15
|
+
## The idea
|
|
16
|
+
|
|
17
|
+
- **Area = importance.** A tile's on-screen area is its share of attention. The total
|
|
18
|
+
never grows, so weighting one thing up *takes* space from its siblings. You spend
|
|
19
|
+
importance like a budget.
|
|
20
|
+
- **Weight is yours; status is the agent's.** You set importance by dragging (or the
|
|
21
|
+
size slider), deliberately. Agents set status — `todo → in_progress → blocked → done`.
|
|
22
|
+
- **Any item can carry a status, at any depth.** It's a uniformly recursive tree — an
|
|
23
|
+
item may contain items *and* hold its own status. A whole group can be `blocked` (the
|
|
24
|
+
branch is stuck) without lying about a child.
|
|
25
|
+
- **Status renders as heat.** `blocked` glows; `done` drops off the board. An item's own
|
|
26
|
+
status sets a heat *floor*, and heat also rolls up area-weighted from its contents — so
|
|
27
|
+
a stuck thing deep in a group makes the whole group glow, visible from across the room.
|
|
28
|
+
- **`done` is reversible.** Finished items drop off so the board shows live work, but the
|
|
29
|
+
**done** toggle brings them back (dimmed) so you can re-open one — hiding never means
|
|
30
|
+
losing.
|
|
31
|
+
|
|
32
|
+
## How agents update it
|
|
33
|
+
|
|
34
|
+
One POST. It can *only* change an item's status — never your weights or structure, by
|
|
35
|
+
construction (it's a different endpoint with no access to weight).
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
node examples/flag.mjs build-office.design-office-in-sketchup blocked
|
|
39
|
+
# or directly:
|
|
40
|
+
curl -X POST http://localhost:4000/api/status \
|
|
41
|
+
-H 'content-type: application/json' \
|
|
42
|
+
-d '{"path":"build-office.design-office-in-sketchup","status":"blocked"}'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## File format
|
|
46
|
+
|
|
47
|
+
```jsonc
|
|
48
|
+
{
|
|
49
|
+
"name": "Priorities",
|
|
50
|
+
"children": [
|
|
51
|
+
{ "id": "work", "name": "Work", "weight": 2, "children": [
|
|
52
|
+
{ "id": "ship", "name": "Ship v2", "weight": 3, "status": "in_progress" }
|
|
53
|
+
]}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Any node may carry a `status`; a node with children also rolls up heat from them (its own
|
|
59
|
+
status sets the floor). `id` is a stable string agents address by (dotted: `work.ship`);
|
|
60
|
+
`name` can change freely. Edit the file directly and the board live-updates — the server
|
|
61
|
+
watches it.
|
|
62
|
+
|
|
63
|
+
## Routes
|
|
64
|
+
|
|
65
|
+
| Route | Who | Does |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `GET /` | — | the board |
|
|
68
|
+
| `GET /api/state` | — | current tree |
|
|
69
|
+
| `GET /api/events` | — | Server-Sent Events; `change` on any write |
|
|
70
|
+
| `POST /api/status` | **agents** | `{path, status}` — status only, any node |
|
|
71
|
+
| `POST /api/weight` | **you / UI** | `{path, weight}` — weight only |
|
|
72
|
+
|
|
73
|
+
Set `TILEMON_TOKEN` to require `Authorization: Bearer <token>` on the write routes
|
|
74
|
+
before exposing the port beyond a trusted network.
|
|
75
|
+
|
|
76
|
+
## The renderer is reusable
|
|
77
|
+
|
|
78
|
+
`board.js` is a framework-agnostic ES module that owns no data — you hand it a tree and
|
|
79
|
+
two callbacks:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
import { mount } from 'tilemon/board.js';
|
|
83
|
+
const board = mount(boardEl, controlsEl, {
|
|
84
|
+
state,
|
|
85
|
+
onWeightChange: (path, weight) => { /* persist however you like */ },
|
|
86
|
+
onStatusChange: (path, status) => { /* persist however you like */ },
|
|
87
|
+
});
|
|
88
|
+
board.update(newState); // re-render; drill level + selection preserved
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The npx tool wires those callbacks to `POST`. A hosted app would wire the same callbacks
|
|
92
|
+
to a database — the renderer doesn't change. See [`SPEC.md`](./SPEC.md) for the full
|
|
93
|
+
design, architecture, and roadmap.
|
|
94
|
+
|
|
95
|
+
## Develop
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
node server.mjs ./state.sample.json # boot against the sample
|
|
99
|
+
npm test # headless renderer checks (no browser needed)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
MIT.
|
package/board.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// board.js — Tilemon renderer. Framework-agnostic, zero dependencies, ESM.
|
|
2
|
+
//
|
|
3
|
+
// import { mount } from './board.js'
|
|
4
|
+
// const board = mount(boardEl, controlsEl, {
|
|
5
|
+
// state, // the tree (see SPEC §4.1)
|
|
6
|
+
// onWeightChange: (path, weight) => {}, // human resized a node -> persist
|
|
7
|
+
// onStatusChange: (path, status) => {}, // a leaf's status changed -> persist
|
|
8
|
+
// })
|
|
9
|
+
// board.update(newState) // re-render from fresh state (drill/selection preserved)
|
|
10
|
+
//
|
|
11
|
+
// The renderer owns NO storage. It announces *what changed* via callbacks; the host
|
|
12
|
+
// decides where that goes (a file via POST in the npx tool, a DB in the hosted app).
|
|
13
|
+
// That seam is the whole reason this is a separate module — see SPEC §3.
|
|
14
|
+
//
|
|
15
|
+
// Data model: a node is { id, name, weight, children } (parent) or
|
|
16
|
+
// { id, name, weight, status } (leaf). status ∈ todo|in_progress|blocked|done.
|
|
17
|
+
// `done` leaves drop off the board; status maps to heat for colour; heat rolls up
|
|
18
|
+
// area-weighted through parents. Weight is the node's share vs. its siblings.
|
|
19
|
+
|
|
20
|
+
const PAD = 5, HEADER = 22, INSET = 2;
|
|
21
|
+
const STATUS_HEAT = { todo: 0, in_progress: 0.5, blocked: 1 }; // `done` => dropped, no heat
|
|
22
|
+
const STATUSES = ['todo', 'in_progress', 'blocked', 'done'];
|
|
23
|
+
const DONE_COLOR = 'rgb(74,92,58)'; // muted sage — "complete", off the heat ramp, fits the warm-dark palette
|
|
24
|
+
|
|
25
|
+
const STYLE = `
|
|
26
|
+
.tlm-board{position:relative;width:100%;height:100%;overflow:hidden;background:var(--tlm-bg,#14120D);touch-action:none}
|
|
27
|
+
.tlm-board .tile{position:absolute;border-radius:5px;overflow:hidden;cursor:grab;user-select:none;outline:0 solid transparent;
|
|
28
|
+
transition:left .18s ease,top .18s ease,width .18s ease,height .18s ease,background .25s ease,opacity .2s ease}
|
|
29
|
+
.tlm-board.nodrag .tile{transition:background .25s ease}
|
|
30
|
+
.tlm-board .tile:active{cursor:grabbing}
|
|
31
|
+
.tlm-board .tile.sel{outline:2px solid var(--tlm-gold,#E8C56A);outline-offset:-2px;z-index:600!important}
|
|
32
|
+
.tlm-board .tile.armed{outline:2px dashed var(--tlm-gold,#E8C56A);outline-offset:-2px;z-index:600!important}
|
|
33
|
+
.tlm-board .tile .hd{position:absolute;top:0;left:0;right:0;height:22px;display:flex;align-items:center;
|
|
34
|
+
justify-content:space-between;padding:0 8px;gap:6px;pointer-events:none}
|
|
35
|
+
.tlm-board .tile .leaf{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
|
36
|
+
text-align:center;padding:6px;gap:3px;pointer-events:none}
|
|
37
|
+
.tlm-board .nm{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
|
38
|
+
.tlm-board .wt{font-family:"Space Mono",monospace;font-size:10.5px;opacity:.6;white-space:nowrap}
|
|
39
|
+
.tlm-board .tile.done{opacity:.6}
|
|
40
|
+
.tlm-board .tile.hot{animation:tlm-pulse 1.7s ease-in-out infinite}
|
|
41
|
+
@keyframes tlm-pulse{0%,100%{filter:brightness(1)}50%{filter:brightness(1.22)}}
|
|
42
|
+
@media (prefers-reduced-motion:reduce){.tlm-board .tile.hot{animation:none}}
|
|
43
|
+
.tlm-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-height:30px}
|
|
44
|
+
.tlm-controls .crumb{font-size:14px;color:var(--tlm-dim,#9A9182);display:flex;align-items:center;gap:6px;flex:1;min-width:180px}
|
|
45
|
+
.tlm-controls .crumb b{color:var(--tlm-ink,#ECE7DA);font-weight:500}
|
|
46
|
+
.tlm-controls .crumb span.c{cursor:pointer}.tlm-controls .crumb span.c:hover{color:var(--tlm-gold,#E8C56A)}
|
|
47
|
+
.tlm-controls .crumb .sep{color:var(--tlm-line,#2A2820)}
|
|
48
|
+
.tlm-controls button,.tlm-controls .tog{font-family:"Space Mono",monospace;font-size:11.5px;background:var(--tlm-panel,#1C1A14);
|
|
49
|
+
color:var(--tlm-ink,#ECE7DA);border:1px solid var(--tlm-line,#2A2820);border-radius:6px;padding:6px 9px;cursor:pointer;
|
|
50
|
+
transition:border-color .15s,color .15s,opacity .15s}
|
|
51
|
+
.tlm-controls button:hover:not(:disabled){border-color:var(--tlm-gold,#E8C56A);color:var(--tlm-gold,#E8C56A)}
|
|
52
|
+
.tlm-controls button:disabled{opacity:.32;cursor:default}
|
|
53
|
+
.tlm-controls button.on{border-color:var(--tlm-gold,#E8C56A);color:var(--tlm-gold,#E8C56A)}
|
|
54
|
+
.tlm-controls .grp{display:flex;gap:4px;align-items:center}
|
|
55
|
+
.tlm-controls .grp .lbl{font-family:"Space Mono",monospace;font-size:10.5px;color:var(--tlm-dim,#9A9182);margin-right:2px}
|
|
56
|
+
.tlm-sizebar{display:flex;align-items:center;gap:10px;background:var(--tlm-panel,#1C1A14);border:1px solid var(--tlm-line,#2A2820);
|
|
57
|
+
border-radius:8px;padding:7px 12px;font-family:"Space Mono",monospace;font-size:11.5px;color:var(--tlm-dim,#9A9182);width:100%}
|
|
58
|
+
.tlm-sizebar b{color:var(--tlm-ink,#ECE7DA);font-weight:400}
|
|
59
|
+
.tlm-sizebar input[type=range]{flex:1;min-width:120px;accent-color:var(--tlm-gold,#E8C56A)}
|
|
60
|
+
.tlm-sizebar .pct{color:var(--tlm-gold,#E8C56A);min-width:38px;text-align:right}
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
function injectStyle(doc) {
|
|
64
|
+
if (doc.getElementById('tilemon-style')) return;
|
|
65
|
+
const s = doc.createElement('style');
|
|
66
|
+
s.id = 'tilemon-style';
|
|
67
|
+
s.textContent = STYLE;
|
|
68
|
+
doc.head.appendChild(s);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function mount(boardEl, controlsEl, opts = {}) {
|
|
72
|
+
const onWeightChange = opts.onWeightChange || (() => {});
|
|
73
|
+
const onStatusChange = opts.onStatusChange || (() => {});
|
|
74
|
+
injectStyle(boardEl.ownerDocument);
|
|
75
|
+
boardEl.classList.add('tlm-board');
|
|
76
|
+
if (controlsEl) controlsEl.classList.add('tlm-controls');
|
|
77
|
+
|
|
78
|
+
let srcState = opts.state || { name: 'Priorities', children: [] };
|
|
79
|
+
let root, viewRoot;
|
|
80
|
+
let viewRootId = null, selId = null, showWeights = false, showDone = !!opts.showDone;
|
|
81
|
+
let tileEls = {}, drag = null, freeze = false;
|
|
82
|
+
|
|
83
|
+
// ---- derive the working tree from source state ----
|
|
84
|
+
// Any node (at any depth) may carry a status — the tree is uniformly recursive, not
|
|
85
|
+
// leaf-only. A node's own status maps to heat and acts as a *floor* (see calcHeat);
|
|
86
|
+
// `done` at any level drops that node and its whole subtree, unless `showDone` is on
|
|
87
|
+
// (then it renders dimmed, so `done` is reversible from the board, not a trapdoor).
|
|
88
|
+
// Stable ids are preserved so callback paths address the real underlying nodes.
|
|
89
|
+
const num = (v, d) => { const x = Number(v); return Number.isFinite(x) && x > 0 ? x : d; };
|
|
90
|
+
function clone(n) {
|
|
91
|
+
const isDone = n.status === 'done';
|
|
92
|
+
if (isDone && !showDone) return null; // done node (any level) drops its subtree
|
|
93
|
+
const ownHeat = STATUS_HEAT[n.status] ?? 0; // done -> 0 (calm); shown dimmed via _done
|
|
94
|
+
const kids = n.children;
|
|
95
|
+
if (kids && kids.length) {
|
|
96
|
+
const cc = [];
|
|
97
|
+
for (const k of kids) { const c = clone(k); if (c) cc.push(c); }
|
|
98
|
+
if (cc.length) return { id: n.id, name: n.name, weight: num(n.weight, 1),
|
|
99
|
+
status: n.status, heat: ownHeat, _done: isDone, children: cc };
|
|
100
|
+
// every child was dropped (all done, hidden): keep this node as a single tile if it
|
|
101
|
+
// carries its own status, else the empty container is effectively done — drop it.
|
|
102
|
+
if (n.status && !isDone) return { id: n.id, name: n.name, weight: num(n.weight, 1),
|
|
103
|
+
status: n.status, heat: ownHeat, children: null };
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return { id: n.id, name: n.name, weight: num(n.weight, 1),
|
|
107
|
+
status: n.status || 'todo', heat: ownHeat, _done: isDone, children: null };
|
|
108
|
+
}
|
|
109
|
+
function buildWorking(src) {
|
|
110
|
+
const top = [];
|
|
111
|
+
for (const k of (src.children || [])) { const c = clone(k); if (c) top.push(c); }
|
|
112
|
+
return { id: src.id, name: src.name || 'Priorities', weight: num(src.weight, 1), children: top };
|
|
113
|
+
}
|
|
114
|
+
// resolve a dotted-id path back to the node in the SOURCE tree (so local edits mirror
|
|
115
|
+
// into srcState and survive a rebuild) — mirrors server.mjs resolvePath.
|
|
116
|
+
function resolveInSrc(path) {
|
|
117
|
+
let node = srcState;
|
|
118
|
+
for (const part of path.split('.')) { const k = (node.children || []).find(c => c.id === part); if (!k) return null; node = k; }
|
|
119
|
+
return node;
|
|
120
|
+
}
|
|
121
|
+
function rebuild() {
|
|
122
|
+
root = buildWorking(srcState);
|
|
123
|
+
viewRoot = (viewRootId && find(root, viewRootId)) || root;
|
|
124
|
+
viewRootId = viewRoot === root ? null : viewRoot.id;
|
|
125
|
+
if (selId && !find(root, selId)) selId = null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- squarified treemap (Bruls/Huizing/van Wijk) — proven, unchanged ----
|
|
129
|
+
function worstAspect(row, rowArea, side) {
|
|
130
|
+
const thick = rowArea / side; let worst = 1;
|
|
131
|
+
for (const it of row) { const len = (it.area / rowArea) * side; const r = Math.max(thick / len, len / thick); if (r > worst) worst = r; }
|
|
132
|
+
return worst;
|
|
133
|
+
}
|
|
134
|
+
function squarify(items, x, y, w, h, out) {
|
|
135
|
+
const rest = items.slice();
|
|
136
|
+
while (rest.length) {
|
|
137
|
+
const side = Math.min(w, h); let row = [], rowArea = 0, worst = Infinity;
|
|
138
|
+
while (rest.length) {
|
|
139
|
+
const it = rest[0], na = rowArea + it.area, nw = worstAspect(row.concat(it), na, side);
|
|
140
|
+
if (row.length === 0 || nw <= worst) { row.push(rest.shift()); rowArea = na; worst = nw; } else break;
|
|
141
|
+
}
|
|
142
|
+
let thick = rowArea / side;
|
|
143
|
+
if (w >= h) { thick = Math.min(thick, w); let cy = y;
|
|
144
|
+
for (const it of row) { const ih = (it.area / rowArea) * h; out.push({ node: it.node, x, y: cy, w: thick, h: ih }); cy += ih; }
|
|
145
|
+
x += thick; w -= thick;
|
|
146
|
+
} else { thick = Math.min(thick, h); let cx = x;
|
|
147
|
+
for (const it of row) { const iw = (it.area / rowArea) * w; out.push({ node: it.node, x: cx, y, w: iw, h: thick }); cx += iw; }
|
|
148
|
+
y += thick; h -= thick;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function layout(node, x, y, w, h, depth, isView) {
|
|
153
|
+
node._rect = { x, y, w, h, depth };
|
|
154
|
+
const ch = node.children; if (!ch || !ch.length) return;
|
|
155
|
+
const header = isView ? 0 : HEADER;
|
|
156
|
+
const ix = x + PAD, iy = y + PAD + header, iw = w - 2 * PAD, ih = h - 2 * PAD - header;
|
|
157
|
+
if (iw < 4 || ih < 4) { ch.forEach(c => { c._parent = node; layout(c, x, y, 0, 0, depth + 1, false); }); return; }
|
|
158
|
+
const total = ch.reduce((s, c) => s + Math.max(c.weight, 1e-6), 0), area = iw * ih;
|
|
159
|
+
const items = ch.map(c => ({ node: c, area: area * (Math.max(c.weight, 1e-6) / total) }));
|
|
160
|
+
if (freeze) items.sort((a, b) => (a.node._ord == null ? 0 : a.node._ord) - (b.node._ord == null ? 0 : b.node._ord));
|
|
161
|
+
else items.sort((a, b) => b.area - a.area || String(a.node.id).localeCompare(String(b.node.id)));
|
|
162
|
+
items.forEach((it, i) => { it.node._ord = i; });
|
|
163
|
+
const out = []; squarify(items, ix, iy, iw, ih, out);
|
|
164
|
+
for (const o of out) { o.node._parent = node; layout(o.node, o.x, o.y, o.w, o.h, depth + 1, false); }
|
|
165
|
+
}
|
|
166
|
+
function calcHeat(node) {
|
|
167
|
+
const own = node.heat || 0; // this node's own status-heat (0 if none)
|
|
168
|
+
const ch = node.children;
|
|
169
|
+
if (!ch || !ch.length) { node._heat = own; return node._heat; }
|
|
170
|
+
let a = 0, s = 0;
|
|
171
|
+
for (const c of ch) { const ca = Math.max(c._rect.w * c._rect.h, 1e-4), hc = calcHeat(c); a += ca; s += hc * ca; }
|
|
172
|
+
const rollup = a ? s / a : 0;
|
|
173
|
+
node._heat = Math.max(own, rollup); // own status is a floor; children can only push hotter
|
|
174
|
+
return node._heat;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- colour ----
|
|
178
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
179
|
+
function heatColor(h) {
|
|
180
|
+
h = Math.max(0, Math.min(1, h));
|
|
181
|
+
const st = [[0, [50, 48, 41]], [0.35, [92, 74, 46]], [0.7, [176, 106, 31]], [1, [216, 67, 46]]];
|
|
182
|
+
for (let i = 0; i < st.length - 1; i++) {
|
|
183
|
+
const [p0, c0] = st[i], [p1, c1] = st[i + 1];
|
|
184
|
+
if (h <= p1) { const t = (h - p0) / (p1 - p0); return `rgb(${Math.round(lerp(c0[0], c1[0], t))},${Math.round(lerp(c0[1], c1[1], t))},${Math.round(lerp(c0[2], c1[2], t))})`; }
|
|
185
|
+
}
|
|
186
|
+
return 'rgb(216,67,46)';
|
|
187
|
+
}
|
|
188
|
+
const textColor = h => h > 0.6 ? '#2A1206' : 'var(--tlm-ink,#ECE7DA)';
|
|
189
|
+
const esc = s => String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
190
|
+
|
|
191
|
+
// ---- tree helpers (stable string ids) ----
|
|
192
|
+
function find(n, id) { if (n.id === id) return n; for (const c of (n.children || [])) { const f = find(c, id); if (f) return f; } return null; }
|
|
193
|
+
const selected = () => selId ? find(root, selId) : null;
|
|
194
|
+
const activeContainer = () => { const s = selected(); return (s && s.children && s.children.length) ? s : viewRoot; };
|
|
195
|
+
function resizeTarget(node, ac) { let a = node; while (a && a._parent && a._parent !== ac) a = a._parent; return (a && a._parent === ac) ? a : null; }
|
|
196
|
+
function pathNodes(n) { const p = []; let c = n; while (c) { p.unshift(c); c = c._parent; } return p; }
|
|
197
|
+
function buildPath(n) { const p = []; let c = n; while (c && c._parent) { p.unshift(c.id); c = c._parent; } return p.join('.'); }
|
|
198
|
+
function shareOf(node) { const p = node._parent; if (!p) return 100; const s = p.children.reduce((a, c) => a + c.weight, 0); return s ? node.weight / s * 100 : 100; }
|
|
199
|
+
function setShare(node, pct) {
|
|
200
|
+
const p = node._parent; if (!p) return;
|
|
201
|
+
const others = p.children.reduce((s, c) => s + c.weight, 0) - node.weight; if (others <= 1e-9) return;
|
|
202
|
+
const f = Math.max(0.02, Math.min(0.97, pct / 100)); node.weight = f / (1 - f) * others;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- board render ----
|
|
206
|
+
function renderBoard() {
|
|
207
|
+
const W = boardEl.clientWidth, H = boardEl.clientHeight;
|
|
208
|
+
layout(viewRoot, 0, 0, W, H, 0, true); calcHeat(viewRoot);
|
|
209
|
+
const vis = []; (function walk(n) { (n.children || []).forEach(c => { vis.push(c); walk(c); }); })(viewRoot);
|
|
210
|
+
const seen = new Set();
|
|
211
|
+
for (const node of vis) {
|
|
212
|
+
const r = node._rect;
|
|
213
|
+
if (r.w < 3 || r.h < 3) { if (tileEls[node.id]) { tileEls[node.id].remove(); delete tileEls[node.id]; } continue; }
|
|
214
|
+
seen.add(node.id);
|
|
215
|
+
const isParent = node.children && node.children.length;
|
|
216
|
+
let el = tileEls[node.id];
|
|
217
|
+
if (!el) {
|
|
218
|
+
el = boardEl.ownerDocument.createElement('div'); el.className = 'tile'; el.style.opacity = '0';
|
|
219
|
+
el.dataset.id = node.id; attach(el); tileEls[node.id] = el; boardEl.appendChild(el);
|
|
220
|
+
requestAnimationFrame(() => { if (tileEls[node.id]) el.style.opacity = '1'; });
|
|
221
|
+
}
|
|
222
|
+
el.style.left = (r.x + INSET) + 'px'; el.style.top = (r.y + INSET) + 'px';
|
|
223
|
+
el.style.width = Math.max(0, r.w - 2 * INSET) + 'px'; el.style.height = Math.max(0, r.h - 2 * INSET) + 'px';
|
|
224
|
+
el.style.zIndex = r.depth;
|
|
225
|
+
el.style.background = node._done ? DONE_COLOR : heatColor(node._heat);
|
|
226
|
+
el.style.color = node._done ? 'var(--tlm-ink,#ECE7DA)' : textColor(node._heat);
|
|
227
|
+
const isSel = selId === node.id, isPar = node.children && node.children.length;
|
|
228
|
+
el.classList.toggle('sel', isSel && !isPar); el.classList.toggle('armed', isSel && !!isPar);
|
|
229
|
+
el.classList.toggle('hot', node._heat > 0.66 && !node._done);
|
|
230
|
+
el.classList.toggle('done', !!node._done);
|
|
231
|
+
const wt = showWeights ? `<span class="wt">${node.weight.toFixed(node.weight < 10 ? 1 : 0)}</span>` : '';
|
|
232
|
+
let html = '';
|
|
233
|
+
if (isParent) { if (r.w > 54 && r.h > 30) html = `<div class="hd"><span class="nm">${esc(node.name)}</span>${wt}</div>`; }
|
|
234
|
+
else { if (r.w > 40 && r.h > 26) html = `<div class="leaf"><span class="nm">${esc(node.name)}</span>${wt}</div>`; }
|
|
235
|
+
if (el._html !== html) { el.innerHTML = html; el._html = html; }
|
|
236
|
+
}
|
|
237
|
+
for (const id in tileEls) { if (!seen.has(id)) { tileEls[id].remove(); delete tileEls[id]; } }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function attach(el) {
|
|
241
|
+
el.addEventListener('pointerdown', onDown);
|
|
242
|
+
el.addEventListener('dblclick', e => {
|
|
243
|
+
e.stopPropagation();
|
|
244
|
+
const n = find(root, el.dataset.id);
|
|
245
|
+
if (n && n.children && n.children.length) { viewRoot = n; viewRootId = n.id; selId = null; render(); }
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function onDown(e) {
|
|
249
|
+
e.stopPropagation();
|
|
250
|
+
const id = e.currentTarget.dataset.id, node = find(root, id); if (!node) return;
|
|
251
|
+
let ac = activeContainer(), target = resizeTarget(node, ac);
|
|
252
|
+
if (!target) { ac = viewRoot; target = resizeTarget(node, ac) || node; } // dragged outside armed parent -> top level
|
|
253
|
+
const p = target._parent, others = p ? p.children.reduce((s, c) => s + c.weight, 0) - target.weight : 0;
|
|
254
|
+
drag = { tappedId: id, node: target, p, others, sx: e.clientX, sy: e.clientY, sw: target.weight, moved: false };
|
|
255
|
+
freeze = true; boardEl.classList.add('nodrag');
|
|
256
|
+
try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) { }
|
|
257
|
+
window.addEventListener('pointermove', onMove);
|
|
258
|
+
window.addEventListener('pointerup', onUp);
|
|
259
|
+
}
|
|
260
|
+
function onMove(e) {
|
|
261
|
+
if (!drag) return;
|
|
262
|
+
const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
|
|
263
|
+
if (!drag.moved && Math.hypot(dx, dy) < 4) return;
|
|
264
|
+
drag.moved = true;
|
|
265
|
+
if (drag.p && drag.others > 0) {
|
|
266
|
+
const delta = dx + dy; // right and down grow
|
|
267
|
+
let w = drag.sw * Math.exp(delta / 220);
|
|
268
|
+
const minW = drag.others * 0.01 / 0.99, maxW = drag.others * 0.97 / 0.03;
|
|
269
|
+
drag.node.weight = Math.max(minW, Math.min(maxW, w));
|
|
270
|
+
renderBoard();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function onUp() {
|
|
274
|
+
window.removeEventListener('pointermove', onMove);
|
|
275
|
+
window.removeEventListener('pointerup', onUp);
|
|
276
|
+
boardEl.classList.remove('nodrag'); freeze = false;
|
|
277
|
+
if (drag && !drag.moved) { selId = drag.tappedId; }
|
|
278
|
+
else if (drag && drag.moved && drag.node) {
|
|
279
|
+
const p = buildPath(drag.node); onWeightChange(p, drag.node.weight);
|
|
280
|
+
const sn = resolveInSrc(p); if (sn) sn.weight = drag.node.weight; // mirror into source
|
|
281
|
+
}
|
|
282
|
+
drag = null; render();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function setStatus(node, status) {
|
|
286
|
+
const p = buildPath(node);
|
|
287
|
+
onStatusChange(p, status); // persist (host turns this into a write)
|
|
288
|
+
const sn = resolveInSrc(p); if (sn) sn.status = status;
|
|
289
|
+
rebuild(); render(); // rebuild from source so done-drop / show-done stay consistent
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- controls (breadcrumb · status buttons for a selected leaf · weight toggle · size slider) ----
|
|
293
|
+
function clearSizebar() { if (!controlsEl) return; const o = controlsEl.querySelector('.tlm-sizebar'); if (o) o.remove(); }
|
|
294
|
+
function renderControls() {
|
|
295
|
+
if (!controlsEl) return;
|
|
296
|
+
const sel = selected(), bc = pathNodes(viewRoot);
|
|
297
|
+
const crumb = bc.map((n, i) => i === bc.length - 1 ? `<b>${esc(n.name)}</b>`
|
|
298
|
+
: `<span class="c" data-id="${esc(n.id)}">${esc(n.name)}</span><span class="sep">/</span>`).join(' ');
|
|
299
|
+
let html = `<div class="crumb">${crumb}</div>`;
|
|
300
|
+
if (sel) html += `<div class="grp"><span class="lbl">status</span>` + // any item, at any level, can hold a status
|
|
301
|
+
STATUSES.map(s => `<button class="st${sel.status === s ? ' on' : ''}" data-s="${s}">${s.replace('_', ' ')}</button>`).join('') + `</div>`;
|
|
302
|
+
html += `<div class="grp"><button id="wtTog" class="tog">weights: ${showWeights ? 'on' : 'off'}</button>` +
|
|
303
|
+
`<button id="doneTog" class="tog">done: ${showDone ? 'shown' : 'hidden'}</button></div>`;
|
|
304
|
+
controlsEl.innerHTML = html;
|
|
305
|
+
clearSizebar();
|
|
306
|
+
if (sel && sel._parent) {
|
|
307
|
+
const only = sel._parent.children.length === 1;
|
|
308
|
+
const sb = controlsEl.ownerDocument.createElement('div'); sb.className = 'tlm-sizebar';
|
|
309
|
+
sb.innerHTML = `fine-tune <b>${esc(sel.name)}</b> in <b>${esc(sel._parent.name)}</b>
|
|
310
|
+
<input type="range" id="sizeR" min="2" max="95" value="${Math.round(shareOf(sel))}" ${only ? 'disabled' : ''}>
|
|
311
|
+
<span class="pct" id="sizeP">${Math.round(shareOf(sel))}%</span>`;
|
|
312
|
+
controlsEl.appendChild(sb);
|
|
313
|
+
const rg = sb.querySelector('#sizeR'), lab = sb.querySelector('#sizeP');
|
|
314
|
+
rg.addEventListener('input', () => { setShare(sel, +rg.value); lab.textContent = Math.round(shareOf(sel)) + '%'; renderBoard(); });
|
|
315
|
+
rg.addEventListener('change', () => { const p = buildPath(sel); onWeightChange(p, sel.weight); const sn = resolveInSrc(p); if (sn) sn.weight = sel.weight; });
|
|
316
|
+
}
|
|
317
|
+
controlsEl.querySelectorAll('.c').forEach(s => s.onclick = () => { const t = find(root, s.dataset.id); if (t) { viewRoot = t; viewRootId = t.id; selId = null; render(); } });
|
|
318
|
+
controlsEl.querySelector('#wtTog').onclick = () => { showWeights = !showWeights; render(); };
|
|
319
|
+
controlsEl.querySelector('#doneTog').onclick = () => { showDone = !showDone; rebuild(); render(); };
|
|
320
|
+
if (sel) controlsEl.querySelectorAll('.st').forEach(b => b.onclick = () => setStatus(sel, b.dataset.s));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function render() { renderControls(); renderBoard(); }
|
|
324
|
+
|
|
325
|
+
// ---- background click clears selection; observe resize ----
|
|
326
|
+
const onBgDown = e => { if (e.target === boardEl) { selId = null; render(); } };
|
|
327
|
+
boardEl.addEventListener('pointerdown', onBgDown);
|
|
328
|
+
const ro = new ResizeObserver(() => renderBoard()); ro.observe(boardEl);
|
|
329
|
+
|
|
330
|
+
rebuild(); render();
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
update(newState) {
|
|
334
|
+
if (drag) return; // never yank the board out from under a drag
|
|
335
|
+
srcState = newState; rebuild(); render();
|
|
336
|
+
},
|
|
337
|
+
setShowDone(v) { showDone = !!v; rebuild(); render(); },
|
|
338
|
+
getState() { return srcState; },
|
|
339
|
+
destroy() {
|
|
340
|
+
ro.disconnect();
|
|
341
|
+
boardEl.removeEventListener('pointerdown', onBgDown);
|
|
342
|
+
for (const id in tileEls) { tileEls[id].remove(); delete tileEls[id]; }
|
|
343
|
+
if (controlsEl) controlsEl.innerHTML = '';
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
package/dashboard.html
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Tilemon — priority board</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root{--tlm-bg:#14120D;--tlm-ink:#ECE7DA;--tlm-dim:#9A9182;--tlm-line:#2A2820;--tlm-gold:#E8C56A;--tlm-panel:#1C1A14}
|
|
12
|
+
*{box-sizing:border-box}
|
|
13
|
+
html,body{margin:0;height:100%;background:var(--tlm-bg);color:var(--tlm-ink);
|
|
14
|
+
font-family:"Space Grotesk",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
|
15
|
+
#app{display:flex;flex-direction:column;height:100vh;padding:14px;gap:10px}
|
|
16
|
+
#board{flex:1;min-height:340px;border-radius:10px}
|
|
17
|
+
.hint{font-family:"Space Mono",monospace;font-size:10.5px;color:var(--tlm-dim);line-height:1.5}
|
|
18
|
+
.hint .off{color:#b0563a}
|
|
19
|
+
</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<div id="app">
|
|
23
|
+
<div id="controls"></div>
|
|
24
|
+
<div id="board"></div>
|
|
25
|
+
<div class="hint" id="hint">drag = resize the item it belongs to (down/right bigger, up/left smaller) · click an item to arm it (dashed), then drags resize the items inside · double-click = drill in · click any tile to set its status</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<script type="module">
|
|
29
|
+
// dashboard.html — the FILE source adapter. It owns the "where does data go" answer:
|
|
30
|
+
// reads from /api/state, writes back via POST, live-refreshes over SSE. The renderer
|
|
31
|
+
// (board.js) knows none of this — swap this file for a DB-backed host and the renderer
|
|
32
|
+
// is unchanged. See SPEC §3 (renderer over sources) and §4.5 (this wiring).
|
|
33
|
+
import { mount } from './board.js';
|
|
34
|
+
|
|
35
|
+
const boardEl = document.getElementById('board');
|
|
36
|
+
const controlsEl = document.getElementById('controls');
|
|
37
|
+
const hint = document.getElementById('hint');
|
|
38
|
+
|
|
39
|
+
async function getState() {
|
|
40
|
+
const r = await fetch('/api/state');
|
|
41
|
+
if (!r.ok) throw new Error('GET /api/state -> ' + r.status);
|
|
42
|
+
return r.json();
|
|
43
|
+
}
|
|
44
|
+
async function post(path, body) {
|
|
45
|
+
try {
|
|
46
|
+
const r = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
47
|
+
if (!r.ok) console.error('POST', path, '->', r.status, await r.text());
|
|
48
|
+
} catch (e) { console.error('POST', path, 'failed', e); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const state = await getState();
|
|
52
|
+
const board = mount(boardEl, controlsEl, {
|
|
53
|
+
state,
|
|
54
|
+
onWeightChange: (path, weight) => post('/api/weight', { path, weight }), // human resized -> persist
|
|
55
|
+
onStatusChange: (path, status) => post('/api/status', { path, status }), // status flagged -> persist
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Live refresh: any write (ours, another tab's, an agent's POST, or a direct file edit
|
|
59
|
+
// the server is watching) emits a `change` event. Re-fetch and update; board.js preserves
|
|
60
|
+
// the current drill level and selection, and ignores updates mid-drag.
|
|
61
|
+
const es = new EventSource('/api/events');
|
|
62
|
+
es.onmessage = async () => { try { board.update(await getState()); } catch (e) { console.error(e); } };
|
|
63
|
+
es.onerror = () => { hint.innerHTML = '<span class="off">live updates disconnected — reconnecting…</span>'; };
|
|
64
|
+
es.onopen = () => { hint.textContent = 'drag = resize the item it belongs to · click an item to arm it, then drags resize the items inside · double-click = drill in · click any tile to set its status · "done" toggle brings finished items back'; };
|
|
65
|
+
</script>
|
|
66
|
+
</body>
|
|
67
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tilemon",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A priority board where importance is space. Zero-sum treemap over a JSON file; agents flag status, you own the weights.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "tilemon": "server.mjs" },
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.mjs ./state.json",
|
|
9
|
+
"test": "node test/board.test.mjs"
|
|
10
|
+
},
|
|
11
|
+
"engines": { "node": ">=18" },
|
|
12
|
+
"files": ["server.mjs", "dashboard.html", "board.js"],
|
|
13
|
+
"license": "MIT"
|
|
14
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tilemon v1 — local file-source server (scaffold; verify in Claude Code).
|
|
3
|
+
// Zero external dependencies — Node 18+ built-ins only, so `npx`/`node` runs with no install.
|
|
4
|
+
//
|
|
5
|
+
// node server.mjs ./state.json # or: npx tilemon ./state.json
|
|
6
|
+
// PORT=4000 TILEMON_TOKEN=secret node server.mjs ./state.json
|
|
7
|
+
//
|
|
8
|
+
// Routes:
|
|
9
|
+
// GET / -> dashboard.html
|
|
10
|
+
// GET /api/state -> current tree (JSON)
|
|
11
|
+
// GET /api/events -> Server-Sent Events; emits "change" on any update
|
|
12
|
+
// POST /api/status -> { "path": "work.ship-v2.data-migration", "status": "blocked" } (AGENTS)
|
|
13
|
+
// POST /api/weight -> { "path": "work", "weight": 3.5 } (HUMAN / UI)
|
|
14
|
+
//
|
|
15
|
+
// Auth model (capability-scoped, the locked design):
|
|
16
|
+
// - /api/status is the AGENT write surface. It can ONLY change a node's status (any level).
|
|
17
|
+
// - /api/weight is the HUMAN write surface. Weights are yours; agents never touch them.
|
|
18
|
+
// - If TILEMON_TOKEN is set, BOTH write routes require `Authorization: Bearer <token>`.
|
|
19
|
+
// Leave it unset on a trusted LAN; set it before exposing the port publicly.
|
|
20
|
+
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import { readFile, writeFile, rename, watch } from 'node:fs/promises';
|
|
23
|
+
import { existsSync } from 'node:fs';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const FILE = process.argv[2] || './state.json';
|
|
29
|
+
const PORT = Number(process.env.PORT) || 4000;
|
|
30
|
+
const TOKEN = process.env.TILEMON_TOKEN || null;
|
|
31
|
+
const DASH = join(__dir, 'dashboard.html'); // rename dashboard-reference.html -> dashboard.html, or point here
|
|
32
|
+
|
|
33
|
+
const VALID_STATUS = new Set(['todo', 'in_progress', 'blocked', 'done']);
|
|
34
|
+
const clients = new Set();
|
|
35
|
+
|
|
36
|
+
// ---- state io (atomic write to avoid half-written reads) ----
|
|
37
|
+
async function readState() {
|
|
38
|
+
return JSON.parse(await readFile(FILE, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
let writing = Promise.resolve();
|
|
41
|
+
async function writeState(state) {
|
|
42
|
+
writing = writing.then(async () => {
|
|
43
|
+
const tmp = FILE + '.tmp';
|
|
44
|
+
await writeFile(tmp, JSON.stringify(state, null, 2));
|
|
45
|
+
await rename(tmp, FILE); // atomic on same filesystem
|
|
46
|
+
});
|
|
47
|
+
return writing;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- path resolution: dotted stable ids, e.g. "work.ship-v2.data-migration" ----
|
|
51
|
+
function resolvePath(state, path) {
|
|
52
|
+
const parts = path.split('.');
|
|
53
|
+
let node = state, depth = 0;
|
|
54
|
+
// root is unnamed container; first part matches a top-level child
|
|
55
|
+
for (const part of parts) {
|
|
56
|
+
const kids = node.children || [];
|
|
57
|
+
const next = kids.find(c => c.id === part);
|
|
58
|
+
if (!next) return null;
|
|
59
|
+
node = next; depth++;
|
|
60
|
+
}
|
|
61
|
+
return depth === parts.length ? node : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- SSE broadcast ----
|
|
65
|
+
function broadcast() {
|
|
66
|
+
for (const res of clients) res.write('data: change\n\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- file watcher: external edits (agents writing the file directly, manual edits) also push ----
|
|
70
|
+
let debounce;
|
|
71
|
+
(async () => {
|
|
72
|
+
try {
|
|
73
|
+
const watcher = watch(FILE);
|
|
74
|
+
for await (const _ of watcher) {
|
|
75
|
+
clearTimeout(debounce);
|
|
76
|
+
debounce = setTimeout(broadcast, 80);
|
|
77
|
+
}
|
|
78
|
+
} catch (_) { /* file may not exist yet */ }
|
|
79
|
+
})();
|
|
80
|
+
|
|
81
|
+
function authed(req) {
|
|
82
|
+
if (!TOKEN) return true;
|
|
83
|
+
return req.headers['authorization'] === `Bearer ${TOKEN}`;
|
|
84
|
+
}
|
|
85
|
+
function body(req) {
|
|
86
|
+
return new Promise((res, rej) => {
|
|
87
|
+
let b = ''; req.on('data', c => b += c);
|
|
88
|
+
req.on('end', () => { try { res(b ? JSON.parse(b) : {}); } catch (e) { rej(e); } });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function json(res, code, obj) {
|
|
92
|
+
res.writeHead(code, { 'content-type': 'application/json' });
|
|
93
|
+
res.end(JSON.stringify(obj));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const server = http.createServer(async (req, res) => {
|
|
97
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
98
|
+
|
|
99
|
+
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
100
|
+
try { res.writeHead(200, { 'content-type': 'text/html' }); res.end(await readFile(DASH)); }
|
|
101
|
+
catch { json(res, 500, { error: 'dashboard.html not found next to server.mjs' }); }
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (req.method === 'GET' && url.pathname === '/board.js') {
|
|
106
|
+
try { res.writeHead(200, { 'content-type': 'text/javascript' }); res.end(await readFile(join(__dir, 'board.js'))); }
|
|
107
|
+
catch { json(res, 500, { error: 'board.js not found next to server.mjs' }); }
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
112
|
+
try { json(res, 200, await readState()); }
|
|
113
|
+
catch (e) { json(res, 500, { error: String(e) }); }
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
118
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
119
|
+
res.write(': connected\n\n');
|
|
120
|
+
clients.add(res);
|
|
121
|
+
const ping = setInterval(() => res.write(': ping\n\n'), 25000);
|
|
122
|
+
req.on('close', () => { clearInterval(ping); clients.delete(res); });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (req.method === 'POST' && url.pathname === '/api/status') { // AGENT write
|
|
127
|
+
if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
|
|
128
|
+
try {
|
|
129
|
+
const { path, status } = await body(req);
|
|
130
|
+
if (!VALID_STATUS.has(status)) return json(res, 400, { error: 'bad status' });
|
|
131
|
+
const state = await readState();
|
|
132
|
+
const node = resolvePath(state, path);
|
|
133
|
+
if (!node) return json(res, 404, { error: 'path not found: ' + path });
|
|
134
|
+
node.status = status; // status only — never weight; valid on any node
|
|
135
|
+
await writeState(state); broadcast();
|
|
136
|
+
json(res, 200, { ok: true });
|
|
137
|
+
} catch (e) { json(res, 400, { error: String(e) }); }
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (req.method === 'POST' && url.pathname === '/api/weight') { // HUMAN / UI write
|
|
142
|
+
if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
|
|
143
|
+
try {
|
|
144
|
+
const { path, weight } = await body(req);
|
|
145
|
+
const state = await readState();
|
|
146
|
+
const node = resolvePath(state, path);
|
|
147
|
+
if (!node) return json(res, 404, { error: 'path not found: ' + path });
|
|
148
|
+
node.weight = Number(weight); // weight only
|
|
149
|
+
await writeState(state); broadcast();
|
|
150
|
+
json(res, 200, { ok: true });
|
|
151
|
+
} catch (e) { json(res, 400, { error: String(e) }); }
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
json(res, 404, { error: 'not found' });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!existsSync(FILE)) console.warn(`! ${FILE} does not exist yet — create it or copy state.sample.json`);
|
|
159
|
+
server.listen(PORT, () => {
|
|
160
|
+
console.log(`Tilemon serving ${FILE}`);
|
|
161
|
+
console.log(` dashboard : http://localhost:${PORT}`);
|
|
162
|
+
console.log(` state : http://localhost:${PORT}/api/state`);
|
|
163
|
+
console.log(TOKEN ? ' auth : token required on writes' : ' auth : OPEN (set TILEMON_TOKEN before exposing the port)');
|
|
164
|
+
});
|