From bace5109d751d9fea60ebec45009b1e53427a4f7 Mon Sep 17 00:00:00 2001 From: spe1020 Date: Tue, 17 Feb 2026 22:22:33 -0500 Subject: [PATCH 1/2] Add Culinary Mesh v2 with engagement-tiered network visualization Engagement-tiered recipe network: hero/notable/community tiers based on zaps and likes, curved bezier edges for shared-culture connections, interactive node dragging with hover highlighting, click-through navigation for recipe and tag nodes, and a settle-then-reveal loading UX that runs the force simulation offscreen before fading in the final layout. Co-Authored-By: Claude Opus 4.6 --- package.json | 2 + pnpm-lock.yaml | 39 + src/components/CulinaryMesh.svelte | 910 ++++++++++++++++++++++ src/components/DesktopSideNav.svelte | 7 + src/components/MembershipBeltBadge.svelte | 111 +++ src/lib/meshUtils.ts | 404 ++++++++++ src/routes/mesh/+page.svelte | 312 ++++++++ src/routes/mesh/+page.ts | 1 + src/routes/user/[slug]/+page.svelte | 6 +- 9 files changed, 1791 insertions(+), 1 deletion(-) create mode 100644 src/components/CulinaryMesh.svelte create mode 100644 src/components/MembershipBeltBadge.svelte create mode 100644 src/lib/meshUtils.ts create mode 100644 src/routes/mesh/+page.svelte create mode 100644 src/routes/mesh/+page.ts diff --git a/package.json b/package.json index 09a22b09..ed6796db 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "bech32": "^2.0.0", "bip39": "^3.1.0", "buffer": "^6.0.3", + "d3-force": "^3.0.0", "date-fns": "^3.6.0", "dompurify": "^3.0.6", "dotenv": "^17.2.3", @@ -88,6 +89,7 @@ "@tailwindcss/forms": "^0.5.10", "@tailwindcss/postcss": "^4.1.13", "@tailwindcss/typography": "^0.5.19", + "@types/d3-force": "^3.0.10", "@types/dompurify": "^3.0.5", "@types/markdown-it": "^13.0.7", "@typescript-eslint/eslint-plugin": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67c7509a..89247705 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: buffer: specifier: ^6.0.3 version: 6.0.3 + d3-force: + specifier: ^3.0.0 + version: 3.0.0 date-fns: specifier: ^3.6.0 version: 3.6.0 @@ -198,6 +201,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.1.13) + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 '@types/dompurify': specifier: ^3.0.5 version: 3.2.0 @@ -1529,6 +1535,9 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/dompurify@3.2.0': resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. @@ -2245,6 +2254,22 @@ packages: engines: {node: '>=4'} hasBin: true + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + d@1.0.2: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} @@ -6372,6 +6397,8 @@ snapshots: '@types/cookie@0.6.0': {} + '@types/d3-force@3.0.10': {} + '@types/dompurify@3.2.0': dependencies: dompurify: 3.3.1 @@ -7201,6 +7228,18 @@ snapshots: cssesc@3.0.0: {} + d3-dispatch@3.0.1: {} + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-quadtree@3.0.1: {} + + d3-timer@3.0.1: {} + d@1.0.2: dependencies: es5-ext: 0.10.64 diff --git a/src/components/CulinaryMesh.svelte b/src/components/CulinaryMesh.svelte new file mode 100644 index 00000000..b6820078 --- /dev/null +++ b/src/components/CulinaryMesh.svelte @@ -0,0 +1,910 @@ + + + + + diff --git a/src/components/DesktopSideNav.svelte b/src/components/DesktopSideNav.svelte index de2dfef4..6b1acf86 100644 --- a/src/components/DesktopSideNav.svelte +++ b/src/components/DesktopSideNav.svelte @@ -11,6 +11,7 @@ import ChatCircleDotsIcon from 'phosphor-svelte/lib/ChatCircleDots'; import ForkKnifeIcon from 'phosphor-svelte/lib/ForkKnife'; import CompassIcon from 'phosphor-svelte/lib/Compass'; + import GraphIcon from 'phosphor-svelte/lib/Graph'; import BellIcon from 'phosphor-svelte/lib/Bell'; import NewspaperIcon from 'phosphor-svelte/lib/Newspaper'; import EnvelopeSimpleIcon from 'phosphor-svelte/lib/EnvelopeSimple'; @@ -59,6 +60,12 @@ icon: CompassIcon, match: (p) => p.startsWith('/explore') }, + { + href: '/mesh', + label: 'Culinary Mesh', + icon: GraphIcon, + match: (p) => p.startsWith('/mesh') + }, { href: '/reads', label: 'Reads', diff --git a/src/components/MembershipBeltBadge.svelte b/src/components/MembershipBeltBadge.svelte new file mode 100644 index 00000000..e7e999dd --- /dev/null +++ b/src/components/MembershipBeltBadge.svelte @@ -0,0 +1,111 @@ + + +{#if isActiveMember} + + + + + + + + + + + + + + +{/if} diff --git a/src/lib/meshUtils.ts b/src/lib/meshUtils.ts new file mode 100644 index 00000000..3e84c5b9 --- /dev/null +++ b/src/lib/meshUtils.ts @@ -0,0 +1,404 @@ +import { ndk } from '$lib/nostr'; +import { get } from 'svelte/store'; +import type { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk'; +import { validateMarkdownTemplate } from '$lib/parser'; +import { + RECIPE_TAGS, + RECIPE_TAG_PREFIX_NEW, + RECIPE_TAG_PREFIX_LEGACY, + CURATED_TAG_SECTIONS, + TAG_ALIASES, + recipeTags +} from '$lib/consts'; +import { getImageOrPlaceholder } from '$lib/placeholderImages'; +import { nip19 } from 'nostr-tools'; + +// ── Types ────────────────────────────────────────────────────── + +export type RecipeNode = { + id: string; + type: 'recipe'; + event: NDKEvent; + image: string; + title: string; + link: string; + tags: string[]; + score: number; + tier: 1 | 2 | 3; + zaps: number; + likes: number; + x?: number; + y?: number; +}; + +export type TagNode = { + id: string; + type: 'tag'; + name: string; + emoji: string; + sectionTitle: string; + sectionIndex: number; + count: number; + x?: number; + y?: number; +}; + +export type MeshNode = RecipeNode | TagNode; + +export type MeshEdge = { + source: string | MeshNode; + target: string | MeshNode; + edgeType: 'recipe-tag' | 'recipe-recipe'; + weight: number; +}; + +export type EngagementMap = Map; + +// ── Build a flat set of all curated tag names ────────────────── + +const CURATED_TAG_SET = new Set( + CURATED_TAG_SECTIONS.flatMap((s) => s.tags) +); + +// ── Build emoji lookup from recipeTags ───────────────────────── + +const TAG_EMOJI_MAP = new Map(); +for (const t of recipeTags) { + if (t.emoji) TAG_EMOJI_MAP.set(t.title, t.emoji); +} + +// ── Fetch recipes ────────────────────────────────────────────── + +export async function fetchMeshRecipes(): Promise { + const ndkInstance = get(ndk); + if (!ndkInstance) return []; + + const filter: NDKFilter = { + limit: 500, + kinds: [30023 as number], + '#t': RECIPE_TAGS + }; + + const recipes: NDKEvent[] = []; + const subscription = ndkInstance.subscribe(filter, { closeOnEose: true }); + + return new Promise((resolve) => { + let resolved = false; + + const finalize = () => { + if (resolved) return; + resolved = true; + subscription.stop(); + resolve(recipes); + }; + + const timeout = setTimeout(finalize, 8000); + + subscription.on('event', (event: NDKEvent) => { + if (typeof validateMarkdownTemplate(event.content) !== 'string' && event.author?.pubkey) { + recipes.push(event); + } + if (recipes.length >= 150) { + clearTimeout(timeout); + finalize(); + } + }); + + subscription.on('eose', () => { + clearTimeout(timeout); + finalize(); + }); + }); +} + +// ── Fetch engagement (likes + zaps) in batches ───────────────── + +export async function fetchMeshEngagement(recipes: NDKEvent[]): Promise { + const ndkInstance = get(ndk); + if (!ndkInstance) return new Map(); + + // Build aTag for each recipe and map aTag → eventId + const aTagToEventId = new Map(); + const allATags: string[] = []; + + for (const recipe of recipes) { + const dTag = recipe.tags.find((t) => t[0] === 'd')?.[1]; + if (!dTag || !recipe.author?.pubkey) continue; + + const aTag = `30023:${recipe.author.pubkey}:${dTag}`; + aTagToEventId.set(aTag, recipe.id); + allATags.push(aTag); + } + + // Per-recipe counters + const counters = new Map(); + for (const eventId of aTagToEventId.values()) { + counters.set(eventId, { likes: 0, zaps: 0 }); + } + + // Chunk aTags into groups of 40 + const chunks: string[][] = []; + for (let i = 0; i < allATags.length; i += 40) { + chunks.push(allATags.slice(i, i + 40)); + } + + // Process all chunks in parallel + await Promise.all( + chunks.map( + (chunk) => + new Promise((resolve) => { + const subscription = ndkInstance.subscribe( + { kinds: [7, 9735], '#a': chunk } as NDKFilter, + { closeOnEose: true } + ); + + const timeout = setTimeout(() => { + subscription.stop(); + resolve(); + }, 4000); + + subscription.on('event', (event: NDKEvent) => { + // Find which aTag this event references + const aTags = event.tags.filter((t) => t[0] === 'a').map((t) => t[1]); + for (const aTag of aTags) { + const eventId = aTagToEventId.get(aTag); + if (!eventId) continue; + const c = counters.get(eventId); + if (!c) continue; + + if (event.kind === 7) { + c.likes++; + } else if (event.kind === 9735) { + c.zaps++; + } + } + }); + + subscription.on('eose', () => { + clearTimeout(timeout); + subscription.stop(); + resolve(); + }); + }) + ) + ); + + // Build engagement map with scores + const result: EngagementMap = new Map(); + for (const [eventId, { likes, zaps }] of counters) { + const score = zaps * 3 + likes; + result.set(eventId, { likes, zaps, score }); + } + + return result; +} + +// ── Extract normalized curated tags from an event ────────────── + +export function extractRecipeTags(event: NDKEvent): string[] { + const prefixNew = RECIPE_TAG_PREFIX_NEW + '-'; + const prefixLegacy = RECIPE_TAG_PREFIX_LEGACY + '-'; + + const raw = event.tags + .filter((t) => t[0] === 't') + .map((t) => t[1] || '') + .filter(Boolean); + + const seen = new Set(); + const result: string[] = []; + + for (const tag of raw) { + let stripped = tag; + + // Strip prefixes (case-insensitive) + const lower = stripped.toLowerCase(); + if (lower.startsWith(prefixNew.toLowerCase())) { + stripped = stripped.slice(prefixNew.length); + } else if (lower.startsWith(prefixLegacy.toLowerCase())) { + stripped = stripped.slice(prefixLegacy.length); + } + + // Title-case: first letter upper, rest lower, per word + stripped = stripped + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join('-'); + + // Apply aliases + if (TAG_ALIASES[stripped]) { + stripped = TAG_ALIASES[stripped]; + } + + // Keep only curated tags, deduplicate + if (CURATED_TAG_SET.has(stripped) && !seen.has(stripped)) { + seen.add(stripped); + result.push(stripped); + } + } + + return result; +} + +// ── Build the mesh graph ─────────────────────────────────────── + +export function buildMeshGraph( + recipes: NDKEvent[], + engagement?: EngagementMap +): { nodes: MeshNode[]; edges: MeshEdge[] } { + const nodes: MeshNode[] = []; + const edges: MeshEdge[] = []; + const tagCounts = new Map(); + + // First pass: create recipe nodes and count tags + const recipeNodes: RecipeNode[] = []; + + for (const event of recipes) { + const tags = extractRecipeTags(event); + if (tags.length === 0) continue; + + const imageUrl = event.tags.find((t) => t[0] === 'image')?.[1]; + const image = getImageOrPlaceholder(imageUrl, event.id); + const title = + event.tags.find((t) => t[0] === 'title')?.[1] || + event.tags.find((t) => t[0] === 'd')?.[1] || + 'Untitled'; + + const d = event.tags.find((t) => t[0] === 'd')?.[1] || ''; + const pubkey = event.author?.pubkey || ''; + let link = '#'; + try { + const naddr = nip19.naddrEncode({ identifier: d, kind: 30023, pubkey }); + link = `/recipe/${naddr}`; + } catch { + // skip encoding errors + } + + const eng = engagement?.get(event.id); + const likes = eng?.likes ?? 0; + const zaps = eng?.zaps ?? 0; + const score = eng?.score ?? 0; + + const node: RecipeNode = { + id: `recipe-${event.id}`, + type: 'recipe', + event, + image, + title, + link, + tags, + score, + tier: 3, // default, will be reassigned below + zaps, + likes + }; + + recipeNodes.push(node); + + for (const tag of tags) { + tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); + } + } + + // ── Tiering ──────────────────────────────────────────────────── + // Sort by score descending, assign tiers + const sorted = [...recipeNodes].sort((a, b) => b.score - a.score); + for (let i = 0; i < sorted.length; i++) { + if (i < 8) { + sorted[i].tier = 1; // Hero + } else if (i < 33) { + sorted[i].tier = 2; // Notable + } else { + sorted[i].tier = 3; // Community + } + } + + // Second pass: create tag nodes + const tagNodeMap = new Map(); + + for (const [sectionIndex, section] of CURATED_TAG_SECTIONS.entries()) { + for (const tagName of section.tags) { + const count = tagCounts.get(tagName) || 0; + if (count === 0) continue; + if (tagNodeMap.has(tagName)) continue; // tag may appear in multiple sections + + const emoji = TAG_EMOJI_MAP.get(tagName) || ''; + const tagNode: TagNode = { + id: `tag-${tagName}`, + type: 'tag', + name: tagName, + emoji, + sectionTitle: section.title, + sectionIndex, + count + }; + + tagNodeMap.set(tagName, tagNode); + } + } + + // Assemble nodes + nodes.push(...tagNodeMap.values(), ...recipeNodes); + + // ── Recipe-tag edges ─────────────────────────────────────────── + for (const recipe of recipeNodes) { + for (const tag of recipe.tags) { + const tagNode = tagNodeMap.get(tag); + if (tagNode) { + edges.push({ + source: recipe.id, + target: tagNode.id, + edgeType: 'recipe-tag', + weight: 1 + }); + } + } + } + + // ── Recipe-recipe edges (shared tags) ────────────────────────── + // Build inverse map: tag → set of recipe node indices + const tagToRecipeIndices = new Map(); + for (let i = 0; i < recipeNodes.length; i++) { + for (const tag of recipeNodes[i].tags) { + let list = tagToRecipeIndices.get(tag); + if (!list) { + list = []; + tagToRecipeIndices.set(tag, list); + } + list.push(i); + } + } + + // Count shared tags between pairs + const sharedCounts = new Map(); + for (const indices of tagToRecipeIndices.values()) { + for (let a = 0; a < indices.length; a++) { + for (let b = a + 1; b < indices.length; b++) { + const key = `${Math.min(indices[a], indices[b])}-${Math.max(indices[a], indices[b])}`; + sharedCounts.set(key, (sharedCounts.get(key) || 0) + 1); + } + } + } + + // Keep pairs with sharedCount >= 2, score and sort + const candidatePairs: { key: string; idxA: number; idxB: number; shared: number; pairScore: number }[] = []; + for (const [key, shared] of sharedCounts) { + if (shared < 2) continue; + const [a, b] = key.split('-').map(Number); + const pairScore = shared * (recipeNodes[a].score + recipeNodes[b].score); + candidatePairs.push({ key, idxA: a, idxB: b, shared, pairScore }); + } + + candidatePairs.sort((a, b) => b.pairScore - a.pairScore); + const topPairs = candidatePairs.slice(0, 250); + + for (const pair of topPairs) { + edges.push({ + source: recipeNodes[pair.idxA].id, + target: recipeNodes[pair.idxB].id, + edgeType: 'recipe-recipe', + weight: pair.shared + }); + } + + return { nodes, edges }; +} diff --git a/src/routes/mesh/+page.svelte b/src/routes/mesh/+page.svelte new file mode 100644 index 00000000..0aa27361 --- /dev/null +++ b/src/routes/mesh/+page.svelte @@ -0,0 +1,312 @@ + + + + Culinary Mesh - zap.cooking + + + + + +
+ +
+
+

