ai-sherpa commited on
Commit
9d9a3a3
Β·
verified Β·
1 Parent(s): b7f84ac

Journal: render mandala client-side from saved symbols (recovers past entries, no cache dependency)

Browse files
Files changed (1) hide show
  1. frontend/journal.js +148 -34
frontend/journal.js CHANGED
@@ -93,24 +93,141 @@ function uuid() {
93
  return Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 9);
94
  }
95
 
96
- // ── Mandala helpers ─────────────────────────────────────────
97
- // Convert a Blob into a persistent data: URL (base64) via FileReader.
98
- function blobToDataURL(blob) {
99
- return new Promise((resolve, reject) => {
100
- const fr = new FileReader();
101
- fr.onload = () => resolve(fr.result);
102
- fr.onerror = () => reject(fr.error);
103
- fr.readAsDataURL(blob);
104
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  }
106
- // Set the mandala <img>. Cache-bust ONLY non-data: URLs β€” appending a
107
- // query string to a data: URL corrupts it. A failed load (e.g. a legacy
108
- // entry whose ephemeral /static/mandala/<hash>.png is gone) hides the
109
- // wrap gracefully instead of showing a broken-image icon.
110
- function setMandalaSrc(url) {
111
- if (!url) { mandalaWrap.hidden = true; return; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  mandalaEl.onerror = () => { mandalaWrap.hidden = true; };
113
- mandalaEl.src = url.startsWith("data:") ? url : url + "?t=" + Date.now();
114
  mandalaWrap.hidden = false;
115
  }
116
 
@@ -459,7 +576,10 @@ function selectEntry(id) {
459
  readingChip.textContent = e.entry_type;
460
  readingBack.hidden = false;
461
  quoted.textContent = e.entry; quoted.hidden = false;
462
- setMandalaSrc(e.mandala_url);
 
 
 
463
  for (const [heading, markdown] of Object.entries(e.sections || {})) {
464
  if (markdown && markdown.trim()) showSection(heading, markdown);
465
  }
@@ -492,6 +612,9 @@ async function streamNew(payload) {
492
  grounded_jungian: payload.grounded_jungian,
493
  include_question: payload.include_question,
494
  symbols: null,
 
 
 
495
  mandala_url: null,
496
  sections: {},
497
  };
@@ -537,25 +660,16 @@ async function streamNew(payload) {
537
  errEl.hidden = false; aborted = true; break;
538
  case "symbols":
539
  draft.symbols = event.symbols;
 
 
 
 
540
  break;
541
  case "mandala":
542
- if (event.url) {
543
- // Show live immediately from the (currently-present) server path.
544
- setMandalaSrc(event.url);
545
- // Persist the IMAGE itself as a data: URL so the mandala survives
546
- // server-cache eviction / Space restarts. The /static/mandala/<hash>.png
547
- // path is ephemeral (gitignored .mandala_cache, regenerated at runtime),
548
- // so it 404s for past entries reopened in a new session. Fetch the bytes
549
- // now (while the file exists) and store base64. A fetch/convert failure
550
- // must never block persisting the reading β€” fall back to the live display.
551
- try {
552
- const blob = await (await fetch(event.url)).blob();
553
- draft.mandala_url = await blobToDataURL(blob);
554
- } catch (mErr) {
555
- console.warn("mandala persist failed; keeping live display only", mErr);
556
- draft.mandala_url = event.url;
557
- }
558
- }
559
  break;
560
  case "reading_section":
561
  draft.sections[event.heading] = event.markdown;
 
93
  return Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 9);
94
  }
95
 
96
+ // ── Mandala (client-side render) ────────────────────────────
97
+ // The mandala is fully deterministic from an entry's symbol NAMES, so we
98
+ // draw it in-browser from the saved `symbols` on every reflection AND
99
+ // restore. This removes the dependency on the server's ephemeral
100
+ // /static/mandala/<hash>.png cache (gitignored, wiped on Space restart),
101
+ // which 404'd for past entries reopened in a new session. The drawing is a
102
+ // faithful port of app.py:generate_mandala (768Γ—768). The palette RGBs
103
+ // below are the mandala's intrinsic artwork colours β€” NOT the journal
104
+ // --kg theme β€” so they are hardcoded verbatim to match the server.
105
+
106
+ // Deterministically derive a soft color from a symbol name.
107
+ // Port of app.py:_color_for_symbol. `h` stays < 256**3 so plain Number
108
+ // math is exact here.
109
+ function colorForSymbol(symbol) {
110
+ let h = 0;
111
+ for (let i = 0; i < symbol.length; i++) {
112
+ h = (h * 31 + symbol.charCodeAt(i)) % (256 ** 3);
113
+ }
114
+ const r = 90 + (h & 0xFF) % 120;
115
+ const g = 90 + ((h >> 8) & 0xFF) % 120;
116
+ const b = 90 + ((h >> 16) & 0xFF) % 120;
117
+ return `rgb(${r},${g},${b})`;
118
  }
119
+
120
+ // Draw the symbolic mandala onto an offscreen canvas and return a PNG
121
+ // data URL. `symbols` is the entry's saved array of {symbol, archetypes}
122
+ // (older entries may store plain strings); only the names matter.
123
+ function renderMandalaCanvas(symbols) {
124
+ const names = (symbols || [])
125
+ .map(s => (typeof s === "string" ? s : (s && s.symbol) || ""))
126
+ .filter(Boolean)
127
+ .slice(0, 8);
128
+
129
+ const size = 768;
130
+ const center = size / 2;
131
+ const canvas = document.createElement("canvas");
132
+ canvas.width = size;
133
+ canvas.height = size;
134
+ const ctx = canvas.getContext("2d");
135
+
136
+ const bg = "rgb(250,247,240)"; // warm off-white
137
+ const ink = "rgb(60,55,50)";
138
+ const gold = "rgb(191,149,63)"; // kintsugi gold
139
+ const serif = '"Georgia", "Times New Roman", serif';
140
+
141
+ ctx.textAlign = "center";
142
+ ctx.textBaseline = "middle";
143
+
144
+ // Background.
145
+ ctx.fillStyle = bg;
146
+ ctx.fillRect(0, 0, size, size);
147
+
148
+ // Concentric circles. range(80,340,52) β†’ [80,132,184,236,288,340].
149
+ ctx.lineWidth = 2;
150
+ let ci = 0;
151
+ for (let radius = 80; radius < 340; radius += 52) {
152
+ ctx.strokeStyle = ci % 2 === 0 ? gold : "rgb(200,190,175)";
153
+ ctx.beginPath();
154
+ ctx.arc(center, center, radius, 0, 2 * Math.PI);
155
+ ctx.stroke();
156
+ ci++;
157
+ }
158
+
159
+ const ringRadius = 250;
160
+ const n = names.length;
161
+
162
+ if (n > 0) {
163
+ for (let idx = 0; idx < n; idx++) {
164
+ const symbol = names[idx];
165
+ const angle = (2 * Math.PI * idx / n) - (Math.PI / 2); // start at top
166
+ const x = center + ringRadius * Math.cos(angle);
167
+ const y = center + ringRadius * Math.sin(angle);
168
+ const color = colorForSymbol(symbol);
169
+
170
+ // Connecting line from center to the node.
171
+ ctx.strokeStyle = "rgb(210,200,185)";
172
+ ctx.lineWidth = 2;
173
+ ctx.beginPath();
174
+ ctx.moveTo(center, center);
175
+ ctx.lineTo(x, y);
176
+ ctx.stroke();
177
+
178
+ // Symbol node: filled circle outlined in gold.
179
+ const nodeR = 30;
180
+ ctx.fillStyle = color;
181
+ ctx.beginPath();
182
+ ctx.arc(x, y, nodeR, 0, 2 * Math.PI);
183
+ ctx.fill();
184
+ ctx.strokeStyle = gold;
185
+ ctx.lineWidth = 2;
186
+ ctx.stroke();
187
+
188
+ // Glyph: first letter inside the node.
189
+ ctx.font = `20px ${serif}`;
190
+ ctx.fillStyle = bg;
191
+ ctx.fillText(symbol[0].toUpperCase(), x, y);
192
+
193
+ // Label below the node.
194
+ ctx.fillStyle = ink;
195
+ ctx.fillText(symbol, x, y + nodeR + 14);
196
+ }
197
+ } else {
198
+ ctx.font = `20px ${serif}`;
199
+ ctx.fillStyle = "rgb(150,140,130)";
200
+ ctx.fillText("no symbols detected", center, center + 120);
201
+ }
202
+
203
+ // Center emblem.
204
+ ctx.fillStyle = "rgb(255,252,246)";
205
+ ctx.beginPath();
206
+ ctx.arc(center, center, 55, 0, 2 * Math.PI);
207
+ ctx.fill();
208
+ ctx.strokeStyle = gold;
209
+ ctx.lineWidth = 3;
210
+ ctx.stroke();
211
+ ctx.font = `20px ${serif}`;
212
+ ctx.fillStyle = gold;
213
+ ctx.fillText("Kintsugi", center, center - 12);
214
+ ctx.fillText("Garden", center, center + 12);
215
+
216
+ // Footer.
217
+ ctx.font = `16px ${serif}`;
218
+ ctx.fillStyle = "rgb(150,140,130)";
219
+ ctx.fillText("session symbolic map", center, size - 28);
220
+
221
+ return canvas.toDataURL("image/png");
222
+ }
223
+
224
+ // Display the mandala for an entry's symbols, rendered client-side.
225
+ // Always shows (an empty symbol set renders the "no symbols detected"
226
+ // mandala rather than hiding). The onerror handler stays as a harmless
227
+ // graceful fallback.
228
+ function showMandalaFor(symbols) {
229
  mandalaEl.onerror = () => { mandalaWrap.hidden = true; };
230
+ mandalaEl.src = renderMandalaCanvas(symbols);
231
  mandalaWrap.hidden = false;
232
  }
233
 
 
576
  readingChip.textContent = e.entry_type;
577
  readingBack.hidden = false;
578
  quoted.textContent = e.entry; quoted.hidden = false;
579
+ // Re-derive the mandala client-side from the entry's saved symbols.
580
+ // This recovers legacy entries whose ephemeral server PNG (mandala_url)
581
+ // now 404s β€” they still carry `symbols`, which is all the render needs.
582
+ showMandalaFor(e.symbols);
583
  for (const [heading, markdown] of Object.entries(e.sections || {})) {
584
  if (markdown && markdown.trim()) showSection(heading, markdown);
585
  }
 
612
  grounded_jungian: payload.grounded_jungian,
613
  include_question: payload.include_question,
614
  symbols: null,
615
+ // mandala_url is intentionally left null: the mandala is always
616
+ // re-derived client-side from `symbols`, so the image is never stored.
617
+ // Kept on the object for shape compatibility with older exports.
618
  mandala_url: null,
619
  sections: {},
620
  };
 
660
  errEl.hidden = false; aborted = true; break;
661
  case "symbols":
662
  draft.symbols = event.symbols;
663
+ // Render the mandala client-side from these symbols and show it.
664
+ // The mandala is fully derived from symbol names, so we never
665
+ // store the image β€” it's re-derived on every restore.
666
+ showMandalaFor(draft.symbols);
667
  break;
668
  case "mandala":
669
+ // The server still emits a mandala URL, but it's an ephemeral
670
+ // /static/mandala/<hash>.png that 404s after a Space restart.
671
+ // We no longer use it: the mandala is drawn in-browser from the
672
+ // `symbols` event above. Intentionally a no-op.
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  break;
674
  case "reading_section":
675
  draft.sections[event.heading] = event.markdown;