feat: port standalone app features to unified rSpace modules
rCal: keyboard navigation (←→ nav, 1/2/3 views, T today, L lunar) and clickable source badges for event filtering. rWork: inline create form with priority/description, inline title editing, and priority badge cycling (replaces prompt() dialogs). rNetwork: force-directed layout (80-iteration simulation), node detail panel with trust scores, and purple trust badges on person nodes. rMaps: provider search with dimming, enhanced detail with OSM directions link, and browser geolocation pin display. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
abbfb552cc
commit
bb6643cf70
|
|
@ -19,6 +19,8 @@ class FolkCalendarView extends HTMLElement {
|
|||
private selectedEvent: any = null;
|
||||
private expandedDay = ""; // mobile day-detail panel
|
||||
private error = "";
|
||||
private filteredSources = new Set<string>();
|
||||
private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -27,11 +29,36 @@ class FolkCalendarView extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
|
||||
document.addEventListener("keydown", this.boundKeyHandler);
|
||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||
this.loadMonth();
|
||||
this.render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.boundKeyHandler) {
|
||||
document.removeEventListener("keydown", this.boundKeyHandler);
|
||||
this.boundKeyHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeydown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
switch (e.key) {
|
||||
case "ArrowLeft": e.preventDefault(); this.navigate(-1); break;
|
||||
case "ArrowRight": e.preventDefault(); this.navigate(1); break;
|
||||
case "1": this.viewMode = "day"; this.render(); break;
|
||||
case "2": this.viewMode = "week"; this.render(); break;
|
||||
case "3": this.viewMode = "month"; this.render(); break;
|
||||
case "t": case "T":
|
||||
this.currentDate = new Date(); this.expandedDay = "";
|
||||
if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
|
||||
break;
|
||||
case "l": case "L": this.showLunar = !this.showLunar; this.render(); break;
|
||||
}
|
||||
}
|
||||
|
||||
private loadDemoData() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
|
|
@ -218,7 +245,14 @@ class FolkCalendarView extends HTMLElement {
|
|||
}
|
||||
|
||||
private getEventsForDate(dateStr: string): any[] {
|
||||
return this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
|
||||
return this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr)
|
||||
&& !this.filteredSources.has(e.source_name));
|
||||
}
|
||||
|
||||
private toggleSource(name: string) {
|
||||
if (this.filteredSources.has(name)) { this.filteredSources.delete(name); }
|
||||
else { this.filteredSources.add(name); }
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
|
|
@ -243,7 +277,9 @@ class FolkCalendarView extends HTMLElement {
|
|||
.view-switch-btn.active { background: #4f46e5; color: #fff; }
|
||||
|
||||
.sources { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.src-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; }
|
||||
.src-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; cursor: pointer; transition: opacity 0.15s; user-select: none; }
|
||||
.src-badge:hover { filter: brightness(1.2); }
|
||||
.src-badge.filtered { opacity: 0.3; text-decoration: line-through; }
|
||||
|
||||
.weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 4px; }
|
||||
.wd { text-align: center; font-size: 11px; color: #64748b; padding: 4px; font-weight: 600; }
|
||||
|
|
@ -371,7 +407,7 @@ class FolkCalendarView extends HTMLElement {
|
|||
</div>
|
||||
|
||||
${this.sources.length > 0 ? `<div class="sources">
|
||||
${this.sources.map(s => `<span class="src-badge" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
|
||||
${this.sources.map(s => `<span class="src-badge ${this.filteredSources.has(s.name) ? "filtered" : ""}" data-source="${this.esc(s.name)}" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
|
||||
</div>` : ""}
|
||||
|
||||
${this.viewMode === "month" ? this.renderMonth() : ""}
|
||||
|
|
@ -711,6 +747,14 @@ class FolkCalendarView extends HTMLElement {
|
|||
this.render();
|
||||
});
|
||||
|
||||
// Source filter toggles
|
||||
this.shadow.querySelectorAll("[data-source]").forEach(el => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleSource((el as HTMLElement).dataset.source!);
|
||||
});
|
||||
});
|
||||
|
||||
// View switcher
|
||||
this.shadow.querySelectorAll("[data-view]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ class FolkMapViewer extends HTMLElement {
|
|||
private dragVbY = 0;
|
||||
private zoomLevel = 1;
|
||||
private selectedProvider = -1;
|
||||
private searchQuery = "";
|
||||
private userLocation: { lat: number; lng: number } | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -97,6 +99,18 @@ class FolkMapViewer extends HTMLElement {
|
|||
this.renderDemo();
|
||||
}
|
||||
|
||||
private getFilteredProviders(): { provider: typeof this.providers[0]; index: number }[] {
|
||||
if (!this.searchQuery.trim()) return this.providers.map((p, i) => ({ provider: p, index: i }));
|
||||
const q = this.searchQuery.toLowerCase();
|
||||
return this.providers.map((p, i) => ({ provider: p, index: i }))
|
||||
.filter(({ provider: p }) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.city.toLowerCase().includes(q) ||
|
||||
p.country.toLowerCase().includes(q) ||
|
||||
p.specialties.some(s => s.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
private renderDemo() {
|
||||
const W = 900;
|
||||
const H = 460;
|
||||
|
|
@ -104,6 +118,8 @@ class FolkMapViewer extends HTMLElement {
|
|||
const px = (lng: number) => ((lng + 180) / 360) * W;
|
||||
const py = (lat: number) => ((90 - lat) / 180) * H;
|
||||
|
||||
const filteredSet = new Set(this.getFilteredProviders().map(f => f.index));
|
||||
|
||||
// Label offsets to avoid overlapping
|
||||
const labelOffsets: Record<string, [number, number]> = {
|
||||
"Radiant Hall Press": [10, -8],
|
||||
|
|
@ -130,13 +146,27 @@ class FolkMapViewer extends HTMLElement {
|
|||
}).join("\n");
|
||||
|
||||
// Provider pins
|
||||
// User location pin
|
||||
const userPin = this.userLocation ? (() => {
|
||||
const ux = px(this.userLocation!.lng);
|
||||
const uy = py(this.userLocation!.lat);
|
||||
return `
|
||||
<g>
|
||||
<circle cx="${ux}" cy="${uy}" r="6" fill="#22c55e" stroke="#fff" stroke-width="2" opacity="0.9">
|
||||
<animate attributeName="r" values="6;10;6" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<text x="${ux + 10}" y="${uy + 4}" fill="#22c55e" font-size="9" font-weight="600" font-family="system-ui,sans-serif">You</text>
|
||||
</g>`;
|
||||
})() : "";
|
||||
|
||||
const pins = this.providers.map((p, i) => {
|
||||
const x = px(p.lng);
|
||||
const y = py(p.lat);
|
||||
const [lx, ly] = labelOffsets[p.name] || [10, 4];
|
||||
const isSelected = this.selectedProvider === i;
|
||||
const isDimmed = this.searchQuery.trim() && !filteredSet.has(i);
|
||||
return `
|
||||
<g class="pin-group" data-idx="${i}" style="cursor:pointer">
|
||||
<g class="pin-group" data-idx="${i}" style="cursor:pointer;${isDimmed ? "opacity:0.2" : ""}">
|
||||
<circle cx="${x}" cy="${y}" r="4" fill="none" stroke="${p.color}" stroke-width="1" opacity="0.5">
|
||||
<animate attributeName="r" values="4;14;4" dur="3s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.5;0;0.5" dur="3s" repeatCount="indefinite" />
|
||||
|
|
@ -182,6 +212,9 @@ class FolkMapViewer extends HTMLElement {
|
|||
<div style="font-size:11px;color:#4a5568;margin-top:10px;font-family:monospace;">
|
||||
${sp.lat.toFixed(4)}\u00B0N, ${Math.abs(sp.lng).toFixed(4)}\u00B0${sp.lng >= 0 ? "E" : "W"}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||
<a href="https://www.openstreetmap.org/?mlat=${sp.lat}&mlon=${sp.lng}#map=15/${sp.lat}/${sp.lng}" target="_blank" rel="noopener" style="flex:1;text-align:center;padding:8px;border-radius:6px;background:#4f46e520;border:1px solid #4f46e540;color:#818cf8;font-size:12px;font-weight:600;text-decoration:none;">Get Directions</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -212,6 +245,20 @@ class FolkMapViewer extends HTMLElement {
|
|||
.zoom-btn:hover { border-color: #555; color: #e2e8f0; }
|
||||
.zoom-label { font-size: 10px; color: #4a5568; font-variant-numeric: tabular-nums; min-width: 32px; text-align: center; }
|
||||
|
||||
.search-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||
.search-input {
|
||||
flex: 1; border: 1px solid #1e293b; border-radius: 8px; padding: 8px 12px;
|
||||
background: #0c1221; color: #e0e0e0; font-size: 13px; outline: none;
|
||||
}
|
||||
.search-input:focus { border-color: #6366f1; }
|
||||
.search-input::placeholder { color: #4a5568; }
|
||||
.geo-btn {
|
||||
padding: 8px 14px; border-radius: 8px; border: 1px solid #1e293b;
|
||||
background: #0c1221; color: #94a3b8; cursor: pointer; font-size: 12px; white-space: nowrap;
|
||||
}
|
||||
.geo-btn:hover { border-color: #334155; color: #e2e8f0; }
|
||||
.geo-btn.active { border-color: #22c55e; color: #22c55e; }
|
||||
|
||||
.map-wrap {
|
||||
width: 100%; border-radius: 12px; background: #0c1221; border: 1px solid #1e293b;
|
||||
overflow: hidden; position: relative; cursor: grab;
|
||||
|
|
@ -292,6 +339,11 @@ class FolkMapViewer extends HTMLElement {
|
|||
<span class="demo-nav__badge"><span class="dot"></span> ${this.providers.length} providers online</span>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<input class="search-input" type="text" id="map-search" placeholder="Search providers by name, city, or specialty..." value="${this.esc(this.searchQuery)}">
|
||||
<button class="geo-btn ${this.userLocation ? "active" : ""}" id="share-geo">${this.userLocation ? "\u{1F4CD} Sharing" : "\u{1F4CD} Share Location"}</button>
|
||||
</div>
|
||||
|
||||
<div class="map-wrap" id="map-wrap">
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
<svg class="map-svg" id="map-svg" viewBox="${this.vbX} ${this.vbY} ${this.vbW} ${this.vbH}" xmlns="http://www.w3.org/2000/svg">
|
||||
|
|
@ -316,6 +368,9 @@ class FolkMapViewer extends HTMLElement {
|
|||
|
||||
<!-- Provider pins -->
|
||||
${pins}
|
||||
|
||||
<!-- User location -->
|
||||
${userPin}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
|
@ -508,6 +563,32 @@ class FolkMapViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
private attachDemoListeners() {
|
||||
// Search input
|
||||
let searchTimeout: any;
|
||||
this.shadow.getElementById("map-search")?.addEventListener("input", (e) => {
|
||||
this.searchQuery = (e.target as HTMLInputElement).value;
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => this.renderDemo(), 200);
|
||||
});
|
||||
|
||||
// Geolocation button
|
||||
this.shadow.getElementById("share-geo")?.addEventListener("click", () => {
|
||||
if (this.userLocation) {
|
||||
this.userLocation = null;
|
||||
this.renderDemo();
|
||||
return;
|
||||
}
|
||||
if ("geolocation" in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
this.userLocation = { lat: pos.coords.latitude, lng: pos.coords.longitude };
|
||||
this.renderDemo();
|
||||
},
|
||||
() => { /* denied — do nothing */ }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const tooltip = this.shadow.getElementById("tooltip");
|
||||
const mapWrap = this.shadow.getElementById("map-wrap");
|
||||
const mapSvg = this.shadow.getElementById("map-svg");
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
private filter: "all" | "person" | "company" | "opportunity" = "all";
|
||||
private searchQuery = "";
|
||||
private error = "";
|
||||
private selectedNode: GraphNode | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -141,6 +142,130 @@ class FolkGraphViewer extends HTMLElement {
|
|||
return filtered;
|
||||
}
|
||||
|
||||
private computeForceLayout(nodes: GraphNode[], edges: GraphEdge[], W: number, H: number): Record<string, { x: number; y: number }> {
|
||||
const pos: Record<string, { x: number; y: number }> = {};
|
||||
|
||||
// Initial positions: orgs in triangle, people around their org
|
||||
const orgCenters: Record<string, { x: number; y: number }> = {
|
||||
"org-1": { x: W / 2, y: 120 },
|
||||
"org-2": { x: 160, y: 380 },
|
||||
"org-3": { x: W - 160, y: 380 },
|
||||
};
|
||||
const orgNameToId: Record<string, string> = {
|
||||
"Commons DAO": "org-1", "Mycelial Lab": "org-2", "Regenerative Fund": "org-3",
|
||||
};
|
||||
|
||||
for (const [id, p] of Object.entries(orgCenters)) pos[id] = { ...p };
|
||||
|
||||
const peopleByOrg: Record<string, GraphNode[]> = {};
|
||||
for (const n of nodes) {
|
||||
if (n.type === "person") {
|
||||
const oid = orgNameToId[n.workspace];
|
||||
if (oid) { (peopleByOrg[oid] ??= []).push(n); }
|
||||
}
|
||||
}
|
||||
for (const [oid, people] of Object.entries(peopleByOrg)) {
|
||||
const c = orgCenters[oid];
|
||||
if (!c) continue;
|
||||
const gcx = W / 2, gcy = 250;
|
||||
const base = Math.atan2(c.y - gcy, c.x - gcx);
|
||||
const spread = Math.PI * 0.8;
|
||||
people.forEach((p, i) => {
|
||||
const angle = base - spread / 2 + (spread * i) / Math.max(people.length - 1, 1);
|
||||
pos[p.id] = { x: c.x + 110 * Math.cos(angle), y: c.y + 110 * Math.sin(angle) };
|
||||
});
|
||||
}
|
||||
|
||||
// Run force iterations
|
||||
const allIds = nodes.map(n => n.id).filter(id => pos[id]);
|
||||
for (let iter = 0; iter < 80; iter++) {
|
||||
const force: Record<string, { fx: number; fy: number }> = {};
|
||||
for (const id of allIds) force[id] = { fx: 0, fy: 0 };
|
||||
|
||||
// Repulsion between all nodes
|
||||
for (let i = 0; i < allIds.length; i++) {
|
||||
for (let j = i + 1; j < allIds.length; j++) {
|
||||
const a = pos[allIds[i]], b = pos[allIds[j]];
|
||||
let dx = b.x - a.x, dy = b.y - a.y;
|
||||
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||
const repel = 3000 / (dist * dist);
|
||||
dx /= dist; dy /= dist;
|
||||
force[allIds[i]].fx -= dx * repel;
|
||||
force[allIds[i]].fy -= dy * repel;
|
||||
force[allIds[j]].fx += dx * repel;
|
||||
force[allIds[j]].fy += dy * repel;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction along edges
|
||||
for (const edge of edges) {
|
||||
const a = pos[edge.source], b = pos[edge.target];
|
||||
if (!a || !b) continue;
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const idealLen = edge.type === "work_at" ? 100 : 200;
|
||||
const attract = (dist - idealLen) * 0.01;
|
||||
const ux = dx / Math.max(dist, 1), uy = dy / Math.max(dist, 1);
|
||||
if (force[edge.source]) { force[edge.source].fx += ux * attract; force[edge.source].fy += uy * attract; }
|
||||
if (force[edge.target]) { force[edge.target].fx -= ux * attract; force[edge.target].fy -= uy * attract; }
|
||||
}
|
||||
|
||||
// Center gravity
|
||||
for (const id of allIds) {
|
||||
const p = pos[id];
|
||||
force[id].fx += (W / 2 - p.x) * 0.002;
|
||||
force[id].fy += (H / 2 - p.y) * 0.002;
|
||||
}
|
||||
|
||||
// Apply forces with damping
|
||||
const damping = 0.4 * (1 - iter / 80);
|
||||
for (const id of allIds) {
|
||||
pos[id].x += force[id].fx * damping;
|
||||
pos[id].y += force[id].fy * damping;
|
||||
pos[id].x = Math.max(30, Math.min(W - 30, pos[id].x));
|
||||
pos[id].y = Math.max(30, Math.min(H - 30, pos[id].y));
|
||||
}
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
private getTrustScore(nodeId: string): number {
|
||||
return Math.min(100, this.edges.filter(e => e.source === nodeId || e.target === nodeId).length * 20);
|
||||
}
|
||||
|
||||
private getConnectedNodes(nodeId: string): GraphNode[] {
|
||||
const connIds = new Set<string>();
|
||||
for (const e of this.edges) {
|
||||
if (e.source === nodeId) connIds.add(e.target);
|
||||
if (e.target === nodeId) connIds.add(e.source);
|
||||
}
|
||||
return this.nodes.filter(n => connIds.has(n.id));
|
||||
}
|
||||
|
||||
private renderDetailPanel(): string {
|
||||
if (!this.selectedNode) return "";
|
||||
const n = this.selectedNode;
|
||||
const connected = this.getConnectedNodes(n.id);
|
||||
const trust = n.type === "person" ? this.getTrustScore(n.id) : -1;
|
||||
return `
|
||||
<div class="detail-panel" id="detail-panel">
|
||||
<div class="detail-header">
|
||||
<span class="detail-icon">${n.type === "company" ? "\u{1F3E2}" : "\u{1F464}"}</span>
|
||||
<div class="detail-info">
|
||||
<div class="detail-name">${this.esc(n.name)}</div>
|
||||
<div class="detail-type">${this.esc(n.type === "company" ? "Organization" : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}</div>
|
||||
</div>
|
||||
<button class="detail-close" id="close-detail">\u2715</button>
|
||||
</div>
|
||||
${n.description ? `<p class="detail-desc">${this.esc(n.description)}</p>` : ""}
|
||||
${trust >= 0 ? `<div class="detail-trust"><span class="trust-label">Trust Score</span><span class="trust-bar"><span class="trust-fill" style="width:${trust}%"></span></span><span class="trust-val">${trust}</span></div>` : ""}
|
||||
${connected.length > 0 ? `
|
||||
<div class="detail-section">Connected (${connected.length})</div>
|
||||
${connected.map(c => `<div class="detail-conn"><span class="conn-dot" style="background:${c.type === "company" ? "#22c55e" : "#3b82f6"}"></span>${this.esc(c.name)}<span class="conn-role">${this.esc(c.role || c.type)}</span></div>`).join("")}
|
||||
` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderGraphNodes(): string {
|
||||
const filtered = this.getFilteredNodes();
|
||||
if (filtered.length === 0 && this.nodes.length > 0) {
|
||||
|
|
@ -161,69 +286,21 @@ class FolkGraphViewer extends HTMLElement {
|
|||
const H = 500;
|
||||
const filteredIds = new Set(filtered.map(n => n.id));
|
||||
|
||||
// Cluster layout: position org nodes as hubs, people orbit around their org
|
||||
// Three orgs arranged in a triangle
|
||||
const orgCenters: Record<string, { x: number; y: number }> = {
|
||||
"org-1": { x: W / 2, y: 120 }, // Commons DAO — top center
|
||||
"org-2": { x: 160, y: 380 }, // Mycelial Lab — bottom left
|
||||
"org-3": { x: W - 160, y: 380 }, // Regenerative Fund — bottom right
|
||||
};
|
||||
// Force-directed layout
|
||||
const positions = this.computeForceLayout(this.nodes, this.edges, W, H);
|
||||
|
||||
// Build a position map for all nodes
|
||||
const positions: Record<string, { x: number; y: number }> = {};
|
||||
|
||||
// Position org nodes at their centers
|
||||
for (const [id, pos] of Object.entries(orgCenters)) {
|
||||
positions[id] = pos;
|
||||
}
|
||||
|
||||
// Group people by their workspace (org)
|
||||
const orgNameToId: Record<string, string> = {
|
||||
"Commons DAO": "org-1",
|
||||
"Mycelial Lab": "org-2",
|
||||
"Regenerative Fund": "org-3",
|
||||
};
|
||||
|
||||
const peopleByOrg: Record<string, GraphNode[]> = {};
|
||||
for (const node of this.nodes) {
|
||||
if (node.type === "person") {
|
||||
const orgId = orgNameToId[node.workspace];
|
||||
if (orgId) {
|
||||
if (!peopleByOrg[orgId]) peopleByOrg[orgId] = [];
|
||||
peopleByOrg[orgId].push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Position people in a semicircle around their org
|
||||
const orbitRadius = 110;
|
||||
for (const [orgId, people] of Object.entries(peopleByOrg)) {
|
||||
const center = orgCenters[orgId];
|
||||
if (!center) continue;
|
||||
const count = people.length;
|
||||
// Spread people in an arc facing outward from the graph center
|
||||
const graphCx = W / 2;
|
||||
const graphCy = (120 + 380) / 2; // vertical center of the triangle
|
||||
const baseAngle = Math.atan2(center.y - graphCy, center.x - graphCx);
|
||||
const spread = Math.PI * 0.8; // 144 degrees arc
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = baseAngle - spread / 2 + (spread * i) / Math.max(count - 1, 1);
|
||||
positions[people[i].id] = {
|
||||
x: center.x + orbitRadius * Math.cos(angle),
|
||||
y: center.y + orbitRadius * Math.sin(angle),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Org background cluster circles
|
||||
// Org colors
|
||||
const orgColors: Record<string, string> = {
|
||||
"org-1": "#6366f1", // indigo for Commons DAO
|
||||
"org-2": "#22c55e", // green for Mycelial Lab
|
||||
"org-3": "#f59e0b", // amber for Regenerative Fund
|
||||
"org-1": "#6366f1", "org-2": "#22c55e", "org-3": "#f59e0b",
|
||||
};
|
||||
const clustersSvg = Object.entries(orgCenters).map(([orgId, pos]) => {
|
||||
|
||||
// Cluster backgrounds based on computed positions
|
||||
const orgIds = ["org-1", "org-2", "org-3"];
|
||||
const clustersSvg = orgIds.map(orgId => {
|
||||
const pos = positions[orgId];
|
||||
if (!pos) return "";
|
||||
const color = orgColors[orgId] || "#333";
|
||||
return `<circle cx="${pos.x}" cy="${pos.y}" r="${orbitRadius + 30}" fill="${color}" opacity="0.04" stroke="${color}" stroke-width="1" stroke-opacity="0.15" stroke-dasharray="4 4"/>`;
|
||||
return `<circle cx="${pos.x}" cy="${pos.y}" r="140" fill="${color}" opacity="0.04" stroke="${color}" stroke-width="1" stroke-opacity="0.15" stroke-dasharray="4 4"/>`;
|
||||
}).join("");
|
||||
|
||||
// Render edges
|
||||
|
|
@ -237,7 +314,6 @@ class FolkGraphViewer extends HTMLElement {
|
|||
if (edge.type === "work_at") {
|
||||
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#555" stroke-width="1" opacity="0.35"/>`);
|
||||
} else if (edge.type === "point_of_contact") {
|
||||
// Cross-org edges: dashed, brighter
|
||||
const mx = (sp.x + tp.x) / 2;
|
||||
const my = (sp.y + tp.y) / 2;
|
||||
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#c084fc" stroke-width="1.5" stroke-dasharray="6 3" opacity="0.6"/>`);
|
||||
|
|
@ -254,6 +330,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
const isOrg = node.type === "company";
|
||||
const color = isOrg ? (orgColors[node.id] || "#22c55e") : "#3b82f6";
|
||||
const radius = isOrg ? 22 : 12;
|
||||
const isSelected = this.selectedNode?.id === node.id;
|
||||
|
||||
let label = this.esc(node.name);
|
||||
let sublabel = "";
|
||||
|
|
@ -263,11 +340,22 @@ class FolkGraphViewer extends HTMLElement {
|
|||
sublabel = `<text x="${pos.x}" y="${pos.y + radius + 24}" fill="#666" font-size="8" text-anchor="middle">${this.esc(node.role)}${node.location ? " \u00b7 " + this.esc(node.location) : ""}</text>`;
|
||||
}
|
||||
|
||||
// Trust score badge for people
|
||||
const trust = !isOrg ? this.getTrustScore(node.id) : -1;
|
||||
const trustBadge = trust >= 0 ? `
|
||||
<circle cx="${pos.x + radius - 2}" cy="${pos.y - radius + 2}" r="8" fill="#7c3aed" stroke="#0d0d14" stroke-width="1.5"/>
|
||||
<text x="${pos.x + radius - 2}" y="${pos.y - radius + 5.5}" fill="#fff" font-size="7" font-weight="700" text-anchor="middle">${trust}</text>
|
||||
` : "";
|
||||
|
||||
return `
|
||||
<circle cx="${pos.x}" cy="${pos.y}" r="${radius}" fill="${color}" opacity="${isOrg ? 0.9 : 0.75}" stroke="${isOrg ? color : "none"}" stroke-width="${isOrg ? 2 : 0}" stroke-opacity="0.3"/>
|
||||
${isOrg ? `<text x="${pos.x}" y="${pos.y + 4}" fill="#fff" font-size="9" font-weight="600" text-anchor="middle">${label.length > 14 ? label.slice(0, 12) + "\u2026" : label}</text>` : ""}
|
||||
<text x="${pos.x}" y="${pos.y + radius + 13}" fill="#ccc" font-size="${isOrg ? 11 : 10}" font-weight="${isOrg ? 600 : 400}" text-anchor="middle">${label}</text>
|
||||
${sublabel}
|
||||
<g class="graph-node" data-node-id="${node.id}" style="cursor:pointer">
|
||||
${isSelected ? `<circle cx="${pos.x}" cy="${pos.y}" r="${radius + 6}" fill="none" stroke="${color}" stroke-width="2" opacity="0.6"/>` : ""}
|
||||
<circle cx="${pos.x}" cy="${pos.y}" r="${radius}" fill="${color}" opacity="${isOrg ? 0.9 : 0.75}" stroke="${isOrg ? color : "none"}" stroke-width="${isOrg ? 2 : 0}" stroke-opacity="0.3"/>
|
||||
${isOrg ? `<text x="${pos.x}" y="${pos.y + 4}" fill="#fff" font-size="9" font-weight="600" text-anchor="middle">${label.length > 14 ? label.slice(0, 12) + "\u2026" : label}</text>` : ""}
|
||||
<text x="${pos.x}" y="${pos.y + radius + 13}" fill="#ccc" font-size="${isOrg ? 11 : 10}" font-weight="${isOrg ? 600 : 400}" text-anchor="middle">${label}</text>
|
||||
${sublabel}
|
||||
${trustBadge}
|
||||
</g>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
|
|
@ -329,6 +417,30 @@ class FolkGraphViewer extends HTMLElement {
|
|||
|
||||
.demo-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; background: #f59e0b22; color: #f59e0b; font-size: 11px; font-weight: 600; margin-left: 8px; }
|
||||
|
||||
.graph-node:hover circle:first-child { filter: brightness(1.2); }
|
||||
|
||||
.detail-panel {
|
||||
background: #1a1a2e; border: 1px solid #334155; border-radius: 10px;
|
||||
padding: 16px; margin-top: 12px;
|
||||
}
|
||||
.detail-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.detail-icon { font-size: 24px; }
|
||||
.detail-info { flex: 1; }
|
||||
.detail-name { font-size: 15px; font-weight: 600; color: #e2e8f0; }
|
||||
.detail-type { font-size: 12px; color: #94a3b8; }
|
||||
.detail-close { background: none; border: none; color: #64748b; font-size: 16px; cursor: pointer; padding: 4px; }
|
||||
.detail-close:hover { color: #e2e8f0; }
|
||||
.detail-desc { font-size: 13px; color: #94a3b8; line-height: 1.5; margin: 8px 0; }
|
||||
.detail-trust { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
|
||||
.trust-label { font-size: 11px; color: #64748b; min-width: 70px; }
|
||||
.trust-bar { flex: 1; height: 6px; background: #1e1e2e; border-radius: 3px; overflow: hidden; }
|
||||
.trust-fill { display: block; height: 100%; background: #7c3aed; border-radius: 3px; transition: width 0.3s; }
|
||||
.trust-val { font-size: 12px; font-weight: 700; color: #a78bfa; min-width: 24px; text-align: right; }
|
||||
.detail-section { font-size: 11px; font-weight: 600; color: #64748b; margin: 12px 0 6px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.detail-conn { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #e2e8f0; padding: 4px 0; }
|
||||
.conn-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.conn-role { font-size: 11px; color: #64748b; margin-left: auto; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.graph-canvas { height: 350px; }
|
||||
.workspace-list { grid-template-columns: 1fr; }
|
||||
|
|
@ -371,6 +483,8 @@ class FolkGraphViewer extends HTMLElement {
|
|||
`}
|
||||
</div>
|
||||
|
||||
${this.renderDetailPanel()}
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item"><span class="legend-dot dot-person"></span> People</div>
|
||||
<div class="legend-item"><span class="legend-dot dot-company"></span> Organizations</div>
|
||||
|
|
@ -406,6 +520,22 @@ class FolkGraphViewer extends HTMLElement {
|
|||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => this.render(), 200);
|
||||
});
|
||||
|
||||
// Node click → detail panel
|
||||
this.shadow.querySelectorAll("[data-node-id]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
const id = (el as HTMLElement).dataset.nodeId!;
|
||||
if (this.selectedNode?.id === id) { this.selectedNode = null; }
|
||||
else { this.selectedNode = this.nodes.find(n => n.id === id) || null; }
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Close detail panel
|
||||
this.shadow.getElementById("close-detail")?.addEventListener("click", () => {
|
||||
this.selectedNode = null;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ class FolkWorkBoard extends HTMLElement {
|
|||
private error = "";
|
||||
private isDemo = false;
|
||||
private dragTaskId: string | null = null;
|
||||
private editingTaskId: string | null = null;
|
||||
private showCreateForm = false;
|
||||
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -102,11 +105,11 @@ class FolkWorkBoard extends HTMLElement {
|
|||
} catch { this.error = "Failed to create workspace"; this.render(); }
|
||||
}
|
||||
|
||||
private async createTask() {
|
||||
const title = prompt("Task title:");
|
||||
if (!title?.trim()) return;
|
||||
private async submitCreateTask(title: string, priority: string, description: string) {
|
||||
if (!title.trim()) return;
|
||||
if (this.isDemo) {
|
||||
this.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: "TODO", priority: "MEDIUM", labels: [] });
|
||||
this.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: "TODO", priority, labels: [], description: description.trim() || undefined });
|
||||
this.showCreateForm = false;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
|
@ -115,12 +118,41 @@ class FolkWorkBoard extends HTMLElement {
|
|||
await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: title.trim() }),
|
||||
body: JSON.stringify({ title: title.trim(), priority, description: description.trim() || undefined }),
|
||||
});
|
||||
this.showCreateForm = false;
|
||||
this.loadTasks();
|
||||
} catch { this.error = "Failed to create task"; this.render(); }
|
||||
}
|
||||
|
||||
private async updateTask(taskId: string, fields: Record<string, string>) {
|
||||
if (this.isDemo) {
|
||||
const task = this.tasks.find(t => t.id === taskId);
|
||||
if (task) Object.assign(task, fields);
|
||||
this.editingTaskId = null;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
await fetch(`${base}/api/tasks/${taskId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
this.editingTaskId = null;
|
||||
this.loadTasks();
|
||||
} catch { this.error = "Failed to update task"; this.render(); }
|
||||
}
|
||||
|
||||
private cyclePriority(taskId: string) {
|
||||
const task = this.tasks.find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
const idx = this.priorities.indexOf(task.priority || "MEDIUM");
|
||||
const next = this.priorities[(idx + 1) % this.priorities.length];
|
||||
this.updateTask(taskId, { priority: next });
|
||||
}
|
||||
|
||||
private async moveTask(taskId: string, newStatus: string) {
|
||||
if (this.isDemo) {
|
||||
const task = this.tasks.find(t => t.id === taskId);
|
||||
|
|
@ -195,6 +227,25 @@ class FolkWorkBoard extends HTMLElement {
|
|||
.move-btn { font-size: 10px; padding: 2px 6px; border-radius: 4px; border: 1px solid #333; background: #16161e; color: #888; cursor: pointer; }
|
||||
.move-btn:hover { border-color: #555; color: #ccc; }
|
||||
|
||||
.create-form { background: #1a1a2e; border: 1px solid #4f46e5; border-radius: 8px; padding: 10px; margin-bottom: 10px; }
|
||||
.create-form input, .create-form select, .create-form textarea {
|
||||
width: 100%; padding: 6px 8px; border-radius: 6px; border: 1px solid #333;
|
||||
background: #16161e; color: #e0e0e0; font-size: 13px; margin-bottom: 6px; outline: none; font-family: inherit;
|
||||
}
|
||||
.create-form input:focus, .create-form select:focus, .create-form textarea:focus { border-color: #6366f1; }
|
||||
.create-form textarea { resize: vertical; min-height: 40px; }
|
||||
.create-form-actions { display: flex; gap: 6px; }
|
||||
.create-form-actions button { padding: 4px 12px; border-radius: 6px; border: none; font-size: 12px; cursor: pointer; font-weight: 600; }
|
||||
.cf-submit { background: #4f46e5; color: #fff; }
|
||||
.cf-cancel { background: transparent; color: #888; border: 1px solid #333 !important; }
|
||||
|
||||
.task-title-input {
|
||||
width: 100%; padding: 4px 6px; border-radius: 4px; border: 1px solid #6366f1;
|
||||
background: #16161e; color: #e0e0e0; font-size: 13px; font-weight: 500; outline: none; font-family: inherit;
|
||||
}
|
||||
.badge.clickable { cursor: pointer; transition: all 0.15s; }
|
||||
.badge.clickable:hover { filter: brightness(1.3); transform: scale(1.1); }
|
||||
|
||||
.empty { text-align: center; color: #666; padding: 40px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
|
@ -229,10 +280,29 @@ class FolkWorkBoard extends HTMLElement {
|
|||
`;
|
||||
}
|
||||
|
||||
private renderCreateForm(): string {
|
||||
if (!this.showCreateForm) return "";
|
||||
return `
|
||||
<div class="create-form" id="create-form">
|
||||
<input type="text" id="cf-title" placeholder="Task title..." autofocus>
|
||||
<select id="cf-priority">
|
||||
<option value="LOW">Low</option>
|
||||
<option value="MEDIUM" selected>Medium</option>
|
||||
<option value="HIGH">High</option>
|
||||
<option value="URGENT">Urgent</option>
|
||||
</select>
|
||||
<textarea id="cf-desc" placeholder="Description (optional)" rows="2"></textarea>
|
||||
<div class="create-form-actions">
|
||||
<button class="cf-submit" id="cf-submit">Add Task</button>
|
||||
<button class="cf-cancel" id="cf-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderBoard(): string {
|
||||
return `
|
||||
<div class="rapp-nav">
|
||||
<button class="rapp-nav__back" data-back="list">← Workspaces</button>
|
||||
<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>
|
||||
<span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span>
|
||||
<button class="rapp-nav__btn" id="create-task">+ New Task</button>
|
||||
</div>
|
||||
|
|
@ -245,6 +315,7 @@ class FolkWorkBoard extends HTMLElement {
|
|||
<span>${this.esc(status.replace(/_/g, " "))}</span>
|
||||
<span class="col-count">${columnTasks.length}</span>
|
||||
</div>
|
||||
${status === "TODO" ? this.renderCreateForm() : ""}
|
||||
${columnTasks.map(t => this.renderTaskCard(t, status)).join("")}
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -255,20 +326,23 @@ class FolkWorkBoard extends HTMLElement {
|
|||
|
||||
private renderTaskCard(task: any, currentStatus: string): string {
|
||||
const otherStatuses = this.statuses.filter(s => s !== currentStatus);
|
||||
const isEditing = this.editingTaskId === task.id;
|
||||
const priorityBadge = (p: string) => {
|
||||
const map: Record<string, string> = { URGENT: "badge-urgent", HIGH: "badge-high", MEDIUM: "badge-medium", LOW: "badge-low" };
|
||||
return map[p] ? `<span class="badge ${map[p]}">${this.esc(p.toLowerCase())}</span>` : "";
|
||||
return map[p] ? `<span class="badge clickable ${map[p]}" data-cycle-priority="${task.id}">${this.esc(p.toLowerCase())}</span>` : "";
|
||||
};
|
||||
return `
|
||||
<div class="task-card" draggable="true" data-task-id="${task.id}">
|
||||
<div class="task-title">${this.esc(task.title)}</div>
|
||||
<div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}">
|
||||
${isEditing
|
||||
? `<input class="task-title-input" data-edit-title="${task.id}" value="${this.esc(task.title)}">`
|
||||
: `<div class="task-title" data-start-edit="${task.id}" style="cursor:text">${this.esc(task.title)}</div>`}
|
||||
<div class="task-meta">
|
||||
${priorityBadge(task.priority || "")}
|
||||
${(task.labels || []).map((l: string) => `<span class="badge">${this.esc(l)}</span>`).join("")}
|
||||
</div>
|
||||
${task.assignee ? `<div style="font-size:11px;color:#888;margin-top:4px">${this.esc(task.assignee)}</div>` : ""}
|
||||
<div class="move-btns">
|
||||
${otherStatuses.map(s => `<button class="move-btn" data-move="${task.id}" data-to="${s}">→ ${this.esc(s.replace(/_/g, " ").substring(0, 8))}</button>`).join("")}
|
||||
${otherStatuses.map(s => `<button class="move-btn" data-move="${task.id}" data-to="${s}">\u2192 ${this.esc(s.replace(/_/g, " ").substring(0, 8))}</button>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -276,7 +350,66 @@ class FolkWorkBoard extends HTMLElement {
|
|||
|
||||
private attachListeners() {
|
||||
this.shadow.getElementById("create-ws")?.addEventListener("click", () => this.createWorkspace());
|
||||
this.shadow.getElementById("create-task")?.addEventListener("click", () => this.createTask());
|
||||
this.shadow.getElementById("create-task")?.addEventListener("click", () => {
|
||||
this.showCreateForm = !this.showCreateForm;
|
||||
this.render();
|
||||
if (this.showCreateForm) {
|
||||
setTimeout(() => this.shadow.getElementById("cf-title")?.focus(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Create form handlers
|
||||
this.shadow.getElementById("cf-submit")?.addEventListener("click", () => {
|
||||
const title = (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || "";
|
||||
const priority = (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM";
|
||||
const desc = (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "";
|
||||
this.submitCreateTask(title, priority, desc);
|
||||
});
|
||||
this.shadow.getElementById("cf-cancel")?.addEventListener("click", () => {
|
||||
this.showCreateForm = false; this.render();
|
||||
});
|
||||
this.shadow.getElementById("cf-title")?.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") {
|
||||
const title = (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || "";
|
||||
const priority = (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM";
|
||||
const desc = (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "";
|
||||
this.submitCreateTask(title, priority, desc);
|
||||
}
|
||||
});
|
||||
|
||||
// Inline title editing
|
||||
this.shadow.querySelectorAll("[data-start-edit]").forEach(el => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.editingTaskId = (el as HTMLElement).dataset.startEdit!;
|
||||
this.render();
|
||||
setTimeout(() => {
|
||||
const input = this.shadow.querySelector(`[data-edit-title="${this.editingTaskId}"]`) as HTMLInputElement;
|
||||
if (input) { input.focus(); input.select(); }
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
this.shadow.querySelectorAll("[data-edit-title]").forEach(el => {
|
||||
const handler = () => {
|
||||
const taskId = (el as HTMLElement).dataset.editTitle!;
|
||||
const val = (el as HTMLInputElement).value;
|
||||
if (val.trim()) this.updateTask(taskId, { title: val.trim() });
|
||||
else { this.editingTaskId = null; this.render(); }
|
||||
};
|
||||
el.addEventListener("blur", handler);
|
||||
el.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") handler();
|
||||
if ((e as KeyboardEvent).key === "Escape") { this.editingTaskId = null; this.render(); }
|
||||
});
|
||||
});
|
||||
|
||||
// Priority cycling
|
||||
this.shadow.querySelectorAll("[data-cycle-priority]").forEach(el => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.cyclePriority((el as HTMLElement).dataset.cyclePriority!);
|
||||
});
|
||||
});
|
||||
|
||||
this.shadow.querySelectorAll("[data-ws]").forEach(el => {
|
||||
el.addEventListener("click", () => this.openBoard((el as HTMLElement).dataset.ws!));
|
||||
|
|
|
|||
Loading…
Reference in New Issue