Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/frontend/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ let
];
};
# See README.md
hash = "sha256-ARVyVYi011iyTK1sLYc6RLNuwK2I3axKoQG8+7JOrQY=";
hash = "sha256-rG23XhnuiIijV3D+qtP5sbHTO1xe5cKt1rCqsvCsMSI=";
};
};

Expand Down
2 changes: 2 additions & 0 deletions packages/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"fast-equals": "^5.2.2",
"fast-json-patch": "^3.1.1",
"firebase": "^10.14.0",
"html-escaper": "^3.0.3",
"js-file-download": "^0.4.12",
"katex": "^0.16.22",
"lucide-solid": "^0.471.0",
Expand All @@ -71,6 +72,7 @@
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@mdx-js/mdx": "^2.3.0",
"@types/html-escaper": "^3.0.4",
"@types/mdx": "^2.0.13",
"@types/node": "^24.0.0",
"rehype-katex": "^7.0.1",
Expand Down
16 changes: 16 additions & 0 deletions packages/frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/frontend/src/help/analysis/schema-erd.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#### User inputs

<dl>
<dt>Direction: `Vertical | Horizontal`</dt>
<dd>Switches between orienting the graph from top to bottom and from left to right</dd>
</dl>
1 change: 1 addition & 0 deletions packages/frontend/src/help/logics/simple-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,4 @@ In other words, the set corresponding to an attribute type starts as empty and n
### Analyses

<HelpAnalysisById theory={props.theory} analysisId="diagram"/>
<HelpAnalysisById theory={props.theory} analysisId="erd"/>
9 changes: 9 additions & 0 deletions packages/frontend/src/stdlib/analyses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { MorType, ObType } from "catlog-wasm";
import type { DiagramAnalysisMeta, ModelAnalysisMeta } from "../theory";
import * as GraphLayoutConfig from "../visualization/graph_layout_config";
import type * as Checkers from "./analyses/checker_types";
import { defaultSchemaERDConfig, type SchemaERDConfig } from "./analyses/schema_erd_config";
import type * as Simulators from "./analyses/simulator_types";

type AnalysisOptions = {
Expand Down Expand Up @@ -183,6 +184,14 @@ export const modelGraph = (

const ModelGraph = lazy(() => import("./analyses/model_graph"));

export const schemaERD = (options: AnalysisOptions): ModelAnalysisMeta<SchemaERDConfig> => ({
...options,
component: (props) => <SchemaERD {...props} />,
initialContent: defaultSchemaERDConfig,
});

const SchemaERD = lazy(() => import("./analyses/schema_erd"));

export function motifFinding(
options: AnalysisOptions & {
findMotifs: Checkers.MotifFinder;
Expand Down
268 changes: 268 additions & 0 deletions packages/frontend/src/stdlib/analyses/schema_erd.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import type * as Viz from "@viz-js/viz";
import { escape as escapeHtml } from "html-escaper";
import download from "js-file-download";
import Download from "lucide-solid/icons/download";
import { createResource, Show } from "solid-js";

import { Foldable, FormGroup, IconButton, SelectField } from "catcolab-ui-components";
import type { DblModel, MorType, ObType } from "catlog-wasm";
import type { ModelAnalysisProps } from "../../analysis";
import { loadViz } from "../../visualization";
import { Direction, type SchemaERDConfig } from "./schema_erd_config";

/** Visualize a schema as an Entity-Relationship Diagram.

This visualization is specifically designed for schemas (models of the simple-schema
theory) and displays entities as tables with their attributes listed inside.
*/
export default function SchemaERD(props: ModelAnalysisProps<SchemaERDConfig>) {
const graph = () => {
const model = props.liveModel.elaboratedModel();
if (model) {
return schemaToERD(model);
}
};

const [vizResource] = createResource(loadViz);

const svgString = () => {
const viz = vizResource();
const g = graph();
if (!viz || !g) {
return undefined;
}

const direction = props.content.direction ?? Direction.Vertical;
return viz.renderString(g, {
format: "svg",
graphAttributes: {
rankdir: direction === Direction.Horizontal ? "LR" : "TB",
},
});
};

const schemaName = () => props.liveModel.liveDoc.doc.name || "Untitled";
const header = () => (
<>
<IconButton
onClick={() => {
const svg = svgString();
if (svg) {
download(svg, `${schemaName()} - ERD.svg`, "image/svg+xml");
}
}}
disabled={!svgString()}
tooltip={`Export the entity-relationship diagram as SVG`}
>
<Download size={16} />
</IconButton>
</>
);

return (
<div class="graph-visualization-container">
<Foldable title="Entity-relationship diagram" header={header()}>
<FormGroup compact>
<SelectField
label="Direction"
value={props.content.direction ?? Direction.Vertical}
onChange={(evt) => {
props.changeContent((content) => {
content.direction = evt.currentTarget.value as Direction;
});
}}
>
<option value={Direction.Horizontal}>{"Horizontal"}</option>
<option value={Direction.Vertical}>{"Vertical"}</option>
</SelectField>
</FormGroup>
</Foldable>
<div class="graph-visualization">
<Show when={svgString()}>{(svg) => <div innerHTML={svg()} />}</Show>
</div>
</div>
);
}

/** Convert a schema model into an ERD-style Graphviz graph using HTML-like labels. */
export function schemaToERD(model: DblModel): Viz.Graph {
const entityType: ObType = { tag: "Basic", content: "Entity" };
const attrType: MorType = { tag: "Basic", content: "Attr" };

const entities = model.obGeneratorsWithType(entityType);
const nodes: Required<Viz.Graph>["nodes"] = [];

// Collect all mappings to know which entities they point to
const mappingsByEntity = new Map<
string,
Array<{ id: string; name: string; targetEntity: string }>
>();
const mappingType: MorType = { tag: "Hom", content: entityType };
for (const morId of model.morGeneratorsWithType(mappingType)) {
const mor = model.morPresentation(morId);
if (
mor &&
mor.dom.tag === "Basic" &&
mor.cod.tag === "Basic" &&
entities.includes(mor.dom.content) &&
entities.includes(mor.cod.content)
) {
const mappingName = mor.label?.join(".") ?? "";
const sourceEntity = mor.dom.content;
const targetEntity = mor.cod.content;

if (!mappingsByEntity.has(sourceEntity)) {
mappingsByEntity.set(sourceEntity, []);
}
mappingsByEntity.get(sourceEntity)?.push({
id: morId,
name: mappingName,
targetEntity,
});
}
}

// Build entity tables
for (const entityId of entities) {
const entity = model.obPresentation(entityId);

const attributes: Array<{ name: string; type: string }> = [];
for (const morId of model.morGeneratorsWithType(attrType)) {
const mor = model.morPresentation(morId);
if (mor && mor.dom.tag === "Basic" && mor.cod.tag === "Basic") {
const domainMatch = mor.dom.content === entityId;

if (domainMatch) {
const attrName = mor.label?.join(".") ?? "";
const attrTypeId = mor.cod.content;
const attrTypeOb = model.obPresentation(attrTypeId);
const attrTypeName = attrTypeOb?.label?.join(".") ?? "";
attributes.push({ name: attrName, type: attrTypeName });
}
}
}
const mappings = mappingsByEntity.get(entityId) ?? [];

const entityLabel = escapeHtml(entity.label?.join(".") ?? "");
const paddingLeft = computePaddingCenteredLeft(entityLabel);
const paddingRight = computePaddingCenteredRight(entityLabel);
// We cannot use our global CSS custom properties for this color
const bgColor = "#a6f2f2";
let tableRows = `
<tr>
<td port="${entityId}" bgcolor="${bgColor}" align="center" colspan="2"><b><font point-size="12">${paddingLeft}${entityLabel}${paddingRight}</font></b></td>
</tr>
`;

if (attributes.length === 0 && mappings.length === 0) {
tableRows += `
<tr>
<td align="left" colspan="2"><font point-size="12"><i>(no attributes)</i>&#160;&#160;</font></td>
</tr>
`;
} else {
for (const attr of attributes) {
const name = escapeHtml(attr.name);
const label = escapeHtml(attr.type);
const paddingName = computePadding(name);
const paddingLabel = computePadding(label);
tableRows += `
<tr>
<td align="left"><font point-size="12">${name}${paddingName}</font></td>
<td align="left"><font point-size="12">${label}${paddingLabel}</font></td>
</tr>
`;
}
for (const mapping of mappings) {
let label =
model.obPresentation(mapping.targetEntity).label?.join(".") ||
mapping.targetEntity;
label = escapeHtml(label);
label = `→ ${label}`;
const paddingLabel = computePadding(label);
const name = escapeHtml(mapping.name);
const paddingName = computePadding(name);
tableRows += `
<tr>
<td align="left" port="${escapeHtml(mapping.id)}"><font point-size="12">${name}${paddingName}</font></td>
<td align="left"><font point-size="12">${label}${paddingLabel}</font></td>
</tr>
`;
}
}

nodes.push({
name: entityId,
attributes: {
id: entityId,
label: {
html: `
<table border="0" cellborder="1" cellspacing="0" cellpadding="4">
${tableRows}
</table>
`,
},
},
});
}

// Add edges for entity-to-entity mappings (foreign keys) using ports
const edges: Required<Viz.Graph>["edges"] = [];
for (const [sourceEntity, mappings] of mappingsByEntity) {
for (const mapping of mappings) {
edges.push({
tail: sourceEntity,
head: mapping.targetEntity,
attributes: {
id: mapping.id,
tailport: `${mapping.id}:w`,
arrowhead: "none",
arrowtail: "crow",
dir: "both",
},
});
}
}

return {
directed: true,
nodes,
edges,
graphAttributes: {
rankdir: "TB",
bgcolor: "transparent",
},
nodeAttributes: {
fontname: "sans-serif",
fontsize: "10",
shape: "plaintext",
},
edgeAttributes: {
fontname: "sans-serif",
fontsize: "9",
color: "#666666",
},
};
}

// These padding functions are a hack to get the Graphviz HTML-like table layout to contain our text properly
function computePadding(text: string): string {
const width = text.length;
const padding = Math.ceil(width / 6 + Math.sqrt(width));

return Array(padding).fill("&#160;").join("");
}

function computePaddingCenteredRight(text: string): string {
const width = text.length;
const padding = Math.ceil(width / 3);

return Array(padding).fill("&#160;").join("");
}

function computePaddingCenteredLeft(text: string): string {
const width = text.length;
const padding = Math.ceil(width / 3 + Math.sqrt(width));

return Array(padding).fill("&#160;").join("");
}
Loading