diff --git a/packages/frontend/src/diagram/diagram_editor.tsx b/packages/frontend/src/diagram/diagram_editor.tsx index a995926c8..f34eaa664 100644 --- a/packages/frontend/src/diagram/diagram_editor.tsx +++ b/packages/frontend/src/diagram/diagram_editor.tsx @@ -55,7 +55,7 @@ export function DiagramDocumentEditor(props: { const navigate = useNavigate(); const onCreateAnalysis = async (diagramRefId: string) => { - const newRef = createAnalysis("diagram", diagramRefId, api); + const newRef = await createAnalysis("diagram", diagramRefId, api); navigate(`/analysis/${newRef}`); }; diff --git a/packages/frontend/src/stdlib/analyses/diagram_graph.tsx b/packages/frontend/src/stdlib/analyses/diagram_graph.tsx new file mode 100644 index 000000000..71eb61c1f --- /dev/null +++ b/packages/frontend/src/stdlib/analyses/diagram_graph.tsx @@ -0,0 +1,193 @@ +import type * as Viz from "@viz-js/viz"; +import { Show, createSignal } from "solid-js"; +import { P, match } from "ts-pattern"; + +import type { Uuid } from "catlog-wasm"; +import type { DiagramAnalysisProps } from "../../analysis"; +import type { DiagramJudgment } from "../../diagram"; +import type { DiagramAnalysisMeta, Theory } from "../../theory"; +import { DownloadSVGButton, GraphvizSVG } from "../../visualization"; +import { + type GraphContent, + type GraphvizAttributes, + defaultEdgeAttributes, + defaultGraphAttributes, + defaultNodeAttributes, + graphvizEngine, + graphvizFontname, + svgCssClasses, +} from "./graph"; + +import baseStyles from "./base_styles.module.css"; + +/** Configure a graph visualization for use with diagrams in a model. */ +export function configureDiagramGraph(options: { + id: string; + name: string; + description?: string; +}): DiagramAnalysisMeta { + const { id, name, description } = options; + return { + id, + name, + description, + component: (props) => , + initialContent: () => ({ + tag: "graph", + layout: "graphviz-directed", + }), + }; +} + +/** Visualize a diagram in a model as a graph. + +Such a visualizations makes sense for any discrete double theory and is in +general restricted to basic objects. See `ModelGraph` for more. + */ +export function DiagramGraph( + props: { + title?: string; + } & DiagramAnalysisProps, +) { + const [svgRef, setSvgRef] = createSignal(); + + const graphviz = () => { + const liveModel = props.liveDiagram.liveModel; + const theory = liveModel.theory(); + return ( + theory && + diagramToGraphviz(props.liveDiagram.formalJudgments(), theory, { + baseObName(id) { + return liveModel.objectIndex().map.get(id); + }, + baseMorName(id) { + return liveModel.morphismIndex().map.get(id); + }, + }) + ); + }; + + const title = () => props.title ?? "Diagram"; + + return ( +
+
+ {title()} + + +
+ + {(graph) => ( + + )} + +
+ ); +} + +/** Convert a diagram in a model into a Graphviz graph. + */ +export function diagramToGraphviz( + diagram: DiagramJudgment[], + theory: Theory, + options?: { + baseObName?: (id: Uuid) => string | undefined; + baseMorName: (id: Uuid) => string | undefined; + attributes?: GraphvizAttributes; + }, +): Viz.Graph { + const nodes = new Map["nodes"][0]>(); + for (const judgment of diagram) { + const matched = match(judgment) + .with( + { + tag: "object", + obType: P.select("obType"), + over: { + tag: "Basic", + content: P.select("overId"), + }, + }, + (matched) => matched, + ) + .otherwise(() => null); + if (!matched) { + continue; + } + const { id, name } = judgment; + const { obType, overId } = matched; + const label = [name, options?.baseObName?.(overId)].filter((s) => s).join(" : "); + const meta = theory.instanceObTypeMeta(obType); + nodes.set(id, { + name: id, + attributes: { + id, + label, + class: svgCssClasses(meta).join(" "), + fontname: graphvizFontname(meta), + }, + }); + } + + const edges: Required["edges"] = []; + for (const judgment of diagram) { + const matched = match(judgment) + .with( + { + tag: "morphism", + morType: P.select("morType"), + over: { + tag: "Basic", + content: P.select("overId"), + }, + dom: { + tag: "Basic", + content: P.select("domId"), + }, + cod: { + tag: "Basic", + content: P.select("codId"), + }, + }, + (matched) => matched, + ) + .otherwise(() => null); + if (!matched) { + continue; + } + const { id, name } = judgment; + const { morType, overId, codId, domId } = matched; + const label = [name, options?.baseMorName?.(overId)].filter((s) => s).join(" : "); + const meta = theory.instanceMorTypeMeta(morType); + edges.push({ + head: codId, + tail: domId, + attributes: { + id, + label, + class: svgCssClasses(meta).join(" "), + fontname: graphvizFontname(meta), + }, + }); + } + + const attributes = options?.attributes; + return { + directed: true, + nodes: Array.from(nodes.values()), + edges, + graphAttributes: { ...defaultGraphAttributes, ...attributes?.graph }, + nodeAttributes: { ...defaultNodeAttributes, ...attributes?.node }, + edgeAttributes: { ...defaultEdgeAttributes, ...attributes?.edge }, + }; +} diff --git a/packages/frontend/src/stdlib/analyses/graph.ts b/packages/frontend/src/stdlib/analyses/graph.ts new file mode 100644 index 000000000..43303fac4 --- /dev/null +++ b/packages/frontend/src/stdlib/analyses/graph.ts @@ -0,0 +1,65 @@ +import type * as Viz from "@viz-js/viz"; + +import type { BaseTypeMeta } from "../../theory"; + +import textStyles from "../text_styles.module.css"; + +/** Graph layout engine supported by CatColab. + +Currently we just use Graphviz. In the future we may support other tools. + */ +type LayoutEngine = "graphviz-directed" | "graphviz-undirected"; + +/** Configuration for an analysis that visualizes a graph. */ +export type GraphContent = { + tag: "graph"; + + /** Layout engine for graph. */ + layout: LayoutEngine; +}; + +export function graphvizEngine(layout: LayoutEngine): Viz.RenderOptions["engine"] { + if (layout === "graphviz-directed") { + return "dot"; + } else if (layout === "graphviz-undirected") { + return "neato"; + } +} + +/** Top-level attributes of a Graphviz graph. */ +export type GraphvizAttributes = { + graph?: Viz.Graph["graphAttributes"]; + node?: Viz.Graph["nodeAttributes"]; + edge?: Viz.Graph["edgeAttributes"]; +}; + +/** Default graph attributes for Graphviz. */ +export const defaultGraphAttributes: Required["graphAttributes"] = { + nodesep: "0.5", +}; + +/** Default node attributes for Graphviz. */ +export const defaultNodeAttributes: Required["nodeAttributes"] = { + // XXX: How to set the font size? + fontsize: "20", + shape: "box", + width: 0, + height: 0, +}; + +/** Default edge attributes for Graphviz. */ +export const defaultEdgeAttributes: Required["edgeAttributes"] = { + fontsize: "20", + sep: "5", +}; + +// XXX: Precise font matching seems impossible here but we'll at least give +// Graphviz a monospace font if and only if we're using one. +export const graphvizFontname = (meta?: BaseTypeMeta): string => + meta?.textClasses?.includes(textStyles.code) ? "Courier" : "Helvetica"; + +// XXX: This should probably go somewhere else. +export const svgCssClasses = (meta?: BaseTypeMeta): string[] => [ + ...(meta?.svgClasses ?? []), + ...(meta?.textClasses ?? []), +]; diff --git a/packages/frontend/src/stdlib/analyses/index.ts b/packages/frontend/src/stdlib/analyses/index.ts index e896f5c54..77e462541 100644 --- a/packages/frontend/src/stdlib/analyses/index.ts +++ b/packages/frontend/src/stdlib/analyses/index.ts @@ -1,4 +1,5 @@ export * from "./lotka_volterra"; export * from "./model_graph"; +export * from "./diagram_graph"; export * from "./stock_flow_diagram"; export * from "./submodel_graphs"; diff --git a/packages/frontend/src/stdlib/analyses/model_graph.tsx b/packages/frontend/src/stdlib/analyses/model_graph.tsx index 41ca6cd7c..e71220da8 100644 --- a/packages/frontend/src/stdlib/analyses/model_graph.tsx +++ b/packages/frontend/src/stdlib/analyses/model_graph.tsx @@ -1,28 +1,30 @@ import type * as Viz from "@viz-js/viz"; import { Show, createSignal } from "solid-js"; +import { P, match } from "ts-pattern"; import type { ModelAnalysisProps } from "../../analysis"; import type { ModelJudgment } from "../../model"; -import type { ModelAnalysisMeta, ModelTypeMeta, Theory } from "../../theory"; +import type { ModelAnalysisMeta, Theory } from "../../theory"; import { DownloadSVGButton, GraphvizSVG, type SVGRefProp } from "../../visualization"; +import { + type GraphContent, + type GraphvizAttributes, + defaultEdgeAttributes, + defaultGraphAttributes, + defaultNodeAttributes, + graphvizEngine, + graphvizFontname, + svgCssClasses, +} from "./graph"; -import textStyles from "../text_styles.module.css"; import baseStyles from "./base_styles.module.css"; -/** Configuration for a graph visualization of a model. */ -export type ModelGraphContent = { - tag: "graph"; - - /** Layout engine for graph. */ - layout: "graphviz-directed" | "graphviz-undirected"; -}; - /** Configure a graph visualization for use with models of a double theory. */ export function configureModelGraph(options: { id: string; name: string; description?: string; -}): ModelAnalysisMeta { +}): ModelAnalysisMeta { const { id, name, description } = options; return { id, @@ -49,7 +51,7 @@ may be added in the future. export function ModelGraph( props: { title?: string; - } & ModelAnalysisProps, + } & ModelAnalysisProps, ) { const [svgRef, setSvgRef] = createSignal(); @@ -82,20 +84,10 @@ export function ModelGraph( ); } -export function graphvizEngine(layout: ModelGraphContent["layout"]) { - let engine!: Viz.RenderOptions["engine"]; - if (layout === "graphviz-directed") { - engine = "dot"; - } else if (layout === "graphviz-undirected") { - engine = "neato"; - } - return engine; -} - /** Visualize a model of a double theory as a graph using Graphviz. */ export function ModelGraphviz(props: { - model: Array; + model: ModelJudgment[]; theory: Theory; attributes?: GraphvizAttributes; options?: Viz.RenderOptions; @@ -113,7 +105,7 @@ export function ModelGraphviz(props: { /** Convert a model of a double theory into a Graphviz graph. */ export function modelToGraphviz( - model: Array, + model: ModelJudgment[], theory: Theory, attributes?: GraphvizAttributes, ): Viz.Graph { @@ -127,8 +119,8 @@ export function modelToGraphviz( attributes: { id, label: name, - class: cssClass(meta), - fontname: fontname(meta), + class: svgCssClasses(meta).join(" "), + fontname: graphvizFontname(meta), }, }); } @@ -136,30 +128,40 @@ export function modelToGraphviz( const edges: Required["edges"] = []; for (const judgment of model) { - if (judgment.tag === "morphism") { - const { id, name, dom, cod } = judgment; - if ( - dom?.tag !== "Basic" || - cod?.tag !== "Basic" || - !nodes.has(dom.content) || - !nodes.has(cod.content) - ) { - continue; - } - const meta = theory.modelMorTypeMeta(judgment.morType); - edges.push({ - head: cod.content, - tail: dom.content, - attributes: { - id, - label: name, - class: cssClass(meta), - fontname: fontname(meta), - // Not recognized by Graphviz but will be passed through! - arrowstyle: meta?.arrowStyle ?? "default", + const matched = match(judgment) + .with( + { + tag: "morphism", + morType: P.select("morType"), + dom: { + tag: "Basic", + content: P.select("domId"), + }, + cod: { + tag: "Basic", + content: P.select("codId"), + }, }, - }); + (matched) => matched, + ) + .otherwise(() => null); + if (!matched) { + continue; } + const { morType, codId, domId } = matched; + const meta = theory.modelMorTypeMeta(morType); + edges.push({ + head: codId, + tail: domId, + attributes: { + id: judgment.id, + label: judgment.name, + class: svgCssClasses(meta).join(" "), + fontname: graphvizFontname(meta), + // Not recognized by Graphviz but will be passed through! + arrowstyle: meta?.arrowStyle ?? "default", + }, + }); } return { @@ -171,35 +173,3 @@ export function modelToGraphviz( edgeAttributes: { ...defaultEdgeAttributes, ...attributes?.edge }, }; } - -/** Top-level attributes of a Graphviz graph. */ -export type GraphvizAttributes = { - graph?: Viz.Graph["graphAttributes"]; - node?: Viz.Graph["nodeAttributes"]; - edge?: Viz.Graph["edgeAttributes"]; -}; - -const cssClass = (meta?: ModelTypeMeta): string => - [...(meta?.svgClasses ?? []), ...(meta?.textClasses ?? [])].join(" "); - -// XXX: Precise font matching seems impossible here but we'll at least give -// Graphviz a monospace font if and only if we're using one. -const fontname = (meta?: ModelTypeMeta) => - meta?.textClasses?.includes(textStyles.code) ? "Courier" : "Helvetica"; - -const defaultGraphAttributes = { - nodesep: "0.5", -}; - -const defaultNodeAttributes = { - // XXX: How to set the font size? - fontsize: "20", - shape: "box", - width: 0, - height: 0, -}; - -const defaultEdgeAttributes = { - fontsize: "20", - sep: "5", -}; diff --git a/packages/frontend/src/stdlib/analyses/stock_flow_diagram.tsx b/packages/frontend/src/stdlib/analyses/stock_flow_diagram.tsx index 6851d2e4e..5a6cce186 100644 --- a/packages/frontend/src/stdlib/analyses/stock_flow_diagram.tsx +++ b/packages/frontend/src/stdlib/analyses/stock_flow_diagram.tsx @@ -17,12 +17,8 @@ import { loadViz, vizLayoutGraph, } from "../../visualization"; -import { - type GraphvizAttributes, - type ModelGraphContent, - graphvizEngine, - modelToGraphviz, -} from "./model_graph"; +import { type GraphContent, type GraphvizAttributes, graphvizEngine } from "./graph"; +import { modelToGraphviz } from "./model_graph"; import baseStyles from "./base_styles.module.css"; @@ -31,7 +27,7 @@ export function configureStockFlowDiagram(options: { id: string; name: string; description?: string; -}): ModelAnalysisMeta { +}): ModelAnalysisMeta { const { id, name, description } = options; return { id, @@ -47,7 +43,7 @@ export function configureStockFlowDiagram(options: { /** Visualize a stock flow diagram. */ -export function StockFlowDiagram(props: ModelAnalysisProps) { +export function StockFlowDiagram(props: ModelAnalysisProps) { const [svgRef, setSvgRef] = createSignal(); return ( diff --git a/packages/frontend/src/stdlib/analyses/submodel_graphs.tsx b/packages/frontend/src/stdlib/analyses/submodel_graphs.tsx index 2d1cc4536..79aed9788 100644 --- a/packages/frontend/src/stdlib/analyses/submodel_graphs.tsx +++ b/packages/frontend/src/stdlib/analyses/submodel_graphs.tsx @@ -8,7 +8,8 @@ import type { ModelAnalysisProps } from "../../analysis"; import { IconButton } from "../../components"; import type { ModelJudgment } from "../../model"; import type { ModelAnalysisMeta, Theory } from "../../theory"; -import { type GraphvizAttributes, ModelGraphviz } from "./model_graph"; +import type { GraphvizAttributes } from "./graph"; +import { ModelGraphviz } from "./model_graph"; import baseStyles from "./base_styles.module.css"; import "./submodel_graphs.css"; diff --git a/packages/frontend/src/stdlib/theories.tsx b/packages/frontend/src/stdlib/theories.tsx index c60661345..7246ac88c 100644 --- a/packages/frontend/src/stdlib/theories.tsx +++ b/packages/frontend/src/stdlib/theories.tsx @@ -2,6 +2,7 @@ import * as catlog from "catlog-wasm"; import { Theory } from "../theory"; import { + configureDiagramGraph, configureLotkaVolterra, configureModelGraph, configureStockFlowDiagram, @@ -75,6 +76,13 @@ stdTheories.add( description: "Visualize the olog as a diagram", }), ], + diagramAnalyses: [ + configureDiagramGraph({ + id: "graph", + name: "Graph", + description: "Visualize the instance as a graph", + }), + ], }); }, ); @@ -178,6 +186,13 @@ stdTheories.add( description: "Visualize the schema as a diagram", }), ], + diagramAnalyses: [ + configureDiagramGraph({ + id: "graph", + name: "Graph", + description: "Visualize the instance as a graph", + }), + ], }); }, ); @@ -488,6 +503,13 @@ stdTheories.add( shortcut: ["A"], }, ], + diagramAnalyses: [ + configureDiagramGraph({ + id: "graph", + name: "Diagram", + description: "Visualize the equations as a diagram", + }), + ], }); }, ); diff --git a/packages/frontend/src/theory/types.ts b/packages/frontend/src/theory/types.ts index d5eafe64d..282dfab84 100644 --- a/packages/frontend/src/theory/types.ts +++ b/packages/frontend/src/theory/types.ts @@ -189,7 +189,7 @@ type HasMorTypeMeta = { }; /** Frontend metadata applicable to any type in a double theory. */ -type BaseTypeMeta = { +export type BaseTypeMeta = { /** Human-readable name of type. */ name: string;