pandorga 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +77 -0
- data/DESIGN.md +1434 -0
- data/LICENSE +21 -0
- data/README.md +81 -0
- data/_data/themes/dark.yml +97 -0
- data/_data/themes/light.yml +120 -0
- data/_data/themes/shared.yml +37 -0
- data/_data/translations.yml +85 -0
- data/_includes/chord-tab.html +38 -0
- data/_includes/content-runtime/00-config.html +22 -0
- data/_includes/content-runtime/10-utils.html +176 -0
- data/_includes/content-runtime/20-markdown.html +264 -0
- data/_includes/content-runtime/25-code-blocks.html +315 -0
- data/_includes/content-runtime/30-includes-bib.html +190 -0
- data/_includes/content-runtime/30-includes-xcite.html +122 -0
- data/_includes/content-runtime/30-includes.html +365 -0
- data/_includes/content-runtime/40-listing.html +225 -0
- data/_includes/content-runtime/41-listing-helpers.html +71 -0
- data/_includes/content-runtime/42-listing-filter.html +208 -0
- data/_includes/content-runtime/43-blocks.html +379 -0
- data/_includes/content-runtime/50-detail.html +861 -0
- data/_includes/content-runtime/55-toc.html +176 -0
- data/_includes/content-runtime/60-fragments.html +567 -0
- data/_includes/content-runtime/90-bootstrap.html +76 -0
- data/_includes/cut-in.html +10 -0
- data/_includes/cv/cv-box.html +18 -0
- data/_includes/dict-card.html +24 -0
- data/_includes/figure.html +36 -0
- data/_includes/home/band-index.html +43 -0
- data/_includes/home/band-listing.html +49 -0
- data/_includes/mathjax.html +10 -0
- data/_includes/ongoing-product.html +1 -0
- data/_includes/page/content-runtime.html +20 -0
- data/_includes/page/date_evolution.html +19 -0
- data/_includes/page/figure-lightbox.html +102 -0
- data/_includes/page/formatted_date.html +15 -0
- data/_includes/page/hero-default.html +18 -0
- data/_includes/page/listing-runtime-attrs.html +8 -0
- data/_includes/page/mermaid.html +101 -0
- data/_includes/page/network-graph.html +1253 -0
- data/_includes/page/page-header.html +41 -0
- data/_includes/page/professional-connections.html +2 -0
- data/_includes/page/site-footer.html +101 -0
- data/_includes/page/timeline-section.html +868 -0
- data/_includes/plotly.html +22 -0
- data/_includes/theme/dev-reexport.html +100 -0
- data/_includes/theme/favicons.html +4 -0
- data/_includes/theme/font-loader.html +55 -0
- data/_includes/theme/shell-theme-boot.html +115 -0
- data/_includes/theme/site-logo.html +6 -0
- data/_includes/theme/tailwind-theme-colors.json.liquid +65 -0
- data/_includes/theme/tailwind-theme-fonts.json.liquid +6 -0
- data/_includes/theme/theme-context.liquid +20 -0
- data/_includes/theme/theme-toggle.html +94 -0
- data/_includes/theme/theme-vars-block.liquid +161 -0
- data/_includes/theme/theme-vars.html +16 -0
- data/_includes/transcription.html +80 -0
- data/_includes/tweet.html +22 -0
- data/_includes/video.html +11 -0
- data/_includes/xcite.html +3 -0
- data/_includes/youtube-short.html +26 -0
- data/_includes/youtube.html +12 -0
- data/_layouts/article.html +27 -0
- data/_layouts/articles.html +534 -0
- data/_layouts/bibliography.html +417 -0
- data/_layouts/blog.html +544 -0
- data/_layouts/cv.html +393 -0
- data/_layouts/home.html +319 -0
- data/_layouts/network.html +9 -0
- data/_layouts/pandorga/articles.html +4 -0
- data/_layouts/pandorga/bibliography.html +4 -0
- data/_layouts/pandorga/blog.html +4 -0
- data/_layouts/pandorga/cv.html +4 -0
- data/_layouts/pandorga/media.html +4 -0
- data/_layouts/pandorga/network.html +4 -0
- data/_layouts/pandorga/portfolio.html +4 -0
- data/_layouts/post.html +7 -0
- data/_layouts/product.html +5 -0
- data/_layouts/project.html +27 -0
- data/_layouts/projects.html +534 -0
- data/_layouts/resources.html +387 -0
- data/_layouts/shell.html +203 -0
- data/_layouts/text.html +246 -0
- data/_plugins/bibliography_artifact.rb +57 -0
- data/_plugins/content_backend.rb +37 -0
- data/_plugins/content_collections.rb +51 -0
- data/_plugins/content_detail_paths.rb +58 -0
- data/_plugins/content_headers.rb +73 -0
- data/_plugins/content_keys.rb +310 -0
- data/_plugins/content_local_serve.rb +168 -0
- data/_plugins/content_site_adapter.rb +159 -0
- data/_plugins/content_validators_hook.rb +20 -0
- data/_plugins/content_writing_index.rb +52 -0
- data/_plugins/content_writing_paths.rb +55 -0
- data/_plugins/force_utf8_encoding.rb +13 -0
- data/_plugins/git_chronology.rb +211 -0
- data/_plugins/git_metadata.rb +43 -0
- data/_plugins/job_sections.rb +25 -0
- data/_plugins/lib/content_validators.rb +27 -0
- data/_plugins/lib/resource_validator.rb +56 -0
- data/_plugins/lib/tag_validator.rb +153 -0
- data/_plugins/lib/text_excerpt.rb +56 -0
- data/_plugins/lib/writing_entry.rb +107 -0
- data/_plugins/local_detail_rewrites.rb +194 -0
- data/_plugins/timeline.rb +507 -0
- data/_plugins/writing_slug_limits.rb +70 -0
- data/assets/css/article.css +1330 -0
- data/assets/css/base.css +1432 -0
- data/assets/css/cv.css +13 -0
- data/assets/css/global-link.css +94 -0
- data/assets/css/ledger/bibliography.css +307 -0
- data/assets/css/ledger/chrome.css +118 -0
- data/assets/css/ledger/cv.css +836 -0
- data/assets/css/ledger/detail.css +1270 -0
- data/assets/css/ledger/footer.css +298 -0
- data/assets/css/ledger/home.css +1490 -0
- data/assets/css/ledger/network.css +106 -0
- data/assets/css/ledger/projects.css +716 -0
- data/assets/css/ledger/rascunhos.css +379 -0
- data/assets/css/ledger/sources.css +327 -0
- data/assets/css/ledger/writing.css +754 -0
- data/assets/tailwind-play.js +15 -0
- data/examples/full/Gemfile +7 -0
- data/examples/full/_config.yml +92 -0
- data/examples/full/_redirects +3 -0
- data/examples/full/content/collections/articles/2026-01-01-welcome.md +9 -0
- data/examples/full/content/collections/data/contacts.yml +3 -0
- data/examples/full/content/collections/data/education.yml +4 -0
- data/examples/full/content/collections/jobs/research-fellow.md +12 -0
- data/examples/full/content/collections/posts/2026-01-02-note.md +9 -0
- data/examples/full/content/collections/products/ledger-tool.md +6 -0
- data/examples/full/content/collections/projects/demo-kit.md +10 -0
- data/examples/full/content/collections/resources/style-guide.md +7 -0
- data/examples/full/content/pages/cv/summary.md +1 -0
- data/examples/full/content/pages/headers.yml +20 -0
- data/examples/full/index.html +4 -0
- data/examples/minimal/Gemfile +7 -0
- data/examples/minimal/_config.yml +47 -0
- data/examples/minimal/_redirects +3 -0
- data/examples/minimal/content/collections/articles/2026-01-01-hello.md +10 -0
- data/examples/minimal/content/collections/articles/2026-01-02-second.md +9 -0
- data/examples/minimal/content/pages/headers.yml +6 -0
- data/examples/minimal/index.html +4 -0
- data/exe/pandorga +7 -0
- data/functions/api/studio/[[path]].js +562 -0
- data/functions/api/studio/_lib/auth.js +131 -0
- data/functions/api/studio/_lib/blocks.js +156 -0
- data/functions/api/studio/_lib/gemini.js +62 -0
- data/functions/api/studio/_lib/github.js +278 -0
- data/functions/api/studio/_lib/guidelines.js +143 -0
- data/functions/api/studio/_lib/paths.js +76 -0
- data/functions/api/studio/_lib/prompts.js +165 -0
- data/functions/api/studio/_lib/rate-limit.js +40 -0
- data/functions/api/studio/_lib/rewrite.js +136 -0
- data/functions/api/studio/_lib/sanitize.js +143 -0
- data/lib/pandorga/cli.rb +66 -0
- data/lib/pandorga/commands/doctor.rb +66 -0
- data/lib/pandorga/commands/export.rb +30 -0
- data/lib/pandorga/commands/install_functions.rb +41 -0
- data/lib/pandorga/commands/new.rb +113 -0
- data/lib/pandorga/commands/publish.rb +53 -0
- data/lib/pandorga/commands/serve.rb +34 -0
- data/lib/pandorga/commands/studio_schema.rb +309 -0
- data/lib/pandorga/jekyll/generator.rb +74 -0
- data/lib/pandorga/jekyll/plugins/bibliography_artifact.rb +57 -0
- data/lib/pandorga/jekyll/plugins/content_backend.rb +37 -0
- data/lib/pandorga/jekyll/plugins/content_collections.rb +51 -0
- data/lib/pandorga/jekyll/plugins/content_detail_paths.rb +58 -0
- data/lib/pandorga/jekyll/plugins/content_headers.rb +73 -0
- data/lib/pandorga/jekyll/plugins/content_keys.rb +310 -0
- data/lib/pandorga/jekyll/plugins/content_local_serve.rb +168 -0
- data/lib/pandorga/jekyll/plugins/content_site_adapter.rb +159 -0
- data/lib/pandorga/jekyll/plugins/content_validators_hook.rb +20 -0
- data/lib/pandorga/jekyll/plugins/content_writing_index.rb +52 -0
- data/lib/pandorga/jekyll/plugins/content_writing_paths.rb +55 -0
- data/lib/pandorga/jekyll/plugins/force_utf8_encoding.rb +13 -0
- data/lib/pandorga/jekyll/plugins/git_chronology.rb +211 -0
- data/lib/pandorga/jekyll/plugins/git_metadata.rb +43 -0
- data/lib/pandorga/jekyll/plugins/job_sections.rb +25 -0
- data/lib/pandorga/jekyll/plugins/lib/content_validators.rb +27 -0
- data/lib/pandorga/jekyll/plugins/lib/resource_validator.rb +56 -0
- data/lib/pandorga/jekyll/plugins/lib/tag_validator.rb +153 -0
- data/lib/pandorga/jekyll/plugins/lib/text_excerpt.rb +56 -0
- data/lib/pandorga/jekyll/plugins/lib/writing_entry.rb +107 -0
- data/lib/pandorga/jekyll/plugins/local_detail_rewrites.rb +194 -0
- data/lib/pandorga/jekyll/plugins/timeline.rb +507 -0
- data/lib/pandorga/jekyll/plugins/writing_slug_limits.rb +70 -0
- data/lib/pandorga/jekyll.rb +15 -0
- data/lib/pandorga/object_store_env.rb +51 -0
- data/lib/pandorga/registry/schema.yml +115 -0
- data/lib/pandorga/registry.rb +279 -0
- data/lib/pandorga/version.rb +5 -0
- data/lib/pandorga.rb +19 -0
- data/scripts/cdn-allowlist.json +19 -0
- data/scripts/content/backfill-content-keys.rb +99 -0
- data/scripts/content/band_art.rb +362 -0
- data/scripts/content/export-content-json.rb +741 -0
- data/scripts/content/flatten-ansi-capture.mjs +153 -0
- data/scripts/content/hero_portraits.rb +28 -0
- data/scripts/content/publish-content-to-object-store.sh +78 -0
- data/scripts/content/publish-content-to-r2.sh +5 -0
- data/scripts/content/sync-content-json-to-r2.rb +167 -0
- data/scripts/content/sync-content-redirects.rb +12 -0
- data/scripts/generate-configuration-doc.rb +127 -0
- data/scripts/lib/content_license.rb +50 -0
- data/scripts/lib/content_storage_config.rb +70 -0
- data/scripts/lib/normalize-display-math.mjs +52 -0
- data/scripts/lib/r2_json_sync.rb +76 -0
- data/scripts/lib/withdrawn_public_objects.rb +23 -0
- data/scripts/lib/writing_index_contract.rb +61 -0
- data/scripts/pdf/md-to-pdf.mjs +1400 -0
- data/scripts/pdf/md-to-pdf.sh +27 -0
- data/scripts/test/fixtures/dual-template/_config.yml +32 -0
- data/scripts/test/fixtures/dual-template/index.html +4 -0
- data/scripts/test/render-parity/article-note.json +5 -0
- data/scripts/test/render-parity/blockquote.json +5 -0
- data/scripts/test/render-parity/blocks/ansi-basic.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-bold-is-bright.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-drops-cursor-moves.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-escapes-markup.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-extended-colour.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-real-control-byte.json +7 -0
- data/scripts/test/render-parity/blocks/archive-card-date-once.json +19 -0
- data/scripts/test/render-parity/blocks/archive-card-empty-foot.json +7 -0
- data/scripts/test/render-parity/blocks/archive-card-footer-dates.json +22 -0
- data/scripts/test/render-parity/blocks/archive-card-meta-parts.json +26 -0
- data/scripts/test/render-parity/blocks/archive-card.json +32 -0
- data/scripts/test/render-parity/blocks/cta-link.json +13 -0
- data/scripts/test/render-parity/blocks/lang-pill-pt.json +8 -0
- data/scripts/test/render-parity/blocks/lang-pill.json +8 -0
- data/scripts/test/render-parity/blocks/ledger-row-meta-parts.json +29 -0
- data/scripts/test/render-parity/blocks/ledger-row.json +35 -0
- data/scripts/test/render-parity/blocks/meta-strip.json +27 -0
- data/scripts/test/render-parity/blocks/poster-media-image.json +8 -0
- data/scripts/test/render-parity/blocks/poster-media-video.json +9 -0
- data/scripts/test/render-parity/blocks/section-banner.json +13 -0
- data/scripts/test/render-parity/blocks/system-card-custom-meta.json +22 -0
- data/scripts/test/render-parity/blocks/system-card.json +48 -0
- data/scripts/test/render-parity/blocks/tag-chips-links.json +12 -0
- data/scripts/test/render-parity/blocks/tag-chips.json +9 -0
- data/scripts/test/render-parity/cut-in-blank-line.json +9 -0
- data/scripts/test/render-parity/cut-in-right.json +9 -0
- data/scripts/test/render-parity/cut-in.json +8 -0
- data/scripts/test/render-parity/figure.json +5 -0
- data/scripts/test/render-parity/load-runtime.mjs +337 -0
- data/scripts/test/render-parity/ongoing-product.json +5 -0
- data/scripts/test/render-parity/plotly.json +5 -0
- data/scripts/test/render-parity/poem.json +5 -0
- data/scripts/test/render-parity/run-absent-field-checks.mjs +232 -0
- data/scripts/test/render-parity/run-block-fixture.mjs +86 -0
- data/scripts/test/render-parity/run-detail-parity-checks.mjs +123 -0
- data/scripts/test/render-parity/run-field-flow-checks.mjs +227 -0
- data/scripts/test/render-parity/run-fixture.mjs +159 -0
- data/scripts/test/render-parity/selectors.mjs +43 -0
- data/scripts/test/render-parity/video.json +5 -0
- data/scripts/test/render-parity/youtube-short.json +10 -0
- data/scripts/test/render-parity/youtube.json +8 -0
- data/scripts/test/test-absent-field-absent-element.rb +43 -0
- data/scripts/test/test-cdn-pins.rb +44 -0
- data/scripts/test/test-cloudflare-watch-paths-doc.rb +31 -0
- data/scripts/test/test-configuration-doc.rb +36 -0
- data/scripts/test/test-content-detail-urls.rb +346 -0
- data/scripts/test/test-content-keys.rb +110 -0
- data/scripts/test/test-content-license.rb +45 -0
- data/scripts/test/test-content-render-parity.rb +38 -0
- data/scripts/test/test-content-validators.rb +12 -0
- data/scripts/test/test-css-structure.rb +72 -0
- data/scripts/test/test-cv-export-contract.rb +158 -0
- data/scripts/test/test-detail-render-parity.rb +40 -0
- data/scripts/test/test-dev-reexport.rb +122 -0
- data/scripts/test/test-doctor.rb +39 -0
- data/scripts/test/test-dual-template-instances.rb +87 -0
- data/scripts/test/test-dual-template-runtime.rb +167 -0
- data/scripts/test/test-examples-build.rb +60 -0
- data/scripts/test/test-export-content-json-incremental.rb +138 -0
- data/scripts/test/test-export.rb +53 -0
- data/scripts/test/test-exported-fields-reach-the-view.rb +60 -0
- data/scripts/test/test-form-control-theming.rb +143 -0
- data/scripts/test/test-git-chronology.rb +414 -0
- data/scripts/test/test-hero-portraits.mjs +97 -0
- data/scripts/test/test-hero-portraits.rb +162 -0
- data/scripts/test/test-home-band-art.rb +135 -0
- data/scripts/test/test-install-functions.rb +39 -0
- data/scripts/test/test-language-export.rb +79 -0
- data/scripts/test/test-listing-filter-contract.rb +119 -0
- data/scripts/test/test-math-row-breaks.mjs +79 -0
- data/scripts/test/test-math-row-breaks.rb +18 -0
- data/scripts/test/test-new.rb +113 -0
- data/scripts/test/test-no-personal-data.rb +85 -0
- data/scripts/test/test-public-cv-export.rb +164 -0
- data/scripts/test/test-r2-config-consistency.rb +57 -0
- data/scripts/test/test-r2-json-sync.rb +193 -0
- data/scripts/test/test-registry-home.rb +34 -0
- data/scripts/test/test-runtime-js-syntax.rb +64 -0
- data/scripts/test/test-slim-writing-index.rb +126 -0
- data/scripts/test/test-studio-api-contract.rb +99 -0
- data/scripts/test/test-studio-content-files.rb +79 -0
- data/scripts/test/test-studio-liquid-roundtrip.mjs +179 -0
- data/scripts/test/test-studio-rate-limit.mjs +70 -0
- data/scripts/test/test-studio-rewrite-api-contract.rb +59 -0
- data/scripts/test/test-studio-rewrite-sanitize.mjs +210 -0
- data/scripts/test/test-studio-schema-composition.rb +126 -0
- data/scripts/test/test-studio-slug-config.rb +167 -0
- data/scripts/test/test-template-packages.rb +51 -0
- data/scripts/test/test-text-excerpt.rb +51 -0
- data/scripts/test/test-unwrap-media-src.mjs +39 -0
- data/scripts/test/test-unwrap-media-src.rb +19 -0
- data/scripts/test/test-withdrawn-content-export.rb +69 -0
- data/scripts/test/test-writing-index-export-slugs.rb +84 -0
- data/scripts/test/test-writing-slug-limits.rb +61 -0
- data/scripts/test/test-xcite-chronology.rb +106 -0
- data/scripts/validate.sh +74 -0
- data/scripts/visual/build-cv-pages-json.js +33 -0
- data/scripts/visual/build-site-pages-json.js +94 -0
- data/scripts/visual/publish-visual-inspection-to-r2.sh +42 -0
- data/scripts/visual/take-screenshots.js +88 -0
- data/scripts/visual/visual-inspection-pages.json +113 -0
- data/studio/assets/index-B_j0pMD5.css +1 -0
- data/studio/assets/index-Ch4YEv9g.js +285 -0
- data/studio/index.html +27 -0
- data/studio/schema.yml +869 -0
- data/studio-app/README.md +27 -0
- data/studio-app/index.html +26 -0
- data/studio-app/package-lock.json +2499 -0
- data/studio-app/package.json +26 -0
- data/studio-app/skills-lock.json +47 -0
- data/studio-app/src/api/client.js +71 -0
- data/studio-app/src/editor/body-editor.js +70 -0
- data/studio-app/src/editor/liquid-core.js +382 -0
- data/studio-app/src/editor/liquid.js +77 -0
- data/studio-app/src/lib/document.js +207 -0
- data/studio-app/src/lib/list-controls.js +317 -0
- data/studio-app/src/lib/schema.js +66 -0
- data/studio-app/src/lib/yaml-doc.js +100 -0
- data/studio-app/src/main.js +3693 -0
- data/studio-app/src/media/browser.js +578 -0
- data/studio-app/src/preview/render.js +430 -0
- data/studio-app/src/styles/studio.css +2826 -0
- data/studio-app/vite.config.js +25 -0
- data/templates/articles/README.md +40 -0
- data/templates/articles/fixtures/article.md +9 -0
- data/templates/articles/screenshots/README.md +6 -0
- data/templates/articles/studio.yml +73 -0
- data/templates/articles/template.yml +21 -0
- data/templates/bibliography/README.md +41 -0
- data/templates/bibliography/fixtures/references.json +9 -0
- data/templates/bibliography/screenshots/README.md +6 -0
- data/templates/bibliography/studio.yml +66 -0
- data/templates/bibliography/template.yml +18 -0
- data/templates/blog/README.md +38 -0
- data/templates/blog/fixtures/post.md +9 -0
- data/templates/blog/screenshots/README.md +6 -0
- data/templates/blog/studio.yml +69 -0
- data/templates/blog/template.yml +20 -0
- data/templates/cv/README.md +34 -0
- data/templates/cv/fixtures/job.md +12 -0
- data/templates/cv/screenshots/README.md +6 -0
- data/templates/cv/studio.yml +200 -0
- data/templates/cv/template.yml +25 -0
- data/templates/media/README.md +38 -0
- data/templates/media/fixtures/resource.md +7 -0
- data/templates/media/screenshots/README.md +6 -0
- data/templates/media/studio.yml +58 -0
- data/templates/media/template.yml +17 -0
- data/templates/network/README.md +34 -0
- data/templates/network/fixtures/sources.json +3 -0
- data/templates/network/screenshots/README.md +6 -0
- data/templates/network/studio.yml +28 -0
- data/templates/network/template.yml +10 -0
- data/templates/portfolio/README.md +39 -0
- data/templates/portfolio/fixtures/project.md +10 -0
- data/templates/portfolio/screenshots/README.md +6 -0
- data/templates/portfolio/studio.yml +89 -0
- data/templates/portfolio/template.yml +18 -0
- metadata +505 -0
|
@@ -0,0 +1,1253 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
/* The ideas graph, dressed as a ledger plate: a framed panel one step below
|
|
3
|
+
the canvas on the surface ladder, a header rule carrying the legend, and
|
|
4
|
+
square corners throughout.
|
|
5
|
+
|
|
6
|
+
Every colour and every typeface here is a custom property. The graph used
|
|
7
|
+
to read the palette in JavaScript and repaint itself on a theme event; SVG
|
|
8
|
+
presentation attributes cannot hold a var(), so the fix is to stop setting
|
|
9
|
+
them as attributes at all. Fill, stroke and font now come from these rules,
|
|
10
|
+
which means the browser re-resolves them the instant data-theme flips and
|
|
11
|
+
no second palette can drift out of step with the first. */
|
|
12
|
+
|
|
13
|
+
.network-figure {
|
|
14
|
+
position: relative;
|
|
15
|
+
margin: 0;
|
|
16
|
+
border: 1.5px solid var(--border-ink);
|
|
17
|
+
border-radius: var(--radius-card);
|
|
18
|
+
background: var(--color-panel-contrast);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.network-figure-crosshairs {
|
|
22
|
+
/* Empty drafting corners outside the ink plate — Borders artifact frame. */
|
|
23
|
+
inset: -0.45rem;
|
|
24
|
+
z-index: 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.network-figure-head {
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-wrap: wrap;
|
|
30
|
+
align-items: center;
|
|
31
|
+
justify-content: space-between;
|
|
32
|
+
gap: var(--space-md);
|
|
33
|
+
padding: var(--space-sm) var(--space-md);
|
|
34
|
+
border-bottom: 1.5px solid var(--border-ink);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Three entry series plus themes. The brand triad is deliberately
|
|
38
|
+
low-chroma, and identity is therefore carried three times over — the
|
|
39
|
+
legend, the mark's own shape (filled for an entry, hollow for a theme)
|
|
40
|
+
and a dashed stroke on rascunho citations — with hue only reinforcing it.
|
|
41
|
+
Absolute triad roles (not page-accent rebinds): articles = secondary
|
|
42
|
+
(forest), posts = tertiary (ochre), projects = primary (slate). */
|
|
43
|
+
.network-legend {
|
|
44
|
+
display: flex;
|
|
45
|
+
flex-wrap: wrap;
|
|
46
|
+
align-items: center;
|
|
47
|
+
gap: var(--space-lg);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.network-legend-item {
|
|
51
|
+
display: inline-flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
gap: var(--space-xs);
|
|
54
|
+
font-family: var(--font-label);
|
|
55
|
+
font-size: 0.77rem;
|
|
56
|
+
letter-spacing: 0.04em;
|
|
57
|
+
color: var(--color-on-surface-variant);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.network-legend-swatch {
|
|
61
|
+
display: inline-block;
|
|
62
|
+
width: 0.75rem;
|
|
63
|
+
height: 0.75rem;
|
|
64
|
+
flex-shrink: 0;
|
|
65
|
+
background: var(--color-outline);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.network-legend-swatch--articles {
|
|
69
|
+
background: var(--triad-secondary);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.network-legend-swatch--posts {
|
|
73
|
+
background: var(--triad-tertiary);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.network-legend-swatch--projects {
|
|
77
|
+
background: var(--triad-primary);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.network-legend-swatch--themes {
|
|
81
|
+
background: transparent;
|
|
82
|
+
border: 1px solid var(--color-on-surface-variant);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.network-hint {
|
|
86
|
+
font-family: var(--font-label);
|
|
87
|
+
font-size: 0.725rem;
|
|
88
|
+
line-height: 1.2;
|
|
89
|
+
color: var(--color-on-surface-variant);
|
|
90
|
+
letter-spacing: 0.03em;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* `isolation: isolate` is for the ambient pool the graph draws inside its
|
|
94
|
+
own SVG — see assets/css/ledger/network.css, which owns every rule that
|
|
95
|
+
effect needs. It is declared here because this is the rule that owns the
|
|
96
|
+
plate the pool sits in. */
|
|
97
|
+
.network-canvas {
|
|
98
|
+
position: relative;
|
|
99
|
+
isolation: isolate;
|
|
100
|
+
width: 100%;
|
|
101
|
+
height: 520px;
|
|
102
|
+
overflow: hidden;
|
|
103
|
+
cursor: grab;
|
|
104
|
+
background: var(--color-panel-contrast);
|
|
105
|
+
color: var(--color-on-surface);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@media (min-width: 768px) {
|
|
109
|
+
.network-canvas {
|
|
110
|
+
height: 680px;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.network-canvas:active {
|
|
115
|
+
cursor: grabbing;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.network-zoom-hud {
|
|
119
|
+
position: absolute;
|
|
120
|
+
z-index: 3;
|
|
121
|
+
top: var(--space-sm);
|
|
122
|
+
right: var(--space-sm);
|
|
123
|
+
display: flex;
|
|
124
|
+
flex-direction: column;
|
|
125
|
+
gap: 2px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.network-zoom-btn {
|
|
129
|
+
width: 2rem;
|
|
130
|
+
height: 2rem;
|
|
131
|
+
padding: 0;
|
|
132
|
+
border: 1px solid var(--border-ink);
|
|
133
|
+
background: var(--color-background);
|
|
134
|
+
color: var(--color-on-surface);
|
|
135
|
+
font-family: var(--font-mono);
|
|
136
|
+
font-size: 1rem;
|
|
137
|
+
line-height: 1;
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.network-zoom-btn:hover {
|
|
142
|
+
color: var(--color-primary);
|
|
143
|
+
border-color: var(--color-primary);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#network-graph {
|
|
147
|
+
position: absolute;
|
|
148
|
+
inset: 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.network-empty {
|
|
152
|
+
position: absolute;
|
|
153
|
+
inset: 0;
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
justify-content: center;
|
|
157
|
+
padding: var(--space-xl);
|
|
158
|
+
text-align: center;
|
|
159
|
+
color: var(--color-on-surface-variant);
|
|
160
|
+
font-style: italic;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ── Canvas ── */
|
|
164
|
+
/* A painted rectangle rather than a CSS background, because it is also the
|
|
165
|
+
surface the pan gesture has to land on. */
|
|
166
|
+
.network-canvas-bg {
|
|
167
|
+
fill: var(--color-panel-contrast);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ── Edges ── */
|
|
171
|
+
/* Circular arcs rather than straight lines. A straight segment between two
|
|
172
|
+
marks reads as a ruler — a measurement — and these are relations, not
|
|
173
|
+
distances. The arc also separates the two edges of a reciprocal pair,
|
|
174
|
+
which a pair of coincident lines cannot do. */
|
|
175
|
+
.network-edge {
|
|
176
|
+
fill: none;
|
|
177
|
+
stroke: var(--color-outline);
|
|
178
|
+
stroke-opacity: 0.7;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.network-edge--articles {
|
|
182
|
+
stroke: var(--triad-secondary);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* Dashed, so the two kinds of citation stay apart without relying on hue. */
|
|
186
|
+
.network-edge--posts {
|
|
187
|
+
stroke: var(--triad-tertiary);
|
|
188
|
+
stroke-dasharray: 5 3;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.network-edge--projects {
|
|
192
|
+
stroke: var(--triad-primary);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* A theme edge is not a claim the author made, only a word two pieces share,
|
|
196
|
+
so it is drawn as the quietest mark on the plate: no arrow, no colour, and
|
|
197
|
+
an opacity that lets the citation arcs pass in front of it. */
|
|
198
|
+
.network-edge--theme {
|
|
199
|
+
stroke: var(--color-outline);
|
|
200
|
+
stroke-opacity: 0.3;
|
|
201
|
+
stroke-width: 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.network-arrow {
|
|
205
|
+
fill: var(--color-outline);
|
|
206
|
+
fill-opacity: 0.8;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.network-arrow--articles {
|
|
210
|
+
fill: var(--triad-secondary);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.network-arrow--posts {
|
|
214
|
+
fill: var(--triad-tertiary);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.network-arrow--projects {
|
|
218
|
+
fill: var(--triad-primary);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* ── Marks ── */
|
|
222
|
+
/* Squares, because nothing on this site has a corner radius. An entry is
|
|
223
|
+
filled in its series colour and sized by how often it is cited; a theme is
|
|
224
|
+
hollow, which is the shape saying "this is a word, not a piece of
|
|
225
|
+
writing" before the colour says anything at all. */
|
|
226
|
+
.network-node {
|
|
227
|
+
cursor: pointer;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.network-node-entry--articles {
|
|
231
|
+
fill: var(--triad-secondary);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.network-node-entry--posts {
|
|
235
|
+
fill: var(--triad-tertiary);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.network-node-entry--projects {
|
|
239
|
+
fill: var(--triad-primary);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.network-node-theme {
|
|
243
|
+
fill: var(--color-panel-contrast);
|
|
244
|
+
stroke: var(--color-on-surface-variant);
|
|
245
|
+
stroke-width: 1;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.network-node:focus {
|
|
249
|
+
outline: none;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.network-node:focus-visible .network-node-mark {
|
|
253
|
+
stroke: var(--color-on-surface);
|
|
254
|
+
stroke-width: 2;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* Soft tertiary halo on the focused neighborhood — felt-wash, not glow. */
|
|
258
|
+
.network-svg--focusing .network-node.network-mark--near .network-node-mark {
|
|
259
|
+
filter: drop-shadow(0 0 0.45rem color-mix(in srgb, var(--triad-tertiary) 45%, transparent));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.network-svg--focusing .network-node.network-mark--near.network-node-entry--articles .network-node-mark {
|
|
263
|
+
filter: drop-shadow(0 0 0.45rem color-mix(in srgb, var(--triad-secondary) 45%, transparent));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.network-svg--focusing .network-node.network-mark--near.network-node-entry--projects .network-node-mark {
|
|
267
|
+
filter: drop-shadow(0 0 0.45rem color-mix(in srgb, var(--triad-primary) 45%, transparent));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ── Labels ── */
|
|
271
|
+
/* The type roles are the site's, not the graph's. A theme is a tag, so it
|
|
272
|
+
takes the label face that every other tag and chip on the site wears; an
|
|
273
|
+
entry's title is UI copy at this size, so it takes the body sans. Neither
|
|
274
|
+
is notation, and the notation face is reserved for code. The halo is a
|
|
275
|
+
paint-order stroke in the canvas colour: labels sit over the edges, and
|
|
276
|
+
without it a hairline crossing a descender is unreadable. */
|
|
277
|
+
.network-label {
|
|
278
|
+
paint-order: stroke;
|
|
279
|
+
stroke: var(--color-panel-contrast);
|
|
280
|
+
stroke-width: 3px;
|
|
281
|
+
stroke-linejoin: round;
|
|
282
|
+
pointer-events: none;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.network-label--theme {
|
|
286
|
+
font-family: var(--font-label);
|
|
287
|
+
font-size: 11px;
|
|
288
|
+
letter-spacing: 0.04em;
|
|
289
|
+
fill: var(--color-on-surface);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.network-label--entry {
|
|
293
|
+
font-family: var(--font-body);
|
|
294
|
+
font-size: 11.5px;
|
|
295
|
+
fill: var(--color-on-surface-variant);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* Landmarks are drawn from the start; every other title waits to be asked
|
|
299
|
+
for. A map that labels all 51 entries at once is the card layout again. */
|
|
300
|
+
.network-label--demand {
|
|
301
|
+
opacity: 0;
|
|
302
|
+
transition: opacity var(--motion-duration-fast) var(--motion-ease-default);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.network-weight-label {
|
|
306
|
+
font-family: var(--font-body);
|
|
307
|
+
font-size: 10px;
|
|
308
|
+
fill: var(--color-on-surface-variant);
|
|
309
|
+
pointer-events: none;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* ── Focus ── */
|
|
313
|
+
/* Focusing a mark dims everything it does not touch. This is the question a
|
|
314
|
+
graph actually gets asked — "what does this one reach?" — and the old card
|
|
315
|
+
layout could not answer it at any size. */
|
|
316
|
+
.network-mark {
|
|
317
|
+
transition: opacity var(--motion-duration-fast) var(--motion-ease-default);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.network-svg--focusing .network-mark:not(.network-mark--near) {
|
|
321
|
+
opacity: 0.1;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.network-svg--focusing .network-mark--near.network-label--demand {
|
|
325
|
+
opacity: 1;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* A title that was never asked for stays unasked-for: the dim rule above
|
|
329
|
+
would otherwise raise all fifty of them from nothing to a tenth, which is
|
|
330
|
+
enough to read and more than enough to clutter. */
|
|
331
|
+
.network-svg--focusing .network-mark.network-label--demand:not(.network-mark--near) {
|
|
332
|
+
opacity: 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/* ── The hover card ── */
|
|
336
|
+
/* HTML rather than SVG text: a standfirst needs real line breaking, and the
|
|
337
|
+
thumbnail is a real image. It is positioned in screen space, so it does
|
|
338
|
+
not scale with the zoom and stays readable at any magnification. */
|
|
339
|
+
.network-hovercard {
|
|
340
|
+
position: absolute;
|
|
341
|
+
z-index: 2;
|
|
342
|
+
display: none;
|
|
343
|
+
width: 19rem;
|
|
344
|
+
max-width: calc(100% - var(--space-lg));
|
|
345
|
+
padding: var(--space-md);
|
|
346
|
+
background: var(--color-background);
|
|
347
|
+
border: 1px solid var(--border-gentle);
|
|
348
|
+
border-left: 3px solid var(--color-outline);
|
|
349
|
+
pointer-events: none;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.network-hovercard[data-open="true"] {
|
|
353
|
+
display: block;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.network-hovercard[data-type="articles"] {
|
|
357
|
+
border-left-color: var(--triad-secondary);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.network-hovercard[data-type="posts"] {
|
|
361
|
+
border-left-color: var(--triad-tertiary);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.network-hovercard[data-type="projects"] {
|
|
365
|
+
border-left-color: var(--triad-primary);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.network-hovercard-head {
|
|
369
|
+
display: flex;
|
|
370
|
+
align-items: baseline;
|
|
371
|
+
justify-content: space-between;
|
|
372
|
+
gap: var(--space-sm);
|
|
373
|
+
margin-bottom: var(--space-xs);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.network-hovercard-kicker {
|
|
377
|
+
font-family: var(--font-label);
|
|
378
|
+
font-size: 0.7rem;
|
|
379
|
+
letter-spacing: 0.12em;
|
|
380
|
+
color: var(--color-on-surface-variant);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/* The one mono in the component. A date is a meta line, and every meta line
|
|
384
|
+
on the site is set in the notation face by renderMetaStrip. */
|
|
385
|
+
.network-hovercard-date {
|
|
386
|
+
font-family: var(--font-mono);
|
|
387
|
+
font-size: 0.7rem;
|
|
388
|
+
font-variant-numeric: tabular-nums;
|
|
389
|
+
color: var(--color-on-surface-variant);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.network-hovercard-title {
|
|
393
|
+
margin: 0 0 var(--space-xs);
|
|
394
|
+
font-family: var(--font-content-title);
|
|
395
|
+
font-size: 1.15rem;
|
|
396
|
+
font-weight: var(--weight-title);
|
|
397
|
+
line-height: 1.2;
|
|
398
|
+
color: var(--color-on-surface);
|
|
399
|
+
font-synthesis: none;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.network-hovercard-blurb {
|
|
403
|
+
margin: 0;
|
|
404
|
+
font-family: var(--font-body);
|
|
405
|
+
font-size: 0.82rem;
|
|
406
|
+
line-height: 1.5;
|
|
407
|
+
color: var(--color-on-surface-variant);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
.network-hovercard-themes {
|
|
411
|
+
display: flex;
|
|
412
|
+
flex-wrap: wrap;
|
|
413
|
+
gap: var(--space-xs);
|
|
414
|
+
margin-top: var(--space-sm);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.network-hovercard-theme {
|
|
418
|
+
font-family: var(--font-label);
|
|
419
|
+
font-size: 0.68rem;
|
|
420
|
+
letter-spacing: 0.03em;
|
|
421
|
+
padding: 0.1rem 0.4rem;
|
|
422
|
+
background: var(--color-surface-high);
|
|
423
|
+
color: var(--color-on-surface-variant);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.network-hovercard-count {
|
|
427
|
+
margin: 0;
|
|
428
|
+
font-family: var(--font-body);
|
|
429
|
+
font-size: 0.82rem;
|
|
430
|
+
color: var(--color-on-surface-variant);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
</style>
|
|
435
|
+
|
|
436
|
+
<section class="mb-16 scroll-mt-28" id="network">
|
|
437
|
+
<figure class="network-figure">
|
|
438
|
+
<span class="ledger-frame-crosshairs network-figure-crosshairs" aria-hidden="true"></span>
|
|
439
|
+
<figcaption class="network-figure-head">
|
|
440
|
+
<div class="network-legend" id="network-legend"></div>
|
|
441
|
+
<span class="network-hint" id="network-hint">Hover a mark to trace it · Drag to move · Click to open</span>
|
|
442
|
+
</figcaption>
|
|
443
|
+
<div id="network-graph-wrapper" class="network-canvas">
|
|
444
|
+
<div class="network-zoom-hud" role="group" aria-label="Zoom">
|
|
445
|
+
<button type="button" class="network-zoom-btn" data-network-zoom="in" aria-label="Zoom in">+</button>
|
|
446
|
+
<button type="button" class="network-zoom-btn" data-network-zoom="out" aria-label="Zoom out">−</button>
|
|
447
|
+
<button type="button" class="network-zoom-btn" data-network-zoom="fit" aria-label="Fit graph">⊡</button>
|
|
448
|
+
</div>
|
|
449
|
+
<div id="network-graph"></div>
|
|
450
|
+
<div id="network-hovercard" class="network-hovercard" aria-hidden="true"></div>
|
|
451
|
+
</div>
|
|
452
|
+
</figure>
|
|
453
|
+
</section>
|
|
454
|
+
|
|
455
|
+
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
|
|
456
|
+
<script>
|
|
457
|
+
(function () {
|
|
458
|
+
'use strict';
|
|
459
|
+
|
|
460
|
+
var runtime = window.ContentRuntime;
|
|
461
|
+
if (!runtime || !runtime.ListingFilter) return;
|
|
462
|
+
|
|
463
|
+
var params = new URLSearchParams(window.location.search);
|
|
464
|
+
var filterRoot = document.querySelector('[data-listing-filter-root]');
|
|
465
|
+
var legendRoot = document.getElementById('network-legend');
|
|
466
|
+
var writingIndexCache = null;
|
|
467
|
+
var filterUi = null;
|
|
468
|
+
|
|
469
|
+
var NETWORK_TYPE_OPTIONS = [
|
|
470
|
+
{ value: 'articles', label: 'Article' },
|
|
471
|
+
{ value: 'posts', label: 'Post / Rascunho' },
|
|
472
|
+
{ value: 'projects', label: 'Project' }
|
|
473
|
+
];
|
|
474
|
+
|
|
475
|
+
/* Soft region anchors for the three entry series. Articles settle
|
|
476
|
+
top-left, posts top-right, projects along the bottom; theme nodes
|
|
477
|
+
stay free near the entries that carry them. Citation and theme
|
|
478
|
+
forces still pull neighbors together inside each region. */
|
|
479
|
+
var NETWORK_REGIONS = {
|
|
480
|
+
articles: { x: 0.28, y: 0.32 },
|
|
481
|
+
posts: { x: 0.72, y: 0.32 },
|
|
482
|
+
projects: { x: 0.50, y: 0.78 }
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
var NETWORK_ENTRY_TYPES = { articles: true, posts: true, projects: true };
|
|
486
|
+
|
|
487
|
+
/* A tag carried by one entry connects that entry to nothing. Only tags
|
|
488
|
+
two or more entries share become marks, so the theme layer is always
|
|
489
|
+
reporting something the corpus actually has in common. */
|
|
490
|
+
var MIN_THEME_USES = 2;
|
|
491
|
+
|
|
492
|
+
/* Rascunho tags are stored lowercase (`arte`, `provocação`); capitalize
|
|
493
|
+
them on this map so theme marks match the Title Case already used
|
|
494
|
+
for English article tags. Stored identity stays the raw spelling. */
|
|
495
|
+
function themeDisplayName(name) {
|
|
496
|
+
var s = String(name || '');
|
|
497
|
+
if (!s) return s;
|
|
498
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function nodeType(item) {
|
|
502
|
+
return item.collection || item.type;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function initFilter(items) {
|
|
506
|
+
if (!filterRoot) return;
|
|
507
|
+
var typesPresent = {};
|
|
508
|
+
items.forEach(function (item) {
|
|
509
|
+
typesPresent[nodeType(item)] = true;
|
|
510
|
+
});
|
|
511
|
+
var chipGroups = [{
|
|
512
|
+
key: 'type',
|
|
513
|
+
label: 'Type',
|
|
514
|
+
options: [{ value: null, label: 'All nodes' }].concat(
|
|
515
|
+
NETWORK_TYPE_OPTIONS.filter(function (option) {
|
|
516
|
+
return typesPresent[option.value];
|
|
517
|
+
}).map(function (option) {
|
|
518
|
+
return {
|
|
519
|
+
value: option.value,
|
|
520
|
+
label: option.label,
|
|
521
|
+
swatch: 'network-legend-swatch network-legend-swatch--' + option.value
|
|
522
|
+
};
|
|
523
|
+
})
|
|
524
|
+
)
|
|
525
|
+
}];
|
|
526
|
+
filterUi = runtime.ListingFilter.create({
|
|
527
|
+
root: filterRoot,
|
|
528
|
+
initialType: params.get('type') || null,
|
|
529
|
+
chipGroups: chipGroups,
|
|
530
|
+
onChange: function (state) {
|
|
531
|
+
renderNetworkGraph(writingIndexCache, state.type);
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
filterRoot.hidden = false;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/* The legend describes what is on the plate right now, so it is rebuilt
|
|
538
|
+
with the graph rather than hard-coded beside it. */
|
|
539
|
+
function renderLegend(types, themeCount) {
|
|
540
|
+
if (!legendRoot) return;
|
|
541
|
+
var keys = NETWORK_TYPE_OPTIONS.filter(function (option) {
|
|
542
|
+
return types[option.value];
|
|
543
|
+
}).map(function (option) {
|
|
544
|
+
return { kind: option.value, label: option.label };
|
|
545
|
+
});
|
|
546
|
+
if (themeCount) keys.push({ kind: 'themes', label: 'Theme' });
|
|
547
|
+
legendRoot.innerHTML = keys.map(function (key) {
|
|
548
|
+
return '<span class="network-legend-item">' +
|
|
549
|
+
'<span class="network-legend-swatch network-legend-swatch--' + key.kind +
|
|
550
|
+
'" aria-hidden="true"></span>' + runtime.escapeHtml(key.label) + '</span>';
|
|
551
|
+
}).join('');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function formatNetworkDate(value) {
|
|
555
|
+
if (!value) return '';
|
|
556
|
+
var date = new Date(value);
|
|
557
|
+
if (isNaN(date.getTime())) return '';
|
|
558
|
+
return date.toLocaleString('en', { month: 'short', year: 'numeric' });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function buildNetworkEdges(items) {
|
|
562
|
+
var edges = [];
|
|
563
|
+
var seen = new Set();
|
|
564
|
+
function addEdge(source, target) {
|
|
565
|
+
if (!source || !target || source === target) return;
|
|
566
|
+
var key = source + '\x00' + target;
|
|
567
|
+
if (seen.has(key)) return;
|
|
568
|
+
seen.add(key);
|
|
569
|
+
edges.push({ source: source, target: target });
|
|
570
|
+
}
|
|
571
|
+
items.forEach(function (item) {
|
|
572
|
+
var cites = Array.isArray(item.xcites) ? item.xcites : [];
|
|
573
|
+
cites.forEach(function (citeKey) {
|
|
574
|
+
items.forEach(function (target) {
|
|
575
|
+
if (target.key === citeKey) addEdge(item.url, target.url);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
return edges;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/* Themes are counted over the entries actually on the plate, so
|
|
583
|
+
filtering to one series re-asks the question of that series rather
|
|
584
|
+
than leaving the full corpus's tags hanging over a subset. */
|
|
585
|
+
function buildThemes(items) {
|
|
586
|
+
var uses = new Map();
|
|
587
|
+
items.forEach(function (item) {
|
|
588
|
+
var tags = Array.isArray(item.tags) ? item.tags : [];
|
|
589
|
+
tags.forEach(function (tag) {
|
|
590
|
+
var name = String(tag || '').trim();
|
|
591
|
+
if (!name) return;
|
|
592
|
+
if (!uses.has(name)) uses.set(name, []);
|
|
593
|
+
uses.get(name).push(item.url);
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
var themes = [];
|
|
597
|
+
var links = [];
|
|
598
|
+
uses.forEach(function (urls, name) {
|
|
599
|
+
if (urls.length < MIN_THEME_USES) return;
|
|
600
|
+
var id = 'theme:' + name;
|
|
601
|
+
themes.push({ id: id, name: name, uses: urls.length });
|
|
602
|
+
urls.forEach(function (url) {
|
|
603
|
+
links.push({ source: url, target: id, weight: 1, kind: 'theme' });
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
return { themes: themes, links: links };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function renderNetworkGraph(writingIndex, activeType) {
|
|
610
|
+
/* Articles, posts, and projects. The index also carries products;
|
|
611
|
+
those stay off the plate — portfolio CV cards, not nodes on the
|
|
612
|
+
idea map. A shared tag used to let products in through the side
|
|
613
|
+
door under a legend that never named them. */
|
|
614
|
+
var keyed = (writingIndex || []).filter(function (item) {
|
|
615
|
+
return item.key && NETWORK_ENTRY_TYPES[nodeType(item)];
|
|
616
|
+
});
|
|
617
|
+
if (NETWORK_ENTRY_TYPES[activeType]) {
|
|
618
|
+
keyed = keyed.filter(function (item) { return nodeType(item) === activeType; });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
var themeData = buildThemes(keyed);
|
|
622
|
+
var themesByEntry = new Map();
|
|
623
|
+
themeData.links.forEach(function (link) {
|
|
624
|
+
if (!themesByEntry.has(link.source)) themesByEntry.set(link.source, []);
|
|
625
|
+
themesByEntry.get(link.source).push(link.target.slice(6));
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
var rawNodes = keyed.map(function (item) {
|
|
629
|
+
return {
|
|
630
|
+
id: item.url,
|
|
631
|
+
key: item.key,
|
|
632
|
+
kind: 'entry',
|
|
633
|
+
title: item.title,
|
|
634
|
+
url: item.url,
|
|
635
|
+
type: nodeType(item),
|
|
636
|
+
date: formatNetworkDate(item.date),
|
|
637
|
+
blurb: String(item.description || item.excerpt || '').replace(/\s+/g, ' '),
|
|
638
|
+
themes: themesByEntry.get(item.url) || []
|
|
639
|
+
};
|
|
640
|
+
});
|
|
641
|
+
themeData.themes.forEach(function (theme) {
|
|
642
|
+
rawNodes.push({
|
|
643
|
+
id: theme.id,
|
|
644
|
+
kind: 'theme',
|
|
645
|
+
title: themeDisplayName(theme.name),
|
|
646
|
+
url: '',
|
|
647
|
+
type: 'theme',
|
|
648
|
+
uses: theme.uses,
|
|
649
|
+
themes: []
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
var rawEdges = buildNetworkEdges(keyed);
|
|
654
|
+
|
|
655
|
+
var edgeMap = new Map();
|
|
656
|
+
rawEdges.forEach(function (e) {
|
|
657
|
+
var k = e.source + '\x00' + e.target;
|
|
658
|
+
if (edgeMap.has(k)) edgeMap.get(k).weight++;
|
|
659
|
+
else edgeMap.set(k, { source: e.source, target: e.target, weight: 1, kind: 'cite' });
|
|
660
|
+
});
|
|
661
|
+
var links = Array.from(edgeMap.values()).concat(themeData.links);
|
|
662
|
+
|
|
663
|
+
var wrapper = document.getElementById('network-graph-wrapper');
|
|
664
|
+
var container = document.getElementById('network-graph');
|
|
665
|
+
var hovercard = document.getElementById('network-hovercard');
|
|
666
|
+
if (!wrapper || !container) return;
|
|
667
|
+
container.innerHTML = '';
|
|
668
|
+
if (hovercard) hovercard.setAttribute('data-open', 'false');
|
|
669
|
+
|
|
670
|
+
function getSize() { return { W: wrapper.clientWidth, H: wrapper.clientHeight }; }
|
|
671
|
+
var dim = getSize();
|
|
672
|
+
var W = dim.W, H = dim.H;
|
|
673
|
+
|
|
674
|
+
/* Size is the one quantity the mark carries: how often a piece is
|
|
675
|
+
cited. The range here is 0 to 6, so a linear map would be invisible
|
|
676
|
+
at one end and a blob at the other — the root compresses it into
|
|
677
|
+
something the eye can still rank. */
|
|
678
|
+
var THEME_SIZE = 10;
|
|
679
|
+
function markSize(d) {
|
|
680
|
+
if (d.kind === 'theme') return THEME_SIZE;
|
|
681
|
+
return 9 + Math.sqrt(d.deg || 0) * 3.6;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/* The chord is trimmed at both ends before the arc is drawn, so an
|
|
685
|
+
arrowhead lands beside its mark instead of under it. The arc is
|
|
686
|
+
shallow enough — radius is several times the span — that trimming
|
|
687
|
+
along the chord is indistinguishable from trimming along the curve. */
|
|
688
|
+
var ARC_RADIUS_FACTOR = 3.4;
|
|
689
|
+
function arcPath(d) {
|
|
690
|
+
var sx = d.source.x, sy = d.source.y, tx = d.target.x, ty = d.target.y;
|
|
691
|
+
var dx = tx - sx, dy = ty - sy;
|
|
692
|
+
var len = Math.hypot(dx, dy) || 1;
|
|
693
|
+
var ux = dx / len, uy = dy / len;
|
|
694
|
+
var padS = markSize(d.source) / 2 + 3;
|
|
695
|
+
var padT = markSize(d.target) / 2 + (d.kind === 'cite' ? 7 : 3);
|
|
696
|
+
var x1 = sx + ux * padS, y1 = sy + uy * padS;
|
|
697
|
+
var x2 = tx - ux * padT, y2 = ty - uy * padT;
|
|
698
|
+
var r = (Math.hypot(x2 - x1, y2 - y1) || 1) * ARC_RADIUS_FACTOR;
|
|
699
|
+
return 'M' + x1 + ',' + y1 + 'A' + r + ',' + r + ' 0 0,1 ' + x2 + ',' + y2;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
var svg = d3.select('#network-graph').append('svg')
|
|
703
|
+
.attr('class', 'network-svg').attr('width', W).attr('height', H);
|
|
704
|
+
var bgRect = svg.append('rect')
|
|
705
|
+
.attr('class', 'network-canvas-bg').attr('width', W).attr('height', H);
|
|
706
|
+
|
|
707
|
+
var defs = svg.append('defs');
|
|
708
|
+
['articles', 'posts', 'projects'].forEach(function (type) {
|
|
709
|
+
defs.append('marker')
|
|
710
|
+
.attr('id', 'arrow-' + type)
|
|
711
|
+
.attr('viewBox', '-1 -4 9 8').attr('refX', 7).attr('refY', 0)
|
|
712
|
+
.attr('markerWidth', 5).attr('markerHeight', 5).attr('orient', 'auto')
|
|
713
|
+
.append('path').attr('d', 'M0,-3.5L7,0L0,3.5Z')
|
|
714
|
+
.attr('class', 'network-arrow network-arrow--' + type);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
/* ── The ambient pools ──
|
|
718
|
+
Depth treatments under each entry series cluster: articles (forest),
|
|
719
|
+
posts (ochre), projects (slate). Centre and width are read off the
|
|
720
|
+
settled layout in placePool() below.
|
|
721
|
+
|
|
722
|
+
Drawn inside the SVG because the canvas rectangle above is opaque
|
|
723
|
+
and would cover anything beNewsreader it, and between that rectangle and
|
|
724
|
+
the zoom group so it stays a backdrop: every edge, mark and label is
|
|
725
|
+
painted over it. It sits in screen space rather than in graph space,
|
|
726
|
+
like the hover card, so the blur is the same 60px at every
|
|
727
|
+
magnification instead of dissolving as the reader zooms out.
|
|
728
|
+
|
|
729
|
+
Colour, opacity, blur and the dark-palette gate all live in
|
|
730
|
+
assets/css/ledger/network.css. */
|
|
731
|
+
var poolArticlesGrad = defs.append('radialGradient').attr('id', 'network-pool-articles-fill');
|
|
732
|
+
poolArticlesGrad.append('stop').attr('offset', '0%').attr('class', 'network-pool-core network-pool-core--articles');
|
|
733
|
+
poolArticlesGrad.append('stop').attr('offset', '70%').attr('class', 'network-pool-edge network-pool-edge--articles');
|
|
734
|
+
|
|
735
|
+
var poolPostsGrad = defs.append('radialGradient').attr('id', 'network-pool-posts-fill');
|
|
736
|
+
poolPostsGrad.append('stop').attr('offset', '0%').attr('class', 'network-pool-core network-pool-core--posts');
|
|
737
|
+
poolPostsGrad.append('stop').attr('offset', '70%').attr('class', 'network-pool-edge network-pool-edge--posts');
|
|
738
|
+
|
|
739
|
+
var poolProjectsGrad = defs.append('radialGradient').attr('id', 'network-pool-projects-fill');
|
|
740
|
+
poolProjectsGrad.append('stop').attr('offset', '0%').attr('class', 'network-pool-core network-pool-core--projects');
|
|
741
|
+
poolProjectsGrad.append('stop').attr('offset', '70%').attr('class', 'network-pool-edge network-pool-edge--projects');
|
|
742
|
+
|
|
743
|
+
var poolArticlesSel = svg.append('ellipse')
|
|
744
|
+
.attr('class', 'network-pool network-pool--articles')
|
|
745
|
+
.attr('fill', 'url(#network-pool-articles-fill)');
|
|
746
|
+
|
|
747
|
+
var poolPostsSel = svg.append('ellipse')
|
|
748
|
+
.attr('class', 'network-pool network-pool--posts')
|
|
749
|
+
.attr('fill', 'url(#network-pool-posts-fill)');
|
|
750
|
+
|
|
751
|
+
var poolProjectsSel = svg.append('ellipse')
|
|
752
|
+
.attr('class', 'network-pool network-pool--projects')
|
|
753
|
+
.attr('fill', 'url(#network-pool-projects-fill)');
|
|
754
|
+
|
|
755
|
+
var poolSelByType = {
|
|
756
|
+
articles: poolArticlesSel,
|
|
757
|
+
posts: poolPostsSel,
|
|
758
|
+
projects: poolProjectsSel
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
var zoomG = svg.append('g');
|
|
762
|
+
var zoom = d3.zoom().scaleExtent([0.1, 4])
|
|
763
|
+
.on('zoom', function (ev) {
|
|
764
|
+
zoomG.attr('transform', ev.transform);
|
|
765
|
+
moveHovercard();
|
|
766
|
+
placePool();
|
|
767
|
+
});
|
|
768
|
+
svg.call(zoom);
|
|
769
|
+
|
|
770
|
+
document.querySelectorAll('[data-network-zoom]').forEach(function (btn) {
|
|
771
|
+
btn.addEventListener('click', function (ev) {
|
|
772
|
+
ev.preventDefault();
|
|
773
|
+
ev.stopPropagation();
|
|
774
|
+
var action = btn.getAttribute('data-network-zoom');
|
|
775
|
+
if (action === 'fit') {
|
|
776
|
+
fitToCanvas();
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
var factor = action === 'in' ? 1.25 : 0.8;
|
|
780
|
+
svg.transition().duration(180).call(zoom.scaleBy, factor);
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
/* A mark with no relation at all has nothing to say on a map of
|
|
785
|
+
relations, and it is already on the index pages. */
|
|
786
|
+
var connectedIds = new Set();
|
|
787
|
+
links.forEach(function (l) { connectedIds.add(l.source); connectedIds.add(l.target); });
|
|
788
|
+
var simNodes = rawNodes
|
|
789
|
+
.filter(function (d) { return connectedIds.has(d.id); })
|
|
790
|
+
.map(function (d) { return Object.assign({}, d); });
|
|
791
|
+
var byId = {};
|
|
792
|
+
simNodes.forEach(function (n) { byId[n.id] = n; });
|
|
793
|
+
|
|
794
|
+
var citeDegree = {};
|
|
795
|
+
links.forEach(function (l) {
|
|
796
|
+
if (l.kind !== 'cite') return;
|
|
797
|
+
citeDegree[l.source] = (citeDegree[l.source] || 0) + 1;
|
|
798
|
+
citeDegree[l.target] = (citeDegree[l.target] || 0) + 1;
|
|
799
|
+
});
|
|
800
|
+
simNodes.forEach(function (n) { n.deg = citeDegree[n.id] || 0; });
|
|
801
|
+
|
|
802
|
+
var typesDrawn = {};
|
|
803
|
+
var themesDrawn = 0;
|
|
804
|
+
simNodes.forEach(function (n) {
|
|
805
|
+
if (n.kind === 'theme') themesDrawn += 1;
|
|
806
|
+
else typesDrawn[n.type] = true;
|
|
807
|
+
});
|
|
808
|
+
renderLegend(typesDrawn, themesDrawn);
|
|
809
|
+
|
|
810
|
+
/* A graph with nothing in it is a real state — the type filter can
|
|
811
|
+
select a slice that shares neither a citation nor a theme — so say
|
|
812
|
+
so rather than leave an empty plate. */
|
|
813
|
+
if (!simNodes.length) {
|
|
814
|
+
var empty = document.createElement('p');
|
|
815
|
+
empty.className = 'network-empty font-body';
|
|
816
|
+
empty.textContent = 'No cross-references between the selected entries.';
|
|
817
|
+
container.appendChild(empty);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
var r0 = Math.min(W, H) * 0.18;
|
|
822
|
+
simNodes.forEach(function (d, i) {
|
|
823
|
+
var region = NETWORK_REGIONS[d.type] || { x: 0.5, y: 0.5 };
|
|
824
|
+
var a = (i / Math.max(simNodes.length, 1)) * 2 * Math.PI;
|
|
825
|
+
d.x = region.x * W + r0 * Math.cos(a);
|
|
826
|
+
d.y = region.y * H + r0 * Math.sin(a);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
var simLinks = links
|
|
830
|
+
.filter(function (l) { return byId[l.source] && byId[l.target]; })
|
|
831
|
+
.map(function (l) { return Object.assign({}, l); });
|
|
832
|
+
|
|
833
|
+
/* Stopped on creation: the layout is solved before the first paint
|
|
834
|
+
rather than animated into place over five seconds. A citation map is
|
|
835
|
+
something to read, not a demonstration of physics.
|
|
836
|
+
|
|
837
|
+
A theme pulls harder and holds its entries closer than a citation
|
|
838
|
+
does, which is what turns the plate into clusters rather than an
|
|
839
|
+
even field. The themes also repel each other hardest, so the
|
|
840
|
+
clusters they anchor stay apart instead of piling into the middle.
|
|
841
|
+
|
|
842
|
+
Soft X/Y forces bias each entry series toward its region of the
|
|
843
|
+
plate (articles top-left, posts top-right, projects bottom) without
|
|
844
|
+
pinning nodes hard enough to break citation geometry. */
|
|
845
|
+
function regionX(d) {
|
|
846
|
+
if (d.kind === 'theme') return W / 2;
|
|
847
|
+
var region = NETWORK_REGIONS[d.type];
|
|
848
|
+
return region ? region.x * W : W / 2;
|
|
849
|
+
}
|
|
850
|
+
function regionY(d) {
|
|
851
|
+
if (d.kind === 'theme') return H / 2;
|
|
852
|
+
var region = NETWORK_REGIONS[d.type];
|
|
853
|
+
return region ? region.y * H : H / 2;
|
|
854
|
+
}
|
|
855
|
+
var sim = d3.forceSimulation(simNodes)
|
|
856
|
+
.force('link', d3.forceLink(simLinks).id(function (d) { return d.id; })
|
|
857
|
+
.distance(function (l) { return l.kind === 'theme' ? 62 : 104; })
|
|
858
|
+
.strength(function (l) { return l.kind === 'theme' ? 0.85 : 0.35; }))
|
|
859
|
+
.force('charge', d3.forceManyBody().strength(function (d) {
|
|
860
|
+
return d.kind === 'theme' ? -420 : -230;
|
|
861
|
+
}))
|
|
862
|
+
.force('collide', d3.forceCollide(function (d) { return markSize(d) + 16; }))
|
|
863
|
+
.force('x', d3.forceX(regionX).strength(function (d) {
|
|
864
|
+
return d.kind === 'theme' ? 0.03 : 0.09;
|
|
865
|
+
}))
|
|
866
|
+
.force('y', d3.forceY(regionY).strength(function (d) {
|
|
867
|
+
return d.kind === 'theme' ? 0.03 : 0.09;
|
|
868
|
+
}))
|
|
869
|
+
.stop();
|
|
870
|
+
|
|
871
|
+
function srcNode(d) { return typeof d.source === 'object' ? d.source : byId[d.source]; }
|
|
872
|
+
function edgeClass(d) {
|
|
873
|
+
if (d.kind === 'theme') return 'network-mark network-edge network-edge--theme';
|
|
874
|
+
var s = srcNode(d);
|
|
875
|
+
return 'network-mark network-edge network-edge--' + (s ? s.type : 'posts');
|
|
876
|
+
}
|
|
877
|
+
function arrowRef(d) {
|
|
878
|
+
if (d.kind === 'theme') return null;
|
|
879
|
+
var s = srcNode(d);
|
|
880
|
+
return 'url(#arrow-' + (s ? s.type : 'posts') + ')';
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
var linkSel = zoomG.append('g').selectAll('path').data(simLinks).join('path')
|
|
884
|
+
.attr('class', edgeClass)
|
|
885
|
+
.attr('stroke-width', function (d) {
|
|
886
|
+
return d.kind === 'theme' ? 1 : 1.4 + (d.weight - 1) * 0.5;
|
|
887
|
+
})
|
|
888
|
+
.attr('marker-end', arrowRef);
|
|
889
|
+
|
|
890
|
+
var wLabelSel = zoomG.append('g')
|
|
891
|
+
.selectAll('text')
|
|
892
|
+
.data(simLinks.filter(function (l) { return l.kind === 'cite' && l.weight > 1; }))
|
|
893
|
+
.join('text')
|
|
894
|
+
.attr('class', 'network-mark network-weight-label')
|
|
895
|
+
.attr('text-anchor', 'middle')
|
|
896
|
+
.text(function (d) { return d.weight; });
|
|
897
|
+
|
|
898
|
+
var drag = d3.drag()
|
|
899
|
+
.on('start', function (ev, d) { if (!ev.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
|
|
900
|
+
.on('drag', function (ev, d) { d.fx = ev.x; d.fy = ev.y; })
|
|
901
|
+
.on('end', function (ev, d) { if (!ev.active) sim.alphaTarget(0); d.fx = null; d.fy = null; });
|
|
902
|
+
|
|
903
|
+
var nodeSel = zoomG.append('g').selectAll('rect').data(simNodes).join('rect')
|
|
904
|
+
.attr('class', function (d) {
|
|
905
|
+
return 'network-mark network-node network-node-mark ' +
|
|
906
|
+
(d.kind === 'theme' ? 'network-node-theme' : 'network-node-entry network-node-entry--' + d.type);
|
|
907
|
+
})
|
|
908
|
+
.attr('width', markSize).attr('height', markSize)
|
|
909
|
+
.attr('tabindex', 0)
|
|
910
|
+
.attr('role', function (d) { return d.kind === 'theme' ? 'img' : 'link'; })
|
|
911
|
+
.attr('aria-label', function (d) {
|
|
912
|
+
if (d.kind === 'theme') return 'Theme ' + d.title + ', ' + d.uses + ' entries';
|
|
913
|
+
var kind = d.type === 'articles' ? 'article'
|
|
914
|
+
: d.type === 'projects' ? 'project'
|
|
915
|
+
: 'rascunho';
|
|
916
|
+
return d.title + ' — ' + kind;
|
|
917
|
+
})
|
|
918
|
+
.call(drag);
|
|
919
|
+
|
|
920
|
+
/* Adjacency, built once. Focusing is a per-frame operation on a set,
|
|
921
|
+
not a walk of the edge list. */
|
|
922
|
+
var neighbors = {};
|
|
923
|
+
simNodes.forEach(function (n) { neighbors[n.id] = new Set([n.id]); });
|
|
924
|
+
simLinks.forEach(function (l) {
|
|
925
|
+
var s = l.source.id || l.source;
|
|
926
|
+
var t = l.target.id || l.target;
|
|
927
|
+
if (neighbors[s]) neighbors[s].add(t);
|
|
928
|
+
if (neighbors[t]) neighbors[t].add(s);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
/* Every theme is named, and so is every entry the corpus cites three
|
|
932
|
+
times or more: without a few landmarks an anonymous field of marks
|
|
933
|
+
is impossible to get oriented in. The rest of the titles wait for
|
|
934
|
+
the reader to ask. A label is dropped when its box would collide
|
|
935
|
+
with one already placed — a map with two names on top of each other
|
|
936
|
+
has no names. */
|
|
937
|
+
var LABEL_THRESHOLD = 3;
|
|
938
|
+
function labelText(d) {
|
|
939
|
+
if (d.kind === 'theme') return d.title;
|
|
940
|
+
return d.title.length > 28 ? d.title.slice(0, 27) + '…' : d.title;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
var labelSel = zoomG.append('g').selectAll('text').data(simNodes).join('text')
|
|
944
|
+
.attr('class', function (d) {
|
|
945
|
+
return 'network-mark network-label network-label--' +
|
|
946
|
+
(d.kind === 'theme' ? 'theme' : 'entry');
|
|
947
|
+
})
|
|
948
|
+
.attr('text-anchor', 'middle')
|
|
949
|
+
.text(labelText);
|
|
950
|
+
|
|
951
|
+
function placeLabels() {
|
|
952
|
+
var placed = [];
|
|
953
|
+
labelSel.each(function (d) {
|
|
954
|
+
var el = d3.select(this);
|
|
955
|
+
var isLandmark = d.kind === 'theme' || d.deg >= LABEL_THRESHOLD;
|
|
956
|
+
if (!isLandmark) {
|
|
957
|
+
el.classed('network-label--demand', true);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
var box = this.getBBox();
|
|
961
|
+
var clash = placed.some(function (p) {
|
|
962
|
+
return !(box.x + box.width < p.x || box.x > p.x + p.width ||
|
|
963
|
+
box.y + box.height < p.y || box.y > p.y + p.height);
|
|
964
|
+
});
|
|
965
|
+
el.classed('network-label--demand', clash);
|
|
966
|
+
if (!clash) placed.push(box);
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/* ── Focus ── */
|
|
971
|
+
var focusedId = null;
|
|
972
|
+
|
|
973
|
+
function hovercardHtml(d) {
|
|
974
|
+
var parts = [];
|
|
975
|
+
if (d.kind === 'theme') {
|
|
976
|
+
parts.push('<p class="network-hovercard-kicker">Theme</p>');
|
|
977
|
+
parts.push('<h3 class="network-hovercard-title">' + runtime.escapeHtml(d.title) + '</h3>');
|
|
978
|
+
parts.push('<p class="network-hovercard-count">' + d.uses + ' entries carry this theme.</p>');
|
|
979
|
+
return parts.join('');
|
|
980
|
+
}
|
|
981
|
+
parts.push('<div class="network-hovercard-head">');
|
|
982
|
+
parts.push('<span class="network-hovercard-kicker">' +
|
|
983
|
+
(d.type === 'articles' ? 'Article'
|
|
984
|
+
: d.type === 'projects' ? 'Project'
|
|
985
|
+
: 'Rascunho') + '</span>');
|
|
986
|
+
if (d.date) parts.push('<span class="network-hovercard-date">' + runtime.escapeHtml(d.date) + '</span>');
|
|
987
|
+
parts.push('</div>');
|
|
988
|
+
parts.push('<h3 class="network-hovercard-title">' + runtime.escapeHtml(d.title) + '</h3>');
|
|
989
|
+
var degree = neighbors[d.id] ? Math.max(0, neighbors[d.id].size - 1) : 0;
|
|
990
|
+
parts.push('<p class="network-hovercard-count">' +
|
|
991
|
+
(degree === 1 ? '1 neighbor' : degree + ' neighbors') + '</p>');
|
|
992
|
+
if (d.blurb) {
|
|
993
|
+
/* A standfirst is written to a card's measure, not a hover
|
|
994
|
+
card's: left whole, the longest of them doubles the card's
|
|
995
|
+
height and covers the neighborhood the focus just drew. */
|
|
996
|
+
var blurb = d.blurb.length > 168 ? d.blurb.slice(0, 167).replace(/\s+\S*$/, '') + '…' : d.blurb;
|
|
997
|
+
parts.push('<p class="network-hovercard-blurb">' + runtime.escapeHtml(blurb) + '</p>');
|
|
998
|
+
}
|
|
999
|
+
if (d.themes.length) {
|
|
1000
|
+
parts.push('<p class="network-hovercard-themes">' + d.themes.map(function (name) {
|
|
1001
|
+
return '<span class="network-hovercard-theme">' +
|
|
1002
|
+
runtime.escapeHtml(themeDisplayName(name)) + '</span>';
|
|
1003
|
+
}).join('') + '</p>');
|
|
1004
|
+
}
|
|
1005
|
+
return parts.join('');
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function moveHovercard() {
|
|
1009
|
+
if (!hovercard || !focusedId) return;
|
|
1010
|
+
var d = byId[focusedId];
|
|
1011
|
+
if (!d) return;
|
|
1012
|
+
var t = d3.zoomTransform(svg.node());
|
|
1013
|
+
var px = t.applyX(d.x);
|
|
1014
|
+
var py = t.applyY(d.y);
|
|
1015
|
+
var cw = hovercard.offsetWidth;
|
|
1016
|
+
var ch = hovercard.offsetHeight;
|
|
1017
|
+
var left = px + 16;
|
|
1018
|
+
var top = py + 14;
|
|
1019
|
+
if (left + cw > W - 8) left = px - cw - 16;
|
|
1020
|
+
if (left < 8) left = 8;
|
|
1021
|
+
if (top + ch > H - 8) top = H - ch - 8;
|
|
1022
|
+
if (top < 8) top = 8;
|
|
1023
|
+
hovercard.style.left = left + 'px';
|
|
1024
|
+
hovercard.style.top = top + 'px';
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function focusNode(d) {
|
|
1028
|
+
focusedId = d.id;
|
|
1029
|
+
var near = neighbors[d.id] || new Set([d.id]);
|
|
1030
|
+
svg.classed('network-svg--focusing', true);
|
|
1031
|
+
nodeSel.classed('network-mark--near', function (n) { return near.has(n.id); });
|
|
1032
|
+
labelSel.classed('network-mark--near', function (n) { return near.has(n.id); });
|
|
1033
|
+
linkSel.classed('network-mark--near', function (l) {
|
|
1034
|
+
return near.has(l.source.id) && near.has(l.target.id);
|
|
1035
|
+
});
|
|
1036
|
+
wLabelSel.classed('network-mark--near', function (l) {
|
|
1037
|
+
return near.has(l.source.id) && near.has(l.target.id);
|
|
1038
|
+
});
|
|
1039
|
+
if (hovercard) {
|
|
1040
|
+
hovercard.setAttribute('data-type', d.type);
|
|
1041
|
+
hovercard.innerHTML = hovercardHtml(d);
|
|
1042
|
+
hovercard.setAttribute('data-open', 'true');
|
|
1043
|
+
moveHovercard();
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function clearFocus() {
|
|
1048
|
+
focusedId = null;
|
|
1049
|
+
svg.classed('network-svg--focusing', false);
|
|
1050
|
+
nodeSel.classed('network-mark--near', false);
|
|
1051
|
+
labelSel.classed('network-mark--near', false);
|
|
1052
|
+
linkSel.classed('network-mark--near', false);
|
|
1053
|
+
wLabelSel.classed('network-mark--near', false);
|
|
1054
|
+
if (hovercard) hovercard.setAttribute('data-open', 'false');
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function openNode(d) {
|
|
1058
|
+
if (d.kind === 'theme' || !d.url) return;
|
|
1059
|
+
window.location.href = d.url;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/* Pointer, keyboard and touch all reach the same two states. A mouse
|
|
1063
|
+
focuses on hover, so its click opens on the first press; a finger
|
|
1064
|
+
has no hover, so its first tap focuses and the second opens. */
|
|
1065
|
+
nodeSel
|
|
1066
|
+
.on('mouseenter', function (_, d) { focusNode(d); })
|
|
1067
|
+
.on('mouseleave', function () { clearFocus(); })
|
|
1068
|
+
.on('focus', function (_, d) { focusNode(d); })
|
|
1069
|
+
.on('blur', function () { clearFocus(); })
|
|
1070
|
+
.on('click', function (ev, d) {
|
|
1071
|
+
ev.stopPropagation();
|
|
1072
|
+
if (focusedId === d.id) openNode(d);
|
|
1073
|
+
else focusNode(d);
|
|
1074
|
+
})
|
|
1075
|
+
.on('keydown', function (ev, d) {
|
|
1076
|
+
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
1077
|
+
ev.preventDefault();
|
|
1078
|
+
openNode(d);
|
|
1079
|
+
} else if (ev.key === 'Escape') {
|
|
1080
|
+
clearFocus();
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
svg.on('click', function () { clearFocus(); });
|
|
1085
|
+
|
|
1086
|
+
/* Once the layout settles, frame it. A force graph left at its own
|
|
1087
|
+
scale spills past the plate and the reader has to hunt for the parts
|
|
1088
|
+
that fell off the edge. */
|
|
1089
|
+
function fitToCanvas() {
|
|
1090
|
+
var xs = simNodes.map(function (d) { return d.x; });
|
|
1091
|
+
var ys = simNodes.map(function (d) { return d.y; });
|
|
1092
|
+
/* The margin is a label's worth, not a mark's: a mark at the edge
|
|
1093
|
+
of the cloud carries a name wider than itself. */
|
|
1094
|
+
var margin = 70;
|
|
1095
|
+
var minX = Math.min.apply(null, xs) - margin;
|
|
1096
|
+
var maxX = Math.max.apply(null, xs) + margin;
|
|
1097
|
+
var minY = Math.min.apply(null, ys) - margin / 2;
|
|
1098
|
+
var maxY = Math.max.apply(null, ys) + margin / 2;
|
|
1099
|
+
var bw = Math.max(maxX - minX, 1);
|
|
1100
|
+
var bh = Math.max(maxY - minY, 1);
|
|
1101
|
+
var scale = Math.max(0.45, Math.min(1.15, Math.min(W / bw, H / bh)));
|
|
1102
|
+
var tx = W / 2 - scale * (minX + maxX) / 2;
|
|
1103
|
+
var ty = H / 2 - scale * (minY + maxY) / 2;
|
|
1104
|
+
svg.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/* Where the corpus concentrates, measured rather than chosen.
|
|
1108
|
+
|
|
1109
|
+
A plain centroid of the marks is the wrong statistic: the layout
|
|
1110
|
+
carries its own centring force, so the mean of everything lands in
|
|
1111
|
+
the middle of the plate whatever the graph does, and a pool in the
|
|
1112
|
+
middle of its container is a vignette rather than a lamp. What the
|
|
1113
|
+
pool is for is the *densest* part of the cloud, which is a peak and
|
|
1114
|
+
not a mean.
|
|
1115
|
+
|
|
1116
|
+
So each mark scores the relations of every other mark, falling off
|
|
1117
|
+
with distance on a kernel a fifth of the plate wide, and the pool
|
|
1118
|
+
centres on the highest score. Its radius is the kernel-weighted
|
|
1119
|
+
spread around that point, opened out, because a pool that stops at
|
|
1120
|
+
one standard deviation lights the middle of a cluster and leaves
|
|
1121
|
+
its edge dark. Nothing here is a coordinate someone picked. */
|
|
1122
|
+
var poolCentres = { articles: null, posts: null, projects: null };
|
|
1123
|
+
|
|
1124
|
+
function poolWeight(n) {
|
|
1125
|
+
return neighbors[n.id] ? neighbors[n.id].size - 1 : 0;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function measureClusterPool(type) {
|
|
1129
|
+
var targetNodes = simNodes.filter(function (n) {
|
|
1130
|
+
return n.type === type;
|
|
1131
|
+
});
|
|
1132
|
+
if (!targetNodes.length) return null;
|
|
1133
|
+
|
|
1134
|
+
var k = Math.min(W, H) / 5;
|
|
1135
|
+
var falloff = 2 * k * k;
|
|
1136
|
+
|
|
1137
|
+
function kernelAt(x, y) {
|
|
1138
|
+
var sum = 0;
|
|
1139
|
+
targetNodes.forEach(function (b) {
|
|
1140
|
+
var dx = x - b.x;
|
|
1141
|
+
var dy = y - b.y;
|
|
1142
|
+
sum += (poolWeight(b) + 1) * Math.exp(-(dx * dx + dy * dy) / falloff);
|
|
1143
|
+
});
|
|
1144
|
+
return sum;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
var peak = null;
|
|
1148
|
+
var best = 0;
|
|
1149
|
+
targetNodes.forEach(function (a) {
|
|
1150
|
+
var score = kernelAt(a.x, a.y);
|
|
1151
|
+
if (score > best) {
|
|
1152
|
+
best = score;
|
|
1153
|
+
peak = a;
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
if (!peak) return null;
|
|
1157
|
+
|
|
1158
|
+
var mass = 0;
|
|
1159
|
+
var spread = 0;
|
|
1160
|
+
targetNodes.forEach(function (b) {
|
|
1161
|
+
var dx = b.x - peak.x;
|
|
1162
|
+
var dy = b.y - peak.y;
|
|
1163
|
+
var d2 = dx * dx + dy * dy;
|
|
1164
|
+
var w = (poolWeight(b) + 1) * Math.exp(-d2 / falloff);
|
|
1165
|
+
mass += w;
|
|
1166
|
+
spread += w * d2;
|
|
1167
|
+
});
|
|
1168
|
+
return {
|
|
1169
|
+
x: peak.x,
|
|
1170
|
+
y: peak.y,
|
|
1171
|
+
r: mass ? Math.sqrt(spread / mass) * 2.2 : k
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function measurePool() {
|
|
1176
|
+
poolCentres.articles = measureClusterPool('articles');
|
|
1177
|
+
poolCentres.posts = measureClusterPool('posts');
|
|
1178
|
+
poolCentres.projects = measureClusterPool('projects');
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/* Screen space, like the hover card: the pools track their clusters
|
|
1182
|
+
under panning and scale with the zoom, but the blur beNewsreader them stays
|
|
1183
|
+
the same 60px at every magnification instead of dissolving as the
|
|
1184
|
+
reader pulls back. */
|
|
1185
|
+
function placePool() {
|
|
1186
|
+
var t = d3.zoomTransform(svg.node());
|
|
1187
|
+
['articles', 'posts', 'projects'].forEach(function (type) {
|
|
1188
|
+
var c = poolCentres[type];
|
|
1189
|
+
var sel = poolSelByType[type];
|
|
1190
|
+
if (!c) {
|
|
1191
|
+
sel.attr('rx', 0).attr('ry', 0);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
sel
|
|
1195
|
+
.attr('cx', t.applyX(c.x))
|
|
1196
|
+
.attr('cy', t.applyY(c.y))
|
|
1197
|
+
.attr('rx', c.r * t.k)
|
|
1198
|
+
.attr('ry', c.r * t.k);
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function placeMarks() {
|
|
1203
|
+
linkSel.attr('d', arcPath);
|
|
1204
|
+
wLabelSel
|
|
1205
|
+
.attr('x', function (d) { return (d.source.x + d.target.x) / 2; })
|
|
1206
|
+
.attr('y', function (d) { return (d.source.y + d.target.y) / 2; });
|
|
1207
|
+
nodeSel
|
|
1208
|
+
.attr('x', function (d) { return d.x - markSize(d) / 2; })
|
|
1209
|
+
.attr('y', function (d) { return d.y - markSize(d) / 2; });
|
|
1210
|
+
labelSel
|
|
1211
|
+
.attr('x', function (d) { return d.x; })
|
|
1212
|
+
.attr('y', function (d) { return d.y - markSize(d) / 2 - 7; });
|
|
1213
|
+
moveHovercard();
|
|
1214
|
+
measurePool();
|
|
1215
|
+
placePool();
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/* Solve, place, frame. Dragging restarts the simulation from here, so
|
|
1219
|
+
the graph stays live without ever re-framing itself under the
|
|
1220
|
+
reader's hand. */
|
|
1221
|
+
sim.on('tick', placeMarks);
|
|
1222
|
+
sim.tick(400);
|
|
1223
|
+
placeMarks();
|
|
1224
|
+
placeLabels();
|
|
1225
|
+
fitToCanvas();
|
|
1226
|
+
|
|
1227
|
+
window.addEventListener('resize', function () {
|
|
1228
|
+
var d2 = getSize();
|
|
1229
|
+
W = d2.W;
|
|
1230
|
+
H = d2.H;
|
|
1231
|
+
svg.attr('width', W).attr('height', H);
|
|
1232
|
+
bgRect.attr('width', W).attr('height', H);
|
|
1233
|
+
/* The kernel is a fifth of the plate, so a resized plate is a
|
|
1234
|
+
different measurement. */
|
|
1235
|
+
measurePool();
|
|
1236
|
+
fitToCanvas();
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
runtime.loadJson('data/writing_index.json').then(function (writingIndex) {
|
|
1241
|
+
writingIndexCache = writingIndex;
|
|
1242
|
+
var keyed = writingIndex.filter(function (item) {
|
|
1243
|
+
return item.key && NETWORK_ENTRY_TYPES[nodeType(item)];
|
|
1244
|
+
});
|
|
1245
|
+
initFilter(keyed);
|
|
1246
|
+
var initialType = filterUi ? filterUi.getState().type : params.get('type');
|
|
1247
|
+
renderNetworkGraph(writingIndexCache, initialType);
|
|
1248
|
+
}).catch(function (error) {
|
|
1249
|
+
console.error(error);
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
}());
|
|
1253
|
+
</script>
|