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-left" aria-label="Resize shape from bottom left"></button>
|
||||
<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(
|
||||
|
|
|
|||
|
|
@ -1148,6 +1148,55 @@
|
|||
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 {
|
||||
width: min(100%, 600px);
|
||||
|
|
@ -1615,6 +1664,7 @@
|
|||
}
|
||||
/* When collapsed on mobile, hide feed toggle too — show only zoom icon */
|
||||
#canvas-corner-tools.collapsed #feed-toggle,
|
||||
#canvas-corner-tools.collapsed #memory-graph-toggle,
|
||||
#canvas-corner-tools.collapsed .corner-sep {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
@ -2028,6 +2078,9 @@
|
|||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 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) {
|
||||
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;
|
||||
|
|
@ -5795,6 +5848,12 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
|||
|
||||
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 ──
|
||||
if (faded.length > 0) {
|
||||
const header = document.createElement("div");
|
||||
|
|
@ -5818,7 +5877,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
|||
</div>
|
||||
${forgetCount > 0 ? `<span class="forget-count">${forgetCount}x</span>` : ''}
|
||||
<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) => {
|
||||
|
|
@ -5841,7 +5900,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
|||
if (deleted.length > 0) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "memory-section-header";
|
||||
header.textContent = "Deleted";
|
||||
header.textContent = "Forgotten permanently";
|
||||
memoryList.appendChild(header);
|
||||
|
||||
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";
|
||||
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 = `
|
||||
<h3 style="margin:0 0 0.5rem;font-size:1.125rem">Delete ${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>
|
||||
<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">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">
|
||||
<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>`;
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
|
@ -6904,6 +6963,8 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
|||
}
|
||||
|
||||
function toggleFeedMode() {
|
||||
// Exit memory mode first if active
|
||||
if (memoryMode) toggleMemoryGraph();
|
||||
feedMode = !feedMode;
|
||||
canvas.classList.toggle('feed-mode', 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);
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
|
||||
// Debug: expose sync for console inspection
|
||||
|
|
|
|||
Loading…
Reference in New Issue