Extract shared graph SPA
This commit is contained in:
parent
9d138616b1
commit
2c773a2a8c
13 changed files with 2556 additions and 16 deletions
16
README.md
16
README.md
|
|
@ -6,6 +6,7 @@ Shared Quartz engine, reusable GitHub Pages workflow, and the minimum amount of
|
|||
|
||||
- Shared Quartz source, dependencies, and build tooling
|
||||
- `scripts/build-site.mjs` for staging a site repo against the shared engine
|
||||
- `scripts/build-graph-spa.mjs` for building the shared fullscreen graph SPA into a consumer site
|
||||
- Reusable Pages workflow at `.github/workflows/quartz-pages.yml`
|
||||
- Generic defaults only
|
||||
|
||||
|
|
@ -39,6 +40,21 @@ Then from a consumer repo, use its launcher script, or call the shared builder d
|
|||
node ..\\GameCult-Quartz\\scripts\\build-site.mjs build --siteRoot E:\\Projects\\gamecult-site --contentDir GameCult --overlayDir site --outputDir quartz-site/public
|
||||
```
|
||||
|
||||
## Fullscreen Graph SPA
|
||||
|
||||
Consumer sites can render the shared graph surface with `Component.GameCultGraphSpaShell(...)`.
|
||||
The component only owns the mount point and asset tags; the consumer overlay decides where it appears.
|
||||
|
||||
Build the static SPA assets into the consumer repo when its graph payload changes:
|
||||
|
||||
```powershell
|
||||
node ..\\GameCult-Quartz\\scripts\\build-graph-spa.mjs --siteRoot E:\\Projects\\Mimir --outputDir static/epiphany-graph
|
||||
```
|
||||
|
||||
The SPA source lives in `quartz/graph-spa`. It currently imports the shared `EpiphanyGraphViewer`
|
||||
from a sibling `EpiphanyGraph` checkout during local builds, so generated assets should be committed
|
||||
by consumer sites until that viewer is packaged as a normal dependency.
|
||||
|
||||
## Shared Components
|
||||
|
||||
### `AutoIndexFolder`
|
||||
|
|
|
|||
1241
package-lock.json
generated
1241
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -40,11 +40,13 @@
|
|||
"@myriaddreamin/rehype-typst": "^0.6.0",
|
||||
"@napi-rs/simple-git": "0.1.22",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"ansi-truncate": "^1.4.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"cli-spinner": "^0.2.10",
|
||||
"d3": "^7.9.0",
|
||||
"elkjs": "^0.11.1",
|
||||
"esbuild-sass-plugin": "^3.6.0",
|
||||
"flexsearch": "^0.8.205",
|
||||
"github-slugger": "^2.0.0",
|
||||
|
|
@ -66,6 +68,8 @@
|
|||
"preact-render-to-string": "^6.6.5",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"pretty-time": "^1.1.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-citation": "^2.3.1",
|
||||
|
|
@ -93,6 +97,7 @@
|
|||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"vfile": "^6.0.3",
|
||||
"vite": "^7.3.3",
|
||||
"workerpool": "^10.0.1",
|
||||
"ws": "^8.19.0",
|
||||
"yargs": "^18.0.0"
|
||||
|
|
|
|||
26
quartz/components/GameCultGraphSpaShell.tsx
Normal file
26
quartz/components/GameCultGraphSpaShell.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { QuartzComponent, QuartzComponentConstructor } from "./types"
|
||||
|
||||
export type GameCultGraphSpaShellOptions = {
|
||||
stylesheetHref: string
|
||||
moduleSrc: string
|
||||
rootClassName?: string
|
||||
}
|
||||
|
||||
export default ((options?: Partial<GameCultGraphSpaShellOptions>) => {
|
||||
const shellOptions: GameCultGraphSpaShellOptions = {
|
||||
stylesheetHref: "/static/epiphany-graph/assets/viewer.css",
|
||||
moduleSrc: "/static/epiphany-graph/assets/viewer.js",
|
||||
rootClassName: "gamecult-epiphany-graph-root",
|
||||
...options,
|
||||
}
|
||||
|
||||
const GameCultGraphSpaShell: QuartzComponent = () => (
|
||||
<>
|
||||
<link rel="stylesheet" href={shellOptions.stylesheetHref} />
|
||||
<div class={shellOptions.rootClassName}></div>
|
||||
<script type="module" src={shellOptions.moduleSrc}></script>
|
||||
</>
|
||||
)
|
||||
|
||||
return GameCultGraphSpaShell
|
||||
}) satisfies QuartzComponentConstructor<Partial<GameCultGraphSpaShellOptions> | undefined>
|
||||
|
|
@ -24,6 +24,7 @@ import Comments from "./Comments"
|
|||
import Flex from "./Flex"
|
||||
import ConditionalRender from "./ConditionalRender"
|
||||
import AutoIndexFolder from "./AutoIndexFolder"
|
||||
import GameCultGraphSpaShell from "./GameCultGraphSpaShell"
|
||||
|
||||
export {
|
||||
ArticleTitle,
|
||||
|
|
@ -52,4 +53,7 @@ export {
|
|||
Comments,
|
||||
Flex,
|
||||
ConditionalRender,
|
||||
GameCultGraphSpaShell,
|
||||
}
|
||||
|
||||
export type { GameCultGraphSpaShellOptions } from "./GameCultGraphSpaShell"
|
||||
|
|
|
|||
18
quartz/graph-spa/package.json
Normal file
18
quartz/graph-spa/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@gamecult/quartz-graph-spa",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build --base ./"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"elkjs": "^0.11.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
826
quartz/graph-spa/src/main.tsx
Normal file
826
quartz/graph-spa/src/main.tsx
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
import React from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { EpiphanyGraphViewer } from "../../../../EpiphanyGraph/web/epiphany-graph-viewer/src/lib/EpiphanyGraphViewer"
|
||||
import type {
|
||||
EpiphanyGraphEdge,
|
||||
EpiphanyGraphNode,
|
||||
EpiphanyGraphsState,
|
||||
GraphKey,
|
||||
PositionedNode,
|
||||
ViewerSelection,
|
||||
} from "../../../../EpiphanyGraph/web/epiphany-graph-viewer/src/lib/types"
|
||||
import "./styles.css"
|
||||
|
||||
type QuartzContentEntry = {
|
||||
slug: string
|
||||
filePath?: string
|
||||
title?: string
|
||||
links?: string[]
|
||||
tags?: string[]
|
||||
content?: string
|
||||
}
|
||||
|
||||
type QuartzContentIndex = Record<string, QuartzContentEntry>
|
||||
|
||||
type ArticleState =
|
||||
| {
|
||||
status: "idle"
|
||||
html: string | null
|
||||
}
|
||||
| {
|
||||
status: "loading"
|
||||
html: string | null
|
||||
}
|
||||
| {
|
||||
status: "ready"
|
||||
html: string
|
||||
}
|
||||
| {
|
||||
status: "error"
|
||||
html: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
fetchData?: Promise<QuartzContentIndex>
|
||||
}
|
||||
}
|
||||
|
||||
type GraphErrorBoundaryState = {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
class GraphErrorBoundary extends React.Component<React.PropsWithChildren, GraphErrorBoundaryState> {
|
||||
state: GraphErrorBoundaryState = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<pre className="gamecult-graph-error">
|
||||
{this.state.error.message}
|
||||
{"\n"}
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
const SECTION_ORDER = [
|
||||
"World",
|
||||
"Ecology",
|
||||
"Species",
|
||||
"Civilizations",
|
||||
"Technologies",
|
||||
"Conflicts",
|
||||
"Themes",
|
||||
"Root",
|
||||
]
|
||||
|
||||
function entryTitle(slug: string, entry?: QuartzContentEntry) {
|
||||
if (entry?.title) {
|
||||
return entry.title
|
||||
}
|
||||
|
||||
return slug
|
||||
.split("/")
|
||||
.at(-1)!
|
||||
.replace(/-/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
function normalizeSlug(slug: string) {
|
||||
if (slug.endsWith("/")) {
|
||||
return `${slug.slice(0, -1)}/index`
|
||||
}
|
||||
|
||||
return slug
|
||||
}
|
||||
|
||||
function sectionForSlug(slug: string) {
|
||||
return slug.includes("/") ? slug.split("/")[0] : "Root"
|
||||
}
|
||||
|
||||
function slugToArticleUrl(slug: string) {
|
||||
if (slug === "index") {
|
||||
return "/index.html"
|
||||
}
|
||||
|
||||
return `/${slug.split("/").map(encodeURIComponent).join("/")}.html`
|
||||
}
|
||||
|
||||
function slugFromArticleUrl(url: string, slugs: Set<string>) {
|
||||
const parsed = new URL(url, window.location.href)
|
||||
|
||||
if (parsed.origin !== window.location.origin) {
|
||||
return null
|
||||
}
|
||||
|
||||
const decodedPath = decodeURIComponent(parsed.pathname)
|
||||
.replace(/^\//, "")
|
||||
.replace(/\.html$/, "")
|
||||
.replace(/\/$/, "/index")
|
||||
const slug = normalizeSlug(decodedPath || "index")
|
||||
|
||||
return slugs.has(slug) ? slug : null
|
||||
}
|
||||
|
||||
function parseHashSelection(slugs: Set<string>): ViewerSelection | null {
|
||||
const match = window.location.hash.match(/^#note=(.+)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const slug = normalizeSlug(decodeURIComponent(match[1]))
|
||||
|
||||
if (!slugs.has(slug)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "node",
|
||||
graphKey: "architecture",
|
||||
nodeId: slug,
|
||||
}
|
||||
}
|
||||
|
||||
function currentRouteSelection(slugs: Set<string>): ViewerSelection | null {
|
||||
const bodySlug = document.body.dataset.slug
|
||||
const pathSlug = decodeURIComponent(window.location.pathname)
|
||||
.replace(/^\//, "")
|
||||
.replace(/\.html$/, "")
|
||||
.replace(/\/$/, "/index")
|
||||
const slug = normalizeSlug(bodySlug || pathSlug || "index")
|
||||
|
||||
if (!slugs.has(slug)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "node",
|
||||
graphKey: "architecture",
|
||||
nodeId: slug,
|
||||
}
|
||||
}
|
||||
|
||||
function compareSections(left: string, right: string) {
|
||||
const leftIndex = SECTION_ORDER.indexOf(left)
|
||||
const rightIndex = SECTION_ORDER.indexOf(right)
|
||||
|
||||
if (leftIndex !== -1 || rightIndex !== -1) {
|
||||
const orderedLeft = leftIndex === -1 ? SECTION_ORDER.length : leftIndex
|
||||
const orderedRight = rightIndex === -1 ? SECTION_ORDER.length : rightIndex
|
||||
|
||||
return orderedLeft - orderedRight
|
||||
}
|
||||
|
||||
return left.localeCompare(right)
|
||||
}
|
||||
|
||||
function summarizeContent(entry: QuartzContentEntry) {
|
||||
const text = (entry.content ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^Table of contents\s*/i, "")
|
||||
.trim()
|
||||
|
||||
if (!text) {
|
||||
return `Vault note at ${entry.filePath ?? entry.slug}.`
|
||||
}
|
||||
|
||||
const sentenceEnd = text.search(/[.!?](\s|$)/)
|
||||
const sentence = sentenceEnd >= 0 ? text.slice(0, sentenceEnd + 1) : text
|
||||
|
||||
if (sentence.length <= 220) {
|
||||
return sentence
|
||||
}
|
||||
|
||||
return `${sentence.slice(0, 217).trim()}...`
|
||||
}
|
||||
|
||||
function validLinkedSlugs(entry: QuartzContentEntry, slugs: Set<string>) {
|
||||
return (entry.links ?? [])
|
||||
.map(normalizeSlug)
|
||||
.filter(
|
||||
(targetSlug, index, links) => slugs.has(targetSlug) && links.indexOf(targetSlug) === index,
|
||||
)
|
||||
}
|
||||
|
||||
function buildIncomingLinks(entries: Array<[string, QuartzContentEntry]>, slugs: Set<string>) {
|
||||
const incoming = new Map<string, string[]>()
|
||||
|
||||
for (const [slug] of entries) {
|
||||
incoming.set(slug, [])
|
||||
}
|
||||
|
||||
for (const [sourceSlug, entry] of entries) {
|
||||
for (const targetSlug of validLinkedSlugs(entry, slugs)) {
|
||||
incoming.get(targetSlug)?.push(sourceSlug)
|
||||
}
|
||||
}
|
||||
|
||||
return incoming
|
||||
}
|
||||
|
||||
function buildSectionEdges(entries: Array<[string, QuartzContentEntry]>, slugs: Set<string>) {
|
||||
const edgeCounts = new Map<string, number>()
|
||||
|
||||
for (const [sourceSlug, entry] of entries) {
|
||||
const sourceSection = sectionForSlug(sourceSlug)
|
||||
|
||||
for (const targetSlug of validLinkedSlugs(entry, slugs)) {
|
||||
const targetSection = sectionForSlug(targetSlug)
|
||||
const edgeId = `${sourceSection}->${targetSection}`
|
||||
edgeCounts.set(edgeId, (edgeCounts.get(edgeId) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(edgeCounts.entries()).map(([edgeId, count]) => {
|
||||
const [sourceSection, targetSection] = edgeId.split("->")
|
||||
|
||||
return {
|
||||
id: `section:${edgeId}`,
|
||||
source_id: `section:${sourceSection}`,
|
||||
target_id: `section:${targetSection}`,
|
||||
kind: sourceSection === targetSection ? "internal_links" : "cross_links",
|
||||
label: `${count} ${count === 1 ? "link" : "links"}`,
|
||||
mechanism:
|
||||
sourceSection === targetSection
|
||||
? `${count} wiki ${count === 1 ? "link stays" : "links stay"} inside ${sourceSection}.`
|
||||
: `${count} wiki ${count === 1 ? "link runs" : "links run"} from ${sourceSection} into ${targetSection}.`,
|
||||
} satisfies EpiphanyGraphEdge
|
||||
})
|
||||
}
|
||||
|
||||
function buildGraphState(contentIndex: QuartzContentIndex): EpiphanyGraphsState {
|
||||
const entries = Object.entries(contentIndex)
|
||||
.map(([slug, entry]) => {
|
||||
const normalizedSlug = normalizeSlug(entry.slug || slug)
|
||||
|
||||
return [normalizedSlug, { ...entry, slug: normalizedSlug }] as [string, QuartzContentEntry]
|
||||
})
|
||||
.filter(([slug]) => !slug.startsWith("Inspirations/"))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
|
||||
const entryBySlug = new Map(entries)
|
||||
const slugs = new Set(entries.map(([slug]) => slug))
|
||||
const incoming = buildIncomingLinks(entries, slugs)
|
||||
|
||||
const architectureNodes: EpiphanyGraphNode[] = entries.map(([slug, entry]) => {
|
||||
const outgoingCount = validLinkedSlugs(entry, slugs).length
|
||||
const backlinkCount = incoming.get(slug)?.length ?? 0
|
||||
|
||||
return {
|
||||
id: slug,
|
||||
title: entryTitle(slug, entry),
|
||||
status: backlinkCount > 0 ? "linked" : "source",
|
||||
purpose: summarizeContent(entry),
|
||||
mechanism: `${outgoingCount} outgoing wiki ${outgoingCount === 1 ? "link" : "links"}; ${backlinkCount} incoming ${backlinkCount === 1 ? "backlink" : "backlinks"}. Folder: ${sectionForSlug(slug)}.`,
|
||||
metaphor: entry.filePath ?? slug,
|
||||
}
|
||||
})
|
||||
|
||||
const architectureEdges: EpiphanyGraphEdge[] = entries.flatMap(([sourceSlug, entry]) =>
|
||||
validLinkedSlugs(entry, slugs).map((targetSlug, index) => ({
|
||||
id: `${sourceSlug}->${targetSlug}:${index}`,
|
||||
source_id: sourceSlug,
|
||||
target_id: targetSlug,
|
||||
kind: "wiki_link",
|
||||
label: "links to",
|
||||
mechanism: `${entryTitle(sourceSlug, entry)} links to ${entryTitle(targetSlug, entryBySlug.get(targetSlug))}.`,
|
||||
})),
|
||||
)
|
||||
|
||||
const sections = Array.from(new Set(entries.map(([slug]) => sectionForSlug(slug)))).sort(
|
||||
compareSections,
|
||||
)
|
||||
const dataflowNodes: EpiphanyGraphNode[] = sections.map((section) => {
|
||||
const sectionEntries = entries.filter(([slug]) => sectionForSlug(slug) === section)
|
||||
const sectionSlugs = new Set(sectionEntries.map(([slug]) => slug))
|
||||
const outgoingCrossSection = sectionEntries.reduce((count, [, entry]) => {
|
||||
return (
|
||||
count +
|
||||
validLinkedSlugs(entry, slugs).filter((targetSlug) => !sectionSlugs.has(targetSlug)).length
|
||||
)
|
||||
}, 0)
|
||||
const incomingCrossSection = entries.reduce((count, [sourceSlug, entry]) => {
|
||||
if (sectionForSlug(sourceSlug) === section) {
|
||||
return count
|
||||
}
|
||||
|
||||
return (
|
||||
count +
|
||||
validLinkedSlugs(entry, slugs).filter((targetSlug) => sectionSlugs.has(targetSlug)).length
|
||||
)
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
id: `section:${section}`,
|
||||
title: section,
|
||||
status: "active",
|
||||
purpose: `${sectionEntries.length} ${sectionEntries.length === 1 ? "note" : "notes"} in this vault section.`,
|
||||
mechanism: `${outgoingCrossSection} outgoing cross-section ${outgoingCrossSection === 1 ? "link" : "links"}; ${incomingCrossSection} incoming cross-section ${incomingCrossSection === 1 ? "backlink" : "backlinks"}.`,
|
||||
metaphor: "Quartz backlink cluster",
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
architecture: {
|
||||
nodes: architectureNodes,
|
||||
edges: architectureEdges,
|
||||
},
|
||||
dataflow: {
|
||||
nodes: dataflowNodes,
|
||||
edges: buildSectionEdges(entries, slugs),
|
||||
},
|
||||
links: entries.map(([slug]) => ({
|
||||
architecture_node_id: slug,
|
||||
dataflow_node_id: `section:${sectionForSlug(slug)}`,
|
||||
relationship: "section membership",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGraphState() {
|
||||
const contentIndex = window.fetchData
|
||||
? await window.fetchData
|
||||
: await fetch("/static/contentIndex.json").then(
|
||||
(response) => response.json() as Promise<QuartzContentIndex>,
|
||||
)
|
||||
|
||||
return {
|
||||
contentIndex,
|
||||
graphState: buildGraphState(contentIndex),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticleHtml(slug: string) {
|
||||
const response = await fetch(slugToArticleUrl(slug))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not load ${slug}: ${response.status}`)
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
const documentHtml = new DOMParser().parseFromString(html, "text/html")
|
||||
const article = documentHtml.querySelector("article")
|
||||
|
||||
if (!article) {
|
||||
throw new Error(`No article body found for ${slug}`)
|
||||
}
|
||||
|
||||
const articleUrl = new URL(slugToArticleUrl(slug), window.location.href)
|
||||
|
||||
article.querySelectorAll<HTMLAnchorElement>("a[href]").forEach((anchor) => {
|
||||
anchor.href = new URL(anchor.getAttribute("href") ?? "", articleUrl).toString()
|
||||
})
|
||||
|
||||
article.querySelectorAll<HTMLImageElement>("img[src]").forEach((image) => {
|
||||
image.src = new URL(image.getAttribute("src") ?? "", articleUrl).toString()
|
||||
})
|
||||
|
||||
return article.innerHTML
|
||||
}
|
||||
|
||||
function selectedNoteSlug(selection: ViewerSelection | null) {
|
||||
return selection?.kind === "node" && selection.graphKey === "architecture"
|
||||
? selection.nodeId
|
||||
: null
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [graphState, setGraphState] = React.useState<EpiphanyGraphsState | null>(null)
|
||||
const [contentIndex, setContentIndex] = React.useState<QuartzContentIndex | null>(null)
|
||||
const [selection, setSelection] = React.useState<ViewerSelection | null>(null)
|
||||
const [viewportTarget, setViewportTarget] = React.useState<ViewerSelection | null>(null)
|
||||
const [articleCache, setArticleCache] = React.useState<Record<string, ArticleState>>({})
|
||||
const [error, setError] = React.useState<Error | null>(null)
|
||||
const articleCacheRef = React.useRef<Record<string, ArticleState>>({})
|
||||
const loadingArticlesRef = React.useRef<Set<string>>(new Set())
|
||||
const slugs = React.useMemo(
|
||||
() => new Set(Object.keys(contentIndex ?? {}).map(normalizeSlug)),
|
||||
[contentIndex],
|
||||
)
|
||||
const noteSlug = selectedNoteSlug(selection)
|
||||
|
||||
React.useEffect(() => {
|
||||
articleCacheRef.current = articleCache
|
||||
}, [articleCache])
|
||||
|
||||
React.useEffect(() => {
|
||||
document.body.classList.add("gamecult-graph-spa-active")
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove("gamecult-graph-spa-active")
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
loadGraphState()
|
||||
.then(({ contentIndex: nextContentIndex, graphState: nextGraphState }) => {
|
||||
if (!cancelled) {
|
||||
const nextSlugs = new Set(Object.keys(nextContentIndex).map(normalizeSlug))
|
||||
const initialViewportTarget = parseHashSelection(nextSlugs) ??
|
||||
currentRouteSelection(nextSlugs) ?? {
|
||||
kind: "node" as const,
|
||||
graphKey: "architecture" as const,
|
||||
nodeId: nextSlugs.has("index")
|
||||
? "index"
|
||||
: (nextGraphState.architecture.nodes[0]?.id ?? ""),
|
||||
}
|
||||
setContentIndex(nextContentIndex)
|
||||
setGraphState(nextGraphState)
|
||||
setViewportTarget(initialViewportTarget)
|
||||
}
|
||||
})
|
||||
.catch((caught) => {
|
||||
if (!cancelled) {
|
||||
setError(caught instanceof Error ? caught : new Error(String(caught)))
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ensureArticle = React.useCallback((slug: string) => {
|
||||
const current = articleCacheRef.current[slug]
|
||||
|
||||
if (current?.status === "ready" || loadingArticlesRef.current.has(slug)) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingArticlesRef.current.add(slug)
|
||||
setArticleCache((currentCache) => ({
|
||||
...currentCache,
|
||||
[slug]: { status: "loading", html: currentCache[slug]?.html ?? null },
|
||||
}))
|
||||
|
||||
loadArticleHtml(slug)
|
||||
.then((html) => {
|
||||
loadingArticlesRef.current.delete(slug)
|
||||
setArticleCache((currentCache) => ({
|
||||
...currentCache,
|
||||
[slug]: { status: "ready", html },
|
||||
}))
|
||||
})
|
||||
.catch((caught) => {
|
||||
loadingArticlesRef.current.delete(slug)
|
||||
setArticleCache((currentCache) => ({
|
||||
...currentCache,
|
||||
[slug]: {
|
||||
status: "error",
|
||||
html: null,
|
||||
message: caught instanceof Error ? caught.message : String(caught),
|
||||
},
|
||||
}))
|
||||
})
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (noteSlug) {
|
||||
ensureArticle(noteSlug)
|
||||
}
|
||||
}, [ensureArticle, noteSlug])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!noteSlug) {
|
||||
return
|
||||
}
|
||||
|
||||
window.history.replaceState(null, "", `#note=${encodeURIComponent(noteSlug)}`)
|
||||
}, [noteSlug])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const hashSelection = parseHashSelection(slugs)
|
||||
|
||||
if (hashSelection) {
|
||||
setViewportTarget(hashSelection)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", onHashChange)
|
||||
return () => window.removeEventListener("hashchange", onHashChange)
|
||||
}, [slugs])
|
||||
|
||||
const selectNote = React.useCallback(
|
||||
(slug: string) =>
|
||||
setViewportTarget({
|
||||
kind: "node",
|
||||
graphKey: "architecture",
|
||||
nodeId: slug,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const onViewportTargetComplete = React.useCallback(() => {
|
||||
setViewportTarget(null)
|
||||
}, [])
|
||||
|
||||
const onArticleLinkClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
const anchor = (event.target as HTMLElement).closest("a[href]")
|
||||
|
||||
if (!(anchor instanceof HTMLAnchorElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const slug = slugFromArticleUrl(anchor.href, slugs)
|
||||
|
||||
if (!slug) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
selectNote(slug)
|
||||
},
|
||||
[selectNote, slugs],
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<pre className="gamecult-graph-error">
|
||||
{error.message}
|
||||
{"\n"}
|
||||
{error.stack}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
if (!graphState) {
|
||||
return (
|
||||
<main className="gamecult-epiphany-graph-app">
|
||||
<div className="gamecult-graph-loading">Loading Quartz backlink graph...</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="gamecult-epiphany-graph-app">
|
||||
<GraphErrorBoundary>
|
||||
<EpiphanyGraphViewer
|
||||
state={graphState}
|
||||
title="GameCult Knowledge Graph"
|
||||
selection={selection}
|
||||
viewportTarget={viewportTarget}
|
||||
onViewportTargetComplete={onViewportTargetComplete}
|
||||
onSelectionChange={setSelection}
|
||||
overlayPanels
|
||||
className="gamecult-graph-shell"
|
||||
showSidebar={false}
|
||||
focusSelection
|
||||
selectionFocusMode="article"
|
||||
onExpandedNodeClick={onArticleLinkClick}
|
||||
nodeArticle={{
|
||||
className: "gamecult-expanded-article-node",
|
||||
ariaLabel: (node) => `${node.title} article node`,
|
||||
content: (node) => (
|
||||
<ViewportNodeArticle
|
||||
node={node}
|
||||
articleCache={articleCache}
|
||||
contentIndex={contentIndex}
|
||||
ensureArticle={ensureArticle}
|
||||
onSelectNote={selectNote}
|
||||
onArticleLinkClick={onArticleLinkClick}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
graphLabels={{
|
||||
architecture: "Notes",
|
||||
dataflow: "Sections",
|
||||
}}
|
||||
graphDescriptions={{
|
||||
architecture:
|
||||
"Notes are individual vault pages from the gamecult knowledge base. Their edges are Quartz wiki links, with incoming backlinks counted into each node.",
|
||||
dataflow:
|
||||
"Sections are folder-level clusters. Their edges summarize how links and backlinks move between regions of the vault.",
|
||||
}}
|
||||
layoutMode={{
|
||||
architecture: "combined-force",
|
||||
dataflow: "combined-force",
|
||||
}}
|
||||
/>
|
||||
</GraphErrorBoundary>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function ViewportNodeArticle({
|
||||
node,
|
||||
articleCache,
|
||||
contentIndex,
|
||||
ensureArticle,
|
||||
onSelectNote,
|
||||
onArticleLinkClick,
|
||||
}: {
|
||||
node: PositionedNode
|
||||
articleCache: Record<string, ArticleState>
|
||||
contentIndex: QuartzContentIndex | null
|
||||
ensureArticle: (slug: string) => void
|
||||
onSelectNote: (slug: string) => void
|
||||
onArticleLinkClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||
}) {
|
||||
const noteSlug = node.graphKey === "architecture" ? node.id : null
|
||||
const section = node.graphKey === "dataflow" ? node.id.replace(/^section:/, "") : null
|
||||
|
||||
React.useEffect(() => {
|
||||
if (noteSlug) {
|
||||
ensureArticle(noteSlug)
|
||||
}
|
||||
}, [ensureArticle, noteSlug])
|
||||
|
||||
return (
|
||||
<ArticlePanel
|
||||
articleState={
|
||||
noteSlug
|
||||
? (articleCache[noteSlug] ?? { status: "idle", html: null })
|
||||
: { status: "idle", html: null }
|
||||
}
|
||||
contentIndex={contentIndex}
|
||||
noteSlug={noteSlug}
|
||||
section={section}
|
||||
onSelectNote={onSelectNote}
|
||||
onArticleLinkClick={onArticleLinkClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ArticlePanel({
|
||||
articleState,
|
||||
contentIndex,
|
||||
noteSlug,
|
||||
section,
|
||||
onSelectNote,
|
||||
onArticleLinkClick,
|
||||
}: {
|
||||
articleState: ArticleState
|
||||
contentIndex: QuartzContentIndex | null
|
||||
noteSlug: string | null
|
||||
section: string | null
|
||||
onSelectNote: (slug: string) => void
|
||||
onArticleLinkClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||
}) {
|
||||
const note = noteSlug && contentIndex ? contentIndex[noteSlug] : null
|
||||
const sectionNotes = React.useMemo(() => {
|
||||
if (!section || !contentIndex) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.entries(contentIndex)
|
||||
.map(
|
||||
([slug, entry]) =>
|
||||
[normalizeSlug(entry.slug || slug), entry] as [string, QuartzContentEntry],
|
||||
)
|
||||
.filter(([slug]) => sectionForSlug(slug) === section)
|
||||
.sort(([left, leftEntry], [right, rightEntry]) =>
|
||||
entryTitle(left, leftEntry).localeCompare(entryTitle(right, rightEntry)),
|
||||
)
|
||||
}, [contentIndex, section])
|
||||
|
||||
if (section) {
|
||||
return (
|
||||
<article className="gamecult-spa-panel">
|
||||
<header className="gamecult-spa-article-header">
|
||||
<span className="gamecult-spa-node-badge" aria-hidden="true">
|
||||
{initialsForTitle(section)}
|
||||
</span>
|
||||
<span className="gamecult-spa-title-cluster">
|
||||
<p className="gamecult-spa-kicker">Section Cluster</p>
|
||||
<h1>{section}</h1>
|
||||
</span>
|
||||
</header>
|
||||
<p className="gamecult-spa-summary">
|
||||
{sectionNotes.length} notes live in this section. Pick one and the graph becomes the table
|
||||
of contents it was clearly auditioning to be.
|
||||
</p>
|
||||
<div className="gamecult-spa-note-list">
|
||||
{sectionNotes.map(([slug, entry]) => (
|
||||
<button key={slug} type="button" onClick={() => onSelectNote(slug)}>
|
||||
<strong>{entryTitle(slug, entry)}</strong>
|
||||
<span>{summarizeContent(entry)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
if (!noteSlug || !note) {
|
||||
return (
|
||||
<article className="gamecult-spa-panel">
|
||||
<header className="gamecult-spa-article-header">
|
||||
<span className="gamecult-spa-node-badge" aria-hidden="true">
|
||||
GC
|
||||
</span>
|
||||
<span className="gamecult-spa-title-cluster">
|
||||
<p className="gamecult-spa-kicker">Graph Atlas</p>
|
||||
<h1>gamecult</h1>
|
||||
</span>
|
||||
</header>
|
||||
<p className="gamecult-spa-summary">
|
||||
Select a note to read it here. The graph is the site map now, which is probably what it
|
||||
was trying to confess.
|
||||
</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="gamecult-spa-panel">
|
||||
<header className="gamecult-spa-article-header">
|
||||
<span className="gamecult-spa-node-badge" aria-hidden="true">
|
||||
{initialsForTitle(entryTitle(noteSlug, note))}
|
||||
</span>
|
||||
<span className="gamecult-spa-title-cluster">
|
||||
<p className="gamecult-spa-kicker">{sectionForSlug(noteSlug)}</p>
|
||||
<h1>{entryTitle(noteSlug, note)}</h1>
|
||||
</span>
|
||||
</header>
|
||||
{articleState.status === "loading" && (
|
||||
<div className="gamecult-spa-status">Loading article...</div>
|
||||
)}
|
||||
{articleState.status === "error" && (
|
||||
<div className="gamecult-spa-status gamecult-spa-status-error">{articleState.message}</div>
|
||||
)}
|
||||
{articleState.status === "ready" && (
|
||||
<div
|
||||
className="gamecult-spa-article"
|
||||
data-epiphany-article-content
|
||||
onClick={onArticleLinkClick}
|
||||
dangerouslySetInnerHTML={{ __html: articleState.html }}
|
||||
/>
|
||||
)}
|
||||
{articleState.status !== "ready" && articleState.html && (
|
||||
<div
|
||||
className="gamecult-spa-article"
|
||||
data-epiphany-article-content
|
||||
onClick={onArticleLinkClick}
|
||||
dangerouslySetInnerHTML={{ __html: articleState.html }}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function initialsForTitle(title: string) {
|
||||
const words = title
|
||||
.replace(/['’]/g, "")
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
|
||||
if (words.length === 0) {
|
||||
return "GC"
|
||||
}
|
||||
|
||||
return words
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0]?.toUpperCase() ?? "")
|
||||
.join("")
|
||||
}
|
||||
|
||||
function mountGameCultGraph() {
|
||||
document.querySelectorAll(".gamecult-epiphany-graph-root").forEach((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (element.dataset.ready === "true" && element.childElementCount > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(element)
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
})
|
||||
element.dataset.ready = "true"
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleGameCultGraphMount() {
|
||||
mountGameCultGraph()
|
||||
window.setTimeout(mountGameCultGraph, 50)
|
||||
window.setTimeout(mountGameCultGraph, 300)
|
||||
window.setTimeout(mountGameCultGraph, 1000)
|
||||
window.setTimeout(mountGameCultGraph, 2000)
|
||||
}
|
||||
|
||||
document.addEventListener("nav", scheduleGameCultGraphMount)
|
||||
document.addEventListener("DOMContentLoaded", scheduleGameCultGraphMount)
|
||||
scheduleGameCultGraphMount()
|
||||
277
quartz/graph-spa/src/styles.css
Normal file
277
quartz/graph-spa/src/styles.css
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 72% 12%, rgba(255, 174, 88, 0.16), transparent 24%),
|
||||
radial-gradient(circle at 10% 18%, rgba(142, 223, 176, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, #03070d 0%, #07110f 52%, #091712 100%);
|
||||
color: #effcf8;
|
||||
font-family: "Ubuntu", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.gamecult-epiphany-graph-app {
|
||||
box-sizing: border-box;
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.gamecult-epiphany-graph-app * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gamecult-epiphany-graph-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
margin: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: #03070d;
|
||||
}
|
||||
|
||||
.gamecult-graph-error {
|
||||
white-space: pre-wrap;
|
||||
color: #fecaca;
|
||||
background: rgba(69, 10, 10, 0.6);
|
||||
border: 1px solid rgba(248, 113, 113, 0.45);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.gamecult-graph-loading {
|
||||
display: grid;
|
||||
min-height: 320px;
|
||||
place-items: center;
|
||||
color: #cfeee4;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
body.gamecult-graph-spa-active {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gamecult-graph-shell {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.gamecult-graph-shell .epiphany-graph-overlay-panel {
|
||||
opacity: 0.25;
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.gamecult-graph-shell .epiphany-graph-overlay-panel:hover,
|
||||
.gamecult-graph-shell .epiphany-graph-overlay-panel:focus-within {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.gamecult-graph-shell aside.epiphany-graph-overlay-panel > section {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.gamecult-graph-shell aside.epiphany-graph-overlay-panel > section:last-child {
|
||||
display: grid !important;
|
||||
grid-row: 1 / -1;
|
||||
}
|
||||
|
||||
.gamecult-expanded-article-node {
|
||||
box-sizing: border-box;
|
||||
padding: var(--node-pad-y, 0.62rem) var(--node-pad-x, 0.82rem);
|
||||
scrollbar-color: rgba(142, 223, 176, 0.58) rgba(3, 7, 13, 0.32);
|
||||
transition:
|
||||
border-radius 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
padding 120ms ease,
|
||||
opacity 120ms ease;
|
||||
}
|
||||
|
||||
.gamecult-expanded-article-node .gamecult-spa-panel {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.gamecult-spa-panel {
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
color: #effcf8;
|
||||
}
|
||||
|
||||
.gamecult-spa-panel h1 {
|
||||
max-height: var(--node-title-max-height, 1.12em);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #f7fffb;
|
||||
font-size: var(--node-title-size, 1.08rem);
|
||||
line-height: 1.04;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.gamecult-spa-kicker {
|
||||
margin: 0 0 var(--node-kicker-margin, 0.12rem);
|
||||
color: #8edfb0;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
opacity: var(--node-preview, 0);
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.gamecult-spa-summary,
|
||||
.gamecult-spa-status {
|
||||
color: rgba(239, 252, 248, 0.76);
|
||||
line-height: 1.55;
|
||||
max-height: var(--node-preview-height, 0rem);
|
||||
margin-block: var(--node-status-margin, 0rem);
|
||||
overflow: hidden;
|
||||
opacity: var(--node-preview, 0);
|
||||
}
|
||||
|
||||
.gamecult-spa-status {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid rgba(142, 223, 176, 0.16);
|
||||
border-radius: 16px;
|
||||
background: rgba(3, 7, 13, 0.45);
|
||||
}
|
||||
|
||||
.gamecult-spa-status-error {
|
||||
color: #fecaca;
|
||||
border-color: rgba(248, 113, 113, 0.35);
|
||||
}
|
||||
|
||||
.gamecult-spa-article-header {
|
||||
display: grid;
|
||||
grid-template-columns: var(--node-badge-size, 3rem) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--node-panel-gap, 0.72rem);
|
||||
min-height: var(--node-badge-size, 3rem);
|
||||
margin-bottom: var(--node-header-margin, 0rem);
|
||||
}
|
||||
|
||||
.gamecult-spa-node-badge {
|
||||
display: grid;
|
||||
width: var(--node-badge-size, 3rem);
|
||||
height: var(--node-badge-size, 3rem);
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(160deg, #69e2ef, #c2f5f0);
|
||||
color: #08313a;
|
||||
font-size: var(--node-title-size, 1.08rem);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.28),
|
||||
0 0 var(--node-badge-glow, 1rem) rgba(94, 234, 212, 0.3);
|
||||
}
|
||||
|
||||
.gamecult-spa-title-cluster {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gamecult-spa-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gamecult-spa-actions a,
|
||||
.gamecult-spa-note-list button {
|
||||
border: 1px solid rgba(142, 223, 176, 0.18);
|
||||
border-radius: 16px;
|
||||
background: rgba(142, 223, 176, 0.08);
|
||||
color: #effcf8;
|
||||
}
|
||||
|
||||
.gamecult-spa-actions a {
|
||||
padding: 0.5rem 0.7rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.gamecult-spa-article {
|
||||
color: rgba(239, 252, 248, 0.86);
|
||||
line-height: 1.65;
|
||||
max-height: var(--node-article-height, 0rem);
|
||||
overflow: hidden;
|
||||
opacity: var(--node-article, 0);
|
||||
transform: translateY(var(--node-article-offset, 0.65rem));
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.gamecult-spa-article h1 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gamecult-spa-article h2,
|
||||
.gamecult-spa-article h3 {
|
||||
color: #f7fffb;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.gamecult-spa-article a {
|
||||
color: #ffe0a8;
|
||||
}
|
||||
|
||||
.gamecult-spa-article img {
|
||||
max-width: 100%;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.gamecult-spa-note-list {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
max-height: var(--node-note-list-height, 0rem);
|
||||
overflow: hidden;
|
||||
opacity: var(--node-article, 0);
|
||||
}
|
||||
|
||||
.gamecult-spa-note-list button {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.85rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gamecult-spa-note-list button:hover {
|
||||
border-color: rgba(255, 174, 88, 0.38);
|
||||
background: rgba(255, 174, 88, 0.11);
|
||||
}
|
||||
|
||||
.gamecult-spa-note-list strong {
|
||||
color: #f7fffb;
|
||||
}
|
||||
|
||||
.gamecult-spa-note-list span {
|
||||
color: rgba(239, 252, 248, 0.68);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.gamecult-graph-shell aside.epiphany-graph-overlay-panel {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: auto !important;
|
||||
bottom: 12px !important;
|
||||
width: auto !important;
|
||||
max-height: 48vh;
|
||||
}
|
||||
}
|
||||
12
quartz/graph-spa/viewer.html
Normal file
12
quartz/graph-spa/viewer.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>gamecult Fractal Eusocial Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="gamecult-epiphany-graph-root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
quartz/graph-spa/vite.config.ts
Normal file
43
quartz/graph-spa/vite.config.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import path from "node:path"
|
||||
|
||||
const viewerEntry = fileURLToPath(new URL("./viewer.html", import.meta.url))
|
||||
const reactPath = fileURLToPath(new URL("./node_modules/react", import.meta.url))
|
||||
const reactDomPath = fileURLToPath(new URL("./node_modules/react-dom", import.meta.url))
|
||||
const outDir = process.env.GAMECULT_GRAPH_SPA_OUT_DIR
|
||||
? path.resolve(process.env.GAMECULT_GRAPH_SPA_OUT_DIR)
|
||||
: fileURLToPath(new URL("./dist", import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
react: reactPath,
|
||||
"react-dom": reactDomPath,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 4182,
|
||||
},
|
||||
build: {
|
||||
outDir,
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
viewer: viewerEntry,
|
||||
},
|
||||
output: {
|
||||
manualChunks: {
|
||||
react: ["react", "react-dom"],
|
||||
elk: ["elkjs/lib/elk.bundled.js"],
|
||||
},
|
||||
entryFileNames: "assets/[name].js",
|
||||
chunkFileNames: "assets/[name].js",
|
||||
assetFileNames: "assets/[name][extname]",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
94
scripts/build-graph-spa.mjs
Normal file
94
scripts/build-graph-spa.mjs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { spawn } from "node:child_process"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const engineRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
siteRoot: process.cwd(),
|
||||
outputDir: "static/epiphany-graph",
|
||||
}
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
if (!arg.startsWith("--")) continue
|
||||
|
||||
const key = arg.slice(2)
|
||||
const next = argv[i + 1]
|
||||
if (next && !next.startsWith("--")) {
|
||||
options[key] = next
|
||||
i++
|
||||
} else {
|
||||
options[key] = "true"
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
async function exists(targetPath) {
|
||||
try {
|
||||
await fs.lstat(targetPath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function linkNodeModules(graphRoot) {
|
||||
const source = path.join(engineRoot, "node_modules")
|
||||
const dest = path.join(graphRoot, "node_modules")
|
||||
|
||||
if (!(await exists(source))) {
|
||||
throw new Error(
|
||||
"GameCult-Quartz dependencies are missing. Run `npm ci` in the GameCult-Quartz repo first.",
|
||||
)
|
||||
}
|
||||
|
||||
await fs.rm(dest, { recursive: true, force: true })
|
||||
await fs.symlink(source, dest, "junction")
|
||||
}
|
||||
|
||||
function runBuild(graphRoot, outputRoot) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["./node_modules/vite/bin/vite.js", "build", "--base", "./"],
|
||||
{
|
||||
cwd: graphRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
GAMECULT_GRAPH_SPA_OUT_DIR: outputRoot,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
reject(new Error(`Graph SPA build exited with code ${code}`))
|
||||
})
|
||||
child.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const siteRoot = path.resolve(options.siteRoot)
|
||||
const outputRoot = path.resolve(siteRoot, options.outputDir)
|
||||
const graphRoot = path.join(engineRoot, "quartz", "graph-spa")
|
||||
|
||||
await linkNodeModules(graphRoot)
|
||||
await runBuild(graphRoot, outputRoot)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -70,7 +70,13 @@ async function copyEngineScaffold(runtimeRoot) {
|
|||
const dest = path.join(runtimeRoot, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await fs.cp(source, dest, { recursive: true })
|
||||
await fs.cp(source, dest, {
|
||||
recursive: true,
|
||||
filter: (candidate) => {
|
||||
const relative = path.relative(engineRoot, candidate).split(path.sep).join("/")
|
||||
return relative !== "quartz/graph-spa/node_modules"
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await fs.copyFile(source, dest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,5 +18,5 @@
|
|||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "./package.json"],
|
||||
"exclude": ["build/**/*.d.ts"]
|
||||
"exclude": ["build/**/*.d.ts", "quartz/graph-spa/**"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue