feat(canvas): collective forgetting UX + memory graph view
Rename all "Delete" labels to "Forget permanently" to align with the three-state memory model (present → forgotten → deleted). Add explainer blurb in memory panel. New Collective Memory graph view — force-directed bubble chart showing shape remembrance scores sized by how many members still remember each shape, with click-to-navigate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3a222e2ddc
commit
eea3443cba
|
|
@ -422,7 +422,7 @@ export class FolkShape extends FolkElement {
|
||||||
<button part="resize-bottom-right" aria-label="Resize shape from bottom right"></button>
|
<button part="resize-bottom-right" aria-label="Resize shape from bottom right"></button>
|
||||||
<button part="resize-bottom-left" aria-label="Resize shape from bottom left"></button>
|
<button part="resize-bottom-left" aria-label="Resize shape from bottom left"></button>
|
||||||
<div class="slot-container"><slot></slot></div>
|
<div class="slot-container"><slot></slot></div>
|
||||||
<div part="forgotten-tooltip">Forgotten — right-click to remember or delete</div>`,
|
<div part="forgotten-tooltip">Forgotten — right-click to remember or forget permanently</div>`,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.#handles = Object.fromEntries(
|
this.#handles = Object.fromEntries(
|
||||||
|
|
|
||||||
|
|
@ -1148,6 +1148,55 @@
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Memory graph mode ── */
|
||||||
|
#canvas.memory-mode {
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
#canvas.memory-mode #canvas-content {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
body:has(#canvas.memory-mode) #toolbar,
|
||||||
|
body:has(#canvas.memory-mode) #bottom-toolbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
body:has(#canvas.memory-mode) #canvas-corner-tools #corner-zoom-toggle,
|
||||||
|
body:has(#canvas.memory-mode) #canvas-corner-tools #zoom-in,
|
||||||
|
body:has(#canvas.memory-mode) #canvas-corner-tools #zoom-out,
|
||||||
|
body:has(#canvas.memory-mode) #canvas-corner-tools #reset-view,
|
||||||
|
body:has(#canvas.memory-mode) #canvas-corner-tools .corner-sep {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
#memory-graph-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
#memory-graph-overlay svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.memory-bubble { cursor: pointer; }
|
||||||
|
.memory-bubble:hover circle { filter: brightness(1.3); }
|
||||||
|
.memory-bubble text {
|
||||||
|
fill: var(--rs-text-primary, #e2e8f0);
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
pointer-events: none;
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: central;
|
||||||
|
}
|
||||||
|
#memory-graph-title {
|
||||||
|
position: absolute;
|
||||||
|
top: 80px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
color: var(--rs-text-secondary, #94a3b8);
|
||||||
|
font: italic 0.875rem/1.4 system-ui, sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 11;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Feed card wrappers ── */
|
/* ── Feed card wrappers ── */
|
||||||
.feed-card {
|
.feed-card {
|
||||||
width: min(100%, 600px);
|
width: min(100%, 600px);
|
||||||
|
|
@ -1615,6 +1664,7 @@
|
||||||
}
|
}
|
||||||
/* When collapsed on mobile, hide feed toggle too — show only zoom icon */
|
/* When collapsed on mobile, hide feed toggle too — show only zoom icon */
|
||||||
#canvas-corner-tools.collapsed #feed-toggle,
|
#canvas-corner-tools.collapsed #feed-toggle,
|
||||||
|
#canvas-corner-tools.collapsed #memory-graph-toggle,
|
||||||
#canvas-corner-tools.collapsed .corner-sep {
|
#canvas-corner-tools.collapsed .corner-sep {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
@ -2028,6 +2078,9 @@
|
||||||
<button id="feed-toggle" title="Feed View" class="corner-btn">
|
<button id="feed-toggle" title="Feed View" class="corner-btn">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="memory-graph-toggle" title="Collective Memory" class="corner-btn">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><circle cx="5" cy="6" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="5" cy="18" r="2"/><circle cx="19" cy="18" r="2"/><line x1="9.5" y1="10.5" x2="6.5" y2="7.5"/><line x1="14.5" y1="10.5" x2="17.5" y2="7.5"/><line x1="9.5" y1="13.5" x2="6.5" y2="16.5"/><line x1="14.5" y1="13.5" x2="17.5" y2="16.5"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hidden button for JS toggle reference (injected into identity dropdown) -->
|
<!-- Hidden button for JS toggle reference (injected into identity dropdown) -->
|
||||||
|
|
@ -5466,7 +5519,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
if (!alreadyForgotten) {
|
if (!alreadyForgotten) {
|
||||||
html += `<button data-action="forget-too">Forget too</button>`;
|
html += `<button data-action="forget-too">Forget too</button>`;
|
||||||
}
|
}
|
||||||
html += `<button data-action="delete" class="danger">Delete</button>`;
|
html += `<button data-action="delete" class="danger">Forget permanently</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
shapeContextMenu.innerHTML = html;
|
shapeContextMenu.innerHTML = html;
|
||||||
|
|
@ -5795,6 +5848,12 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
|
|
||||||
memoryList.innerHTML = "";
|
memoryList.innerHTML = "";
|
||||||
|
|
||||||
|
// ── Explainer blurb ──
|
||||||
|
const explainer = document.createElement("div");
|
||||||
|
explainer.style.cssText = "font-style:italic;color:var(--rs-text-secondary,#94a3b8);font-size:0.75rem;line-height:1.5;padding:8px 12px 12px;opacity:0.85";
|
||||||
|
explainer.textContent = "In collective knowledge spaces, forgetting is natural. When you forget a shape, it fades — but persists. The more people who forget it, the fainter it becomes. Shapes everyone still remembers stay vivid. You can always choose to remember what others have forgotten.";
|
||||||
|
memoryList.appendChild(explainer);
|
||||||
|
|
||||||
// ── Fading section ──
|
// ── Fading section ──
|
||||||
if (faded.length > 0) {
|
if (faded.length > 0) {
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
|
|
@ -5818,7 +5877,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
</div>
|
</div>
|
||||||
${forgetCount > 0 ? `<span class="forget-count">${forgetCount}x</span>` : ''}
|
${forgetCount > 0 ? `<span class="forget-count">${forgetCount}x</span>` : ''}
|
||||||
<button class="remember-btn">Remember</button>
|
<button class="remember-btn">Remember</button>
|
||||||
<button class="delete-btn">Delete</button>
|
<button class="delete-btn">Forget permanently</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
item.querySelector(".remember-btn").addEventListener("click", (e) => {
|
item.querySelector(".remember-btn").addEventListener("click", (e) => {
|
||||||
|
|
@ -5841,7 +5900,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
if (deleted.length > 0) {
|
if (deleted.length > 0) {
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "memory-section-header";
|
header.className = "memory-section-header";
|
||||||
header.textContent = "Deleted";
|
header.textContent = "Forgotten permanently";
|
||||||
memoryList.appendChild(header);
|
memoryList.appendChild(header);
|
||||||
|
|
||||||
for (const shape of deleted) {
|
for (const shape of deleted) {
|
||||||
|
|
@ -6379,11 +6438,11 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
dialog.style.cssText = "background:var(--rs-bg-surface,#1e1b4b);border:1px solid var(--rs-border,#334155);border-radius:12px;padding:1.5rem 2rem;max-width:380px;text-align:center;color:var(--rs-text-primary,#e2e8f0);font-family:system-ui,sans-serif;user-select:none";
|
dialog.style.cssText = "background:var(--rs-bg-surface,#1e1b4b);border:1px solid var(--rs-border,#334155);border-radius:12px;padding:1.5rem 2rem;max-width:380px;text-align:center;color:var(--rs-text-primary,#e2e8f0);font-family:system-ui,sans-serif;user-select:none";
|
||||||
const btnBase = "padding:0.5rem 1.25rem;border-radius:8px;cursor:pointer;font-size:0.875rem;touch-action:manipulation;user-select:none;-webkit-tap-highlight-color:transparent;transition:filter 0.1s";
|
const btnBase = "padding:0.5rem 1.25rem;border-radius:8px;cursor:pointer;font-size:0.875rem;touch-action:manipulation;user-select:none;-webkit-tap-highlight-color:transparent;transition:filter 0.1s";
|
||||||
dialog.innerHTML = `
|
dialog.innerHTML = `
|
||||||
<h3 style="margin:0 0 0.5rem;font-size:1.125rem">Delete ${count} elements?</h3>
|
<h3 style="margin:0 0 0.5rem;font-size:1.125rem">Forget ${count} elements?</h3>
|
||||||
<p style="color:var(--rs-text-secondary,#94a3b8);font-size:0.875rem;margin:0 0 1.25rem;line-height:1.5">You are about to delete a large number of elements. This action cannot be easily undone.</p>
|
<p style="color:var(--rs-text-secondary,#94a3b8);font-size:0.875rem;margin:0 0 1.25rem;line-height:1.5">These shapes will fade from your view. Others may still remember them. Shapes forgotten by everyone eventually dissolve.</p>
|
||||||
<div style="display:flex;gap:0.75rem;justify-content:center">
|
<div style="display:flex;gap:0.75rem;justify-content:center">
|
||||||
<button id="bulk-delete-cancel" style="${btnBase};border:1px solid var(--rs-border,#334155);background:transparent;color:var(--rs-text-primary,#e2e8f0)">Cancel</button>
|
<button id="bulk-delete-cancel" style="${btnBase};border:1px solid var(--rs-border,#334155);background:transparent;color:var(--rs-text-primary,#e2e8f0)">Cancel</button>
|
||||||
<button id="bulk-delete-confirm" style="${btnBase};border:1px solid #dc2626;background:#dc2626;color:#fff;font-weight:600">DELETE</button>
|
<button id="bulk-delete-confirm" style="${btnBase};border:1px solid #dc2626;background:#dc2626;color:#fff;font-weight:600">FORGET</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
overlay.appendChild(dialog);
|
overlay.appendChild(dialog);
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
@ -6904,6 +6963,8 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFeedMode() {
|
function toggleFeedMode() {
|
||||||
|
// Exit memory mode first if active
|
||||||
|
if (memoryMode) toggleMemoryGraph();
|
||||||
feedMode = !feedMode;
|
feedMode = !feedMode;
|
||||||
canvas.classList.toggle('feed-mode', feedMode);
|
canvas.classList.toggle('feed-mode', feedMode);
|
||||||
feedToggleBtn.classList.toggle('active', feedMode);
|
feedToggleBtn.classList.toggle('active', feedMode);
|
||||||
|
|
@ -6949,6 +7010,171 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
if (feedMode) sortFeedShapes(feedSortKey);
|
if (feedMode) sortFeedShapes(feedSortKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Collective Memory graph view ──
|
||||||
|
const memoryGraphBtn = document.getElementById('memory-graph-toggle');
|
||||||
|
let memoryMode = false;
|
||||||
|
|
||||||
|
function toggleMemoryGraph() {
|
||||||
|
// Exit feed mode first if active
|
||||||
|
if (feedMode) toggleFeedMode();
|
||||||
|
memoryMode = !memoryMode;
|
||||||
|
canvas.classList.toggle('memory-mode', memoryMode);
|
||||||
|
memoryGraphBtn.classList.toggle('active', memoryMode);
|
||||||
|
if (memoryMode) {
|
||||||
|
renderMemoryGraph();
|
||||||
|
} else {
|
||||||
|
const overlay = document.getElementById('memory-graph-overlay');
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
const title = document.getElementById('memory-graph-title');
|
||||||
|
if (title) title.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMemoryGraph() {
|
||||||
|
// Remove previous
|
||||||
|
const old = document.getElementById('memory-graph-overlay');
|
||||||
|
if (old) old.remove();
|
||||||
|
const oldTitle = document.getElementById('memory-graph-title');
|
||||||
|
if (oldTitle) oldTitle.remove();
|
||||||
|
|
||||||
|
const shapes = sync.doc.shapes || {};
|
||||||
|
const members = sync.doc.members || {};
|
||||||
|
const totalMembers = Math.max(Object.keys(members).length, 1);
|
||||||
|
|
||||||
|
// Build nodes from non-deleted shapes
|
||||||
|
const nodes = [];
|
||||||
|
for (const [id, shape] of Object.entries(shapes)) {
|
||||||
|
if (shape.deleted) continue;
|
||||||
|
const fb = shape.forgottenBy;
|
||||||
|
const forgottenCount = (fb && typeof fb === 'object') ? Object.keys(fb).length : 0;
|
||||||
|
const ratio = (totalMembers - forgottenCount) / totalMembers;
|
||||||
|
nodes.push({
|
||||||
|
id,
|
||||||
|
type: shape.type || 'shape',
|
||||||
|
label: getShapeLabel(shape),
|
||||||
|
icon: SHAPE_ICONS[shape.type] || '📦',
|
||||||
|
ratio: Math.max(ratio, 0),
|
||||||
|
forgottenCount,
|
||||||
|
totalMembers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
memoryMode = false;
|
||||||
|
canvas.classList.remove('memory-mode');
|
||||||
|
memoryGraphBtn.classList.remove('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = canvas.clientWidth;
|
||||||
|
const h = canvas.clientHeight;
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
const minR = 12, maxR = 80;
|
||||||
|
|
||||||
|
// Assign radii and initial positions
|
||||||
|
for (const n of nodes) {
|
||||||
|
n.r = minR + (maxR - minR) * n.ratio;
|
||||||
|
n.x = cx + (Math.random() - 0.5) * w * 0.6;
|
||||||
|
n.y = cy + (Math.random() - 0.5) * h * 0.6;
|
||||||
|
n.vx = 0;
|
||||||
|
n.vy = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple force simulation: center gravity + collision
|
||||||
|
for (let iter = 0; iter < 60; iter++) {
|
||||||
|
for (const n of nodes) {
|
||||||
|
// Center gravity — stronger for more-remembered shapes
|
||||||
|
const dx = cx - n.x;
|
||||||
|
const dy = cy - n.y;
|
||||||
|
const pull = 0.02 * n.ratio;
|
||||||
|
n.vx += dx * pull;
|
||||||
|
n.vy += dy * pull;
|
||||||
|
}
|
||||||
|
// Collision avoidance
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
|
const a = nodes[i], b = nodes[j];
|
||||||
|
let ddx = b.x - a.x;
|
||||||
|
let ddy = b.y - a.y;
|
||||||
|
const dist = Math.sqrt(ddx * ddx + ddy * ddy) || 1;
|
||||||
|
const minDist = a.r + b.r + 4;
|
||||||
|
if (dist < minDist) {
|
||||||
|
const push = (minDist - dist) / dist * 0.3;
|
||||||
|
const px = ddx * push;
|
||||||
|
const py = ddy * push;
|
||||||
|
a.vx -= px; a.vy -= py;
|
||||||
|
b.vx += px; b.vy += py;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Apply velocity with damping
|
||||||
|
for (const n of nodes) {
|
||||||
|
n.x += n.vx;
|
||||||
|
n.y += n.vy;
|
||||||
|
n.vx *= 0.6;
|
||||||
|
n.vy *= 0.6;
|
||||||
|
// Clamp to viewport
|
||||||
|
n.x = Math.max(n.r + 8, Math.min(w - n.r - 8, n.x));
|
||||||
|
n.y = Math.max(n.r + 100, Math.min(h - n.r - 8, n.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color: green (120) → amber (40) → red (0)
|
||||||
|
function ratioToColor(ratio, opacity) {
|
||||||
|
const hue = ratio * 120; // 0=red, 60=amber, 120=green
|
||||||
|
return `hsla(${hue}, 70%, 45%, ${opacity})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render SVG
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'memory-graph-overlay';
|
||||||
|
|
||||||
|
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">`;
|
||||||
|
for (const n of nodes) {
|
||||||
|
const opacity = Math.max(0.2, n.ratio);
|
||||||
|
const fill = ratioToColor(n.ratio, opacity);
|
||||||
|
const truncLabel = n.label.length > 16 ? n.label.slice(0, 14) + '…' : n.label;
|
||||||
|
const fontSize = Math.max(9, Math.min(14, n.r * 0.35));
|
||||||
|
const tooltipText = `${n.label} (${n.type}) — forgotten by ${n.forgottenCount} of ${n.totalMembers}`;
|
||||||
|
svg += `<g class="memory-bubble" data-shape-id="${n.id}">`;
|
||||||
|
svg += `<title>${escapeHtml(tooltipText)}</title>`;
|
||||||
|
svg += `<circle cx="${n.x}" cy="${n.y}" r="${n.r}" fill="${fill}" stroke="rgba(255,255,255,0.15)" stroke-width="1"/>`;
|
||||||
|
svg += `<text x="${n.x}" y="${n.y - fontSize * 0.4}" font-size="${fontSize + 2}">${n.icon}</text>`;
|
||||||
|
svg += `<text x="${n.x}" y="${n.y + fontSize * 0.9}" font-size="${fontSize}" opacity="${opacity}">${escapeHtml(truncLabel)}</text>`;
|
||||||
|
svg += `</g>`;
|
||||||
|
}
|
||||||
|
svg += `</svg>`;
|
||||||
|
overlay.innerHTML = svg;
|
||||||
|
canvas.appendChild(overlay);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const titleEl = document.createElement('div');
|
||||||
|
titleEl.id = 'memory-graph-title';
|
||||||
|
titleEl.textContent = `Collective Memory — ${nodes.length} shapes across ${totalMembers} member${totalMembers !== 1 ? 's' : ''}`;
|
||||||
|
canvas.appendChild(titleEl);
|
||||||
|
|
||||||
|
// Click → exit memory mode and pan to shape
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
const bubble = e.target.closest('.memory-bubble');
|
||||||
|
if (!bubble) return;
|
||||||
|
const shapeId = bubble.dataset.shapeId;
|
||||||
|
toggleMemoryGraph(); // exit
|
||||||
|
// Pan canvas to center on the shape
|
||||||
|
const shapeData = (sync.doc.shapes || {})[shapeId];
|
||||||
|
if (shapeData && shapeData.x != null && shapeData.y != null) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const sw = shapeData.width || 300;
|
||||||
|
const sh = shapeData.height || 200;
|
||||||
|
panX = rect.width / 2 - (shapeData.x + sw / 2) * scale;
|
||||||
|
panY = rect.height / 2 - (shapeData.y + sh / 2) * scale;
|
||||||
|
updateCanvasTransform();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryGraphBtn.addEventListener('click', toggleMemoryGraph);
|
||||||
|
|
||||||
sync.connect(wsUrl);
|
sync.connect(wsUrl);
|
||||||
|
|
||||||
// Debug: expose sync for console inspection
|
// Debug: expose sync for console inspection
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue