diff --git a/artefact_kino/artefact_kino.livemd b/artefact_kino/artefact_kino.livemd index 59f72cb..2298981 100644 --- a/artefact_kino/artefact_kino.livemd +++ b/artefact_kino/artefact_kino.livemd @@ -96,6 +96,64 @@ Harmonise merges labels and properties of bound nodes, and properties of bound r The knowledge we have harmonized here is ArtefactCreateMerge, a combination of Artefact and the CreateMerge Artefacts. +## Side by Side + +A common pattern is showing knowledge grow progressively — a section artefact on the left, +the integrated whole on the right, chapter by chapter. `Kino.Layout.grid` places two +`ArtefactKino` widgets side by side. Two small things make this work well. + +**Consistent colours.** The same label string always produces the same colour, regardless +of how many labels either artefact contains. An `Agent` node will be the same shade of +blue-green in a two-label artefact as it is in a ten-label one. You can follow a concept +by colour across panels without thinking about it. + +**Stable header height.** When an artefact carries a long description — a Cypher query or +a chapter summary — the header expands and the graph viewports in the two panels no longer +align. Pass `max_description_lines:` to cap the description to a fixed number of lines with +overflow hidden. Both panels keep the same height and the graphs line up. + +```elixir +require Artefact + +section = + Artefact.new!( + title: "Section 1", + base_label: "Knowing", + description: "MATCH (a:Agent)-[:KNOWS]->(b:Concept)\nRETURN a, b", + nodes: [ + matt: [labels: ["Agent"], properties: %{"name" => "Matt"}, uuid: "019da897-f2de-77ca-b5a4-40f0c3730943"], + knowing: [labels: ["Concept"], properties: %{"name" => "Knowing"}, uuid: "019da897-0000-7000-8000-000000000001"] + ], + relationships: [ + [from: :matt, type: "KNOWS", to: :knowing] + ] + ) + +integrated = + Artefact.new!( + title: "Integrated", + base_label: "Valuing", + description: "MATCH (a:Agent)-[:KNOWS|VALUES]->(b:Concept)\nRETURN a, b", + nodes: [ + matt: [labels: ["Agent"], properties: %{"name" => "Matt"}, uuid: "019da897-f2de-77ca-b5a4-40f0c3730943"], + know: [labels: ["Concept"], properties: %{"name" => "Knowing"}, uuid: "019da897-0000-7000-8000-000000000001"], + value: [labels: ["Concept"], properties: %{"name" => "Valuing"}, uuid: "019da897-0000-7000-8000-000000000002"] + ], + relationships: [ + [from: :matt, type: "KNOWS", to: :know], + [from: :matt, type: "VALUES", to: :value] + ] + ) + +Kino.Layout.grid([ + ArtefactKino.new(section, max_description_lines: 2), + ArtefactKino.new(integrated, max_description_lines: 2) +], columns: 2) +``` + +Notice that the `Agent` node (Matt) is the same colour in both panels, and the two graph +viewports sit at the same height despite the descriptions being different lengths. + ## What Next? Artefacts are knowledge graph fragments. We like to store knowledge in graphical databases, so explore exporting cypher to a graph database with creat and merge. Review the CreateMerge Artifact to understand what create and merge are best for. diff --git a/artefact_kino/lib/artefact_kino.ex b/artefact_kino/lib/artefact_kino.ex index c4bb2e6..17d7677 100644 --- a/artefact_kino/lib/artefact_kino.ex +++ b/artefact_kino/lib/artefact_kino.ex @@ -28,14 +28,18 @@ defmodule ArtefactKino do Options: - `default:` — `:create` (default) or `:merge` + - `max_description_lines:` — integer; caps the description to this many + lines with overflow hidden. Useful when placing widgets side by side so + that long descriptions do not cause misaligned graph viewports. """ def new(%Artefact{} = artefact, opts \\ []) do Artefact.validate!(artefact) default = Keyword.get(opts, :default, :create) - Kino.JS.new(__MODULE__, build_data(artefact, default)) + max_description_lines = Keyword.get(opts, :max_description_lines, nil) + Kino.JS.new(__MODULE__, build_data(artefact, default, max_description_lines)) end - defp build_data(artefact, default) do + defp build_data(artefact, default, max_description_lines) do %{ nodes: vis_nodes(artefact), edges: vis_edges(artefact), @@ -46,6 +50,7 @@ defmodule ArtefactKino do default: Atom.to_string(default), title: artefact.title || artefact.base_label || "Artefact", description: artefact.description, + max_description_lines: max_description_lines, artefact_rows: artefact_rows(artefact), nodes_rows: nodes_rows(artefact), rels_rows: rels_rows(artefact) @@ -135,13 +140,12 @@ defmodule ArtefactKino do // -- colour theory -- - function buildLabelHues(nodes) { - const labels = new Set(); - nodes.forEach(n => n.labels.forEach(l => labels.add(l))); - const sorted = [...labels].sort(); - const hues = {}; - sorted.forEach((l, i) => { hues[l] = (i / sorted.length) * 360; }); - return hues; + function labelToHue(label) { + let h = 0; + for (let i = 0; i < label.length; i++) { + h = (h * 31 + label.charCodeAt(i)) & 0xffffffff; + } + return (h >>> 0) % 360; } function blendHues(hues) { @@ -164,10 +168,9 @@ defmodule ArtefactKino do return "#" + [r, g, b].map(c => Math.round(Math.max(0, Math.min(1, c)) * 255).toString(16).padStart(2, "0")).join(""); } - function nodeColour(labels, labelHues) { + function nodeColour(labels) { if (!labels || labels.length === 0) return { bg: "#2a2a2a", border: "#555" }; - const hues = labels.map(l => labelHues[l] ?? 0); - const blended = blendHues(hues); + const blended = blendHues(labels.map(labelToHue)); const [r1, g1, b1] = hslToRGB(blended, 55, 30); const [r2, g2, b2] = hslToRGB(blended, 65, 50); return { @@ -207,11 +210,15 @@ defmodule ArtefactKino do .replace(/"/g, """) .replace(/'/g, "'"); + const descStyle = data.max_description_lines + ? `font-size:11px;color:#888;margin-top:2px;font-style:italic;display:-webkit-box;-webkit-line-clamp:${data.max_description_lines};-webkit-box-orient:vertical;overflow:hidden;` + : `font-size:11px;color:#888;margin-top:2px;font-style:italic;white-space:pre-line;`; + const headerHtml = `