+ Culinary Mesh +

+ {#if !loading && !error} +

+ {recipeCount} recipes · {tagCount} tags · {connectionCount} connections +

+ {/if} +
+
+ + +
+ {#if error} +
+

{error}

+ +
+ {:else} + + {#if dataReady} + + {/if} + + + {#if loading} +
+
+
+

+ {#if loadingPhase === 'recipes'} + Discovering recipes... + {:else if loadingPhase === 'engagement'} + Mapping community engagement... + {:else if loadingPhase === 'settling'} + Arranging the mesh... + {:else} + Loading the culinary mesh... + {/if} +

+
+
+ {/if} + + + {#if !loading && !error} +
+
+ + Most loved +
+
+ + Notable +
+
+ + Community +
+
+ + + + Shared culture +
+
+ + +
+ +
+ +
+ +
+ {/if} + {/if} +
+
+ + diff --git a/src/routes/mesh/+page.ts b/src/routes/mesh/+page.ts new file mode 100644 index 00000000..a3d15781 --- /dev/null +++ b/src/routes/mesh/+page.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/src/routes/user/[slug]/+page.svelte b/src/routes/user/[slug]/+page.svelte index f1c212bf..14c1bd02 100644 --- a/src/routes/user/[slug]/+page.svelte +++ b/src/routes/user/[slug]/+page.svelte @@ -37,6 +37,7 @@ import { profileCacheManager } from '$lib/profileCache'; import { RECIPE_TAGS } from '$lib/consts'; import ArticleFeed from '../../../components/ArticleFeed.svelte'; + import MembershipBeltBadge from '../../../components/MembershipBeltBadge.svelte'; export const data: PageData = {} as PageData; @@ -1322,7 +1323,10 @@
{#if user?.npub} diff --git a/src/components/mesh/MeshCanvas.svelte b/src/components/mesh/MeshCanvas.svelte new file mode 100644 index 00000000..6e0adcb4 --- /dev/null +++ b/src/components/mesh/MeshCanvas.svelte @@ -0,0 +1,187 @@ + + + diff --git a/src/components/mesh/MeshControlPanel.svelte b/src/components/mesh/MeshControlPanel.svelte new file mode 100644 index 00000000..e6332eb7 --- /dev/null +++ b/src/components/mesh/MeshControlPanel.svelte @@ -0,0 +1,401 @@ + + +
+ +
+
+

+ {isConstellation ? 'Star Map' : 'Culinary Mesh'} +

+ {#if !loading && !hasError} +

+ {recipeCount} recipes · {tagCount} tags · {connectionCount} connections +

+ {/if} +
+ +
+ + + + + +
+
+ + + {#if showFilters} +
+ +
+ + {#if searchValue} + + {/if} +
+ + +
+ Layers: +
+ + + +
+
+ + + {#if cuisineTags.length > 0} +
+ Cuisine: +
+ {#each cuisineTags.slice(0, 12) as tag} + + {/each} +
+
+ {/if} + + + {#if hasActiveFilters} +
+ +
+ {/if} +
+ {/if} +
+ + diff --git a/src/components/mesh/MeshDetailDrawer.svelte b/src/components/mesh/MeshDetailDrawer.svelte new file mode 100644 index 00000000..9ada6992 --- /dev/null +++ b/src/components/mesh/MeshDetailDrawer.svelte @@ -0,0 +1,259 @@ + + + + + +