tscircuit/ti

ti tscircuit library

Version
1.0.193
License
unset
Stars
0

system-block-ui/src/App.tsx

import type { AnyCircuitElement } from "circuit-json";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import {
  BlockPalette,
  SYSTEM_BLOCK_DRAG_MIME,
} from "./components/BlockPalette";
import {
  CircuitIcon,
  FitIcon,
  HammerIcon,
  PdfIcon,
  ResetIcon,
} from "./components/Icons";
import { OutputPanel } from "./components/OutputPanel";
import { SubcircuitPickerModal } from "./components/SubcircuitPickerModal";
import {
  createSystemBlockEditor,
  type SystemBlockEditorController,
  type SystemBlockGraphSnapshot,
  type SystemBlockInitialGraph,
} from "./editor";
import {
  type BlockInstance,
  createSystemBlockExamples,
  DEFAULT_SYSTEM_BLOCK_EXAMPLE_ID,
  generateSystemDesignArtifacts,
  getSubcircuitCatalog,
  resolveDesignConnections,
  type SubcircuitDefinition,
} from "./model";
import { downloadBlob } from "./rendering/download-blob";
import type { EvaluatedSchematicSheet } from "./rendering/evaluate-schematic";
import {
  GENERATED_SYSTEM_MAIN_FILE_NAME,
  getGeneratedSystemEvaluationFsMap,
} from "./rendering/generated-source-files";
import {
  SCHEMATIC_SVG_HEIGHT,
  SCHEMATIC_SVG_WIDTH,
} from "./rendering/schematic-page-size";
import { SchematicEvaluationCoordinator } from "./schematic-evaluation-coordinator";

interface Notice {
  message: string;
  tone: "default" | "error" | "success";
}

interface SchematicBuildResult {
  circuitJson: readonly AnyCircuitElement[];
  sheets: readonly EvaluatedSchematicSheet[];
}

const BUILD_TOAST_ID = "schematic-build";

const instanceBaseName = (componentName: string): string =>
  componentName
    .replace(/([a-z\d])([A-Z])/g, "$1_$2")
    .replace(/[^A-Za-z0-9_]+/g, "_")
    .toLowerCase();

const nextInstance = (
  definition: SubcircuitDefinition,
  blocks: readonly BlockInstance[],
): Pick<BlockInstance, "id" | "name"> => {
  const base = instanceBaseName(definition.componentName);
  let index = 1;
  let name = `${base}_${index}`;
  const used = new Set(blocks.flatMap((block) => [block.id, block.name ?? ""]));
  while (used.has(name)) {
    index += 1;
    name = `${base}_${index}`;
  }
  return { id: name, name };
};

const errorMessage = (error: unknown): string =>
  error instanceof Error ? error.message : String(error);

export function App() {
  const catalog = useMemo(() => getSubcircuitCatalog(), []);
  const examples = useMemo(() => createSystemBlockExamples(catalog), [catalog]);
  const starterExample = examples.find(
    (example) => example.id === DEFAULT_SYSTEM_BLOCK_EXAMPLE_ID,
  );
  if (!starterExample) throw new Error("Missing default system block example.");
  const starterDesign: SystemBlockInitialGraph = starterExample.graph;
  const canvasRef = useRef<HTMLDivElement>(null);
  const controllerRef = useRef<SystemBlockEditorController | undefined>(
    undefined,
  );
  const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined,
  );
  const evaluationCoordinatorRef = useRef<
    SchematicEvaluationCoordinator | undefined
  >(undefined);
  evaluationCoordinatorRef.current ??= new SchematicEvaluationCoordinator();
  const evaluatedCircuitJsonRef = useRef<
    readonly AnyCircuitElement[] | undefined
  >(undefined);
  const schematicSheetsRef = useRef<readonly EvaluatedSchematicSheet[]>([]);
  const schematicBuildPromiseRef = useRef<
    Promise<SchematicBuildResult | undefined> | undefined
  >(undefined);
  const designRevisionRef = useRef(0);

  const [snapshot, setSnapshot] = useState<SystemBlockGraphSnapshot>(() => ({
    blocks: [...starterDesign.blocks],
    connections: [...starterDesign.connections],
    resolvedConnections: [
      ...resolveDesignConnections(
        starterDesign.blocks,
        starterDesign.connections,
        catalog,
      ),
    ],
  }));
  const [notice, setNotice] = useState<Notice>();
  const [pickerBlockId, setPickerBlockId] = useState<string>();
  const [loadedExampleId, setLoadedExampleId] = useState(starterExample.id);
  const [isEditorReady, setIsEditorReady] = useState(false);
  const [isRendering, setIsRendering] = useState(false);
  const [schematicSheets, setSchematicSheets] = useState<
    readonly EvaluatedSchematicSheet[]
  >([]);
  const hasAutomaticPower = snapshot.connections.some(
    (connection) => connection.kind.toLowerCase() === "power",
  );
  const dataLinkCount = snapshot.connections.filter(
    (connection) => connection.kind.toLowerCase() === "data",
  ).length;
  const pickerBlock = pickerBlockId
    ? snapshot.blocks.find((block) => block.id === pickerBlockId)
    : undefined;
  const pickerDefinition = pickerBlock
    ? catalog.find((definition) => definition.id === pickerBlock.definitionId)
    : undefined;

  const notify = useCallback(
    (message: string, tone: Notice["tone"] = "default") => {
      if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
      setNotice({ message, tone });
      noticeTimerRef.current = setTimeout(() => setNotice(undefined), 3600);
    },
    [],
  );

  useEffect(
    () => () => {
      if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
    },
    [],
  );

  const generatedArtifacts = useMemo(
    () =>
      generateSystemDesignArtifacts({
        blocks: snapshot.blocks,
        connections: snapshot.connections,
        catalog,
        boardName: "ti_system_design",
      }),
    [catalog, snapshot.blocks, snapshot.connections],
  );

  const invalidateSchematic = useCallback(() => {
    designRevisionRef.current += 1;
    evaluationCoordinatorRef.current?.invalidateGraph();
    schematicBuildPromiseRef.current = undefined;
    evaluatedCircuitJsonRef.current = undefined;
    schematicSheetsRef.current = [];
    toast.dismiss(BUILD_TOAST_ID);
    setIsRendering(false);
    setSchematicSheets([]);
  }, []);

  useEffect(() => {
    const container = canvasRef.current;
    if (!container) return;

    let disposed = false;
    let controller: SystemBlockEditorController | undefined;

    void createSystemBlockEditor(container, {
      catalog,
      initialGraph: starterDesign,
      onBlockSelectionRequested: setPickerBlockId,
      onGraphChange: (nextSnapshot) => {
        if (disposed) return;
        invalidateSchematic();
        setSnapshot(nextSnapshot);
      },
      onConnectionRejected: ({ error }) => notify(error.message, "error"),
    })
      .then((created) => {
        if (disposed) {
          created.destroy();
          return;
        }
        controller = created;
        controllerRef.current = created;
        setIsEditorReady(true);
        requestAnimationFrame(() => void created.zoomToFit());
      })
      .catch((error: unknown) => {
        if (!disposed) {
          notify(
            `Could not initialize the editor: ${errorMessage(error)}`,
            "error",
          );
        }
      });

    return () => {
      disposed = true;
      if (controllerRef.current === controller)
        controllerRef.current = undefined;
      setIsEditorReady(false);
      evaluationCoordinatorRef.current?.invalidateGraph();
      schematicBuildPromiseRef.current = undefined;
      evaluatedCircuitJsonRef.current = undefined;
      schematicSheetsRef.current = [];
      toast.dismiss(BUILD_TOAST_ID);
      designRevisionRef.current += 1;
      controller?.destroy();
    };
  }, [catalog, invalidateSchematic, notify, starterDesign]);

  const insertDefinition = useCallback(
    async (
      definition: SubcircuitDefinition,
      clientPoint?: { x: number; y: number },
    ) => {
      const controller = controllerRef.current;
      const canvas = canvasRef.current;
      if (!controller || !canvas) return;
      if (definition.canInstantiate === false) {
        notify(
          definition.warning ?? "This block cannot be instantiated.",
          "error",
        );
        return;
      }

      const point =
        clientPoint ??
        (() => {
          const rect = canvas.getBoundingClientRect();
          return {
            x: rect.left + rect.width / 2,
            y: rect.top + rect.height / 2,
          };
        })();

      try {
        await controller.addBlockAtClientPoint(
          definition,
          point,
          nextInstance(definition, controller.getSnapshot().blocks),
        );
        if (definition.warning) notify(definition.warning);
      } catch (error) {
        notify(errorMessage(error), "error");
      }
    },
    [notify],
  );

  const buildSchematic = useCallback(async (): Promise<
    SchematicBuildResult | undefined
  > => {
    if (schematicBuildPromiseRef.current) {
      return schematicBuildPromiseRef.current;
    }

    if (window.location.protocol === "file:") {
      const message =
        'Schematic evaluation requires HTTP. In system-block-ui, run "bun run build && bun run preview", then open the shown local URL.';
      notify(message, "error");
      toast.error(message, { id: BUILD_TOAST_ID });
      return;
    }

    const coordinator = evaluationCoordinatorRef.current;
    if (!coordinator) return;
    const request = coordinator.startRequest();
    setIsRendering(true);
    toast.loading("Building project…", { id: BUILD_TOAST_ID });

    const buildPromise = (async () => {
      try {
        const [
          { evaluateGeneratedTsx },
          { createLocalTiPackageEvaluationFsMap },
        ] = await Promise.all([
          import("./rendering/evaluate-schematic"),
          import("./rendering/local-ti-package-files"),
        ]);
        const selectedDefinitions = snapshot.blocks.map((block) => {
          const definition = catalog.find(
            (candidate) => candidate.id === block.definitionId,
          );
          if (!definition) {
            throw new Error(
              `Cannot render unknown subcircuit ${block.definitionId}.`,
            );
          }
          return definition;
        });
        const rendered = await evaluateGeneratedTsx(generatedArtifacts.tsx, {
          mainComponentPath: GENERATED_SYSTEM_MAIN_FILE_NAME,
          fsMap: {
            ...getGeneratedSystemEvaluationFsMap(generatedArtifacts),
            ...createLocalTiPackageEvaluationFsMap(selectedDefinitions),
          },
          timeoutMs: 45_000,
          schematicOptions: {
            width: SCHEMATIC_SVG_WIDTH,
            height: SCHEMATIC_SVG_HEIGHT,
            includeVersion: true,
          },
        });
        if (!coordinator.isCurrent(request)) return;
        evaluatedCircuitJsonRef.current = rendered.circuitJson;
        schematicSheetsRef.current = rendered.sheets;
        setSchematicSheets(rendered.sheets);
        const message = `${rendered.sheets.length} schematic sheet${rendered.sheets.length === 1 ? "" : "s"} rendered with PCB components enabled; routing and DRC disabled.`;
        toast.success("Project built", { id: BUILD_TOAST_ID });
        notify(message, "success");
        return {
          circuitJson: rendered.circuitJson,
          sheets: rendered.sheets,
        };
      } catch (error) {
        if (!coordinator.isCurrent(request)) return;
        const message = errorMessage(error);
        toast.error(`Build failed: ${message}`, { id: BUILD_TOAST_ID });
        notify(message, "error");
      } finally {
        if (coordinator.isCurrent(request)) setIsRendering(false);
      }
    })();

    schematicBuildPromiseRef.current = buildPromise;
    try {
      return await buildPromise;
    } finally {
      if (schematicBuildPromiseRef.current === buildPromise) {
        schematicBuildPromiseRef.current = undefined;
      }
    }
  }, [catalog, generatedArtifacts, notify, snapshot.blocks]);

  const getBuiltSchematic = useCallback(async () => {
    const circuitJson = evaluatedCircuitJsonRef.current;
    const sheets = schematicSheetsRef.current;
    if (circuitJson && sheets.length > 0) return { circuitJson, sheets };
    return buildSchematic();
  }, [buildSchematic]);

  const replaceSelectedBlock = useCallback(
    (definition: SubcircuitDefinition) => {
      const controller = controllerRef.current;
      if (!controller || !pickerBlockId) return;

      try {
        const result = controller.replaceBlockDefinition(
          pickerBlockId,
          definition,
        );
        setPickerBlockId(undefined);
        const removedCount = result.removedConnections.length;
        notify(
          removedCount > 0
            ? `Selected ${definition.title}; removed ${removedCount} incompatible connection${removedCount === 1 ? "" : "s"}.`
            : `Selected ${definition.title}; compatible connections were preserved.`,
          removedCount > 0 ? "default" : "success",
        );
      } catch (error) {
        notify(errorMessage(error), "error");
      }
    },
    [notify, pickerBlockId],
  );

  const resetDesign = useCallback(async () => {
    const example =
      examples.find((candidate) => candidate.id === loadedExampleId) ??
      starterExample;
    try {
      setPickerBlockId(undefined);
      await controllerRef.current?.loadInitialGraph(example.graph);
      await controllerRef.current?.zoomToFit();
      notify(`Restored the ${example.title} example.`, "success");
    } catch (error) {
      notify(errorMessage(error), "error");
    }
  }, [examples, loadedExampleId, notify, starterExample]);

  const loadExample = useCallback(
    async (exampleId: string) => {
      const example = examples.find((candidate) => candidate.id === exampleId);
      const controller = controllerRef.current;
      if (!example || !controller) return;

      try {
        setPickerBlockId(undefined);
        await controller.loadInitialGraph(example.graph);
        setLoadedExampleId(example.id);
        await controller.zoomToFit();
        notify(
          `Loaded ${example.title} from ${example.sourcePath}.`,
          "success",
        );
      } catch (error) {
        notify(`Could not load example: ${errorMessage(error)}`, "error");
      }
    },
    [examples, notify],
  );

  const downloadPdf = useCallback(async () => {
    const built = await getBuiltSchematic();
    if (!built) return;
    const { circuitJson, sheets } = built;
    try {
      const { createSchematicPdfBlob } = await import("./rendering/export-pdf");
      const blob = await createSchematicPdfBlob(sheets, {
        title: "TI System Block Schematic",
      });
      if (evaluatedCircuitJsonRef.current !== circuitJson) return;
      downloadBlob(blob, "GeneratedSystem.schematic.pdf");
      notify(`${sheets.length}-page schematic PDF downloaded.`, "success");
    } catch (error) {
      if (evaluatedCircuitJsonRef.current !== circuitJson) return;
      notify(errorMessage(error), "error");
    }
  }, [getBuiltSchematic, notify]);

  const downloadCircuitJson = useCallback(async () => {
    const built = await getBuiltSchematic();
    if (!built) return;
    const { circuitJson } = built;
    try {
      const { createCircuitJsonDownloadBlob, getCircuitJsonDownloadFileName } =
        await import("./rendering/export-circuit-json");
      if (evaluatedCircuitJsonRef.current !== circuitJson) return;
      const options = { projectName: "GeneratedSystem" } as const;
      const blob = createCircuitJsonDownloadBlob(circuitJson);
      downloadBlob(blob, getCircuitJsonDownloadFileName(options));
      notify("Circuit JSON downloaded.", "success");
    } catch (error) {
      if (evaluatedCircuitJsonRef.current !== circuitJson) return;
      notify(`Could not export Circuit JSON: ${errorMessage(error)}`, "error");
    }
  }, [getBuiltSchematic, notify]);

  const downloadTscircuitTsxZip = useCallback(async () => {
    const artifacts = generatedArtifacts;
    const designRevision = designRevisionRef.current;
    try {
      const { createTscircuitTsxZipBlob, getTscircuitTsxZipFileName } =
        await import("./rendering/export-tscircuit-tsx");
      if (designRevisionRef.current !== designRevision) return;
      const options = { projectName: "GeneratedSystem" } as const;
      const blob = createTscircuitTsxZipBlob(artifacts);
      if (designRevisionRef.current !== designRevision) return;
      downloadBlob(blob, getTscircuitTsxZipFileName(options));
      notify("tscircuit TSX ZIP downloaded.", "success");
    } catch (error) {
      if (designRevisionRef.current !== designRevision) return;
      notify(
        `Could not export the tscircuit TSX ZIP: ${errorMessage(error)}`,
        "error",
      );
    }
  }, [generatedArtifacts, notify]);

  const downloadKicadProject = useCallback(async () => {
    const built = await getBuiltSchematic();
    if (!built) return;
    const { circuitJson } = built;
    try {
      const { createKicadProjectZipBlob, getKicadProjectZipFileName } =
        await import("./rendering/export-kicad-project");
      const options = { projectName: "GeneratedSystem" } as const;
      const blob = await createKicadProjectZipBlob(circuitJson, options);
      if (evaluatedCircuitJsonRef.current !== circuitJson) return;
      downloadBlob(blob, getKicadProjectZipFileName(options));
      notify("KiCad project ZIP downloaded.", "success");
    } catch (error) {
      if (evaluatedCircuitJsonRef.current !== circuitJson) return;
      notify(
        `Could not export the KiCad project: ${errorMessage(error)}`,
        "error",
      );
    }
  }, [getBuiltSchematic, notify]);

  const downloadAltiumProject = useCallback(async () => {
    const built = await getBuiltSchematic();
    if (!built) return;
    const { circuitJson } = built;
    try {
      const { createAltiumProjectZipBlob, getAltiumProjectZipFileName } =
        await import("./rendering/export-altium-project");
      const options = { projectName: "GeneratedSystem" } as const;
      const blob = await createAltiumProjectZipBlob(circuitJson, options);
      if (evaluatedCircuitJsonRef.current !== circuitJson) return;
      downloadBlob(blob, getAltiumProjectZipFileName(options));
      notify("Altium project ZIP downloaded.", "success");
    } catch (error) {
      if (evaluatedCircuitJsonRef.current !== circuitJson) return;
      notify(
        `Could not export the Altium project: ${errorMessage(error)}`,
        "error",
      );
    }
  }, [getBuiltSchematic, notify]);

  return (
    <main className="app-shell">
      <header className="topbar">
        <div className="brand">
          <span className="brand-mark">
            <CircuitIcon />
          </span>
          <span className="brand-copy">
            <h1 className="brand-title">tscircuit TI Block Builder (Demo)</h1>
          </span>
        </div>

        <div className="topbar-actions">
          <span className="status-chip">
            <span className="status-dot" />
            Automatic resolver active
          </span>
          <button
            className="primary-button"
            disabled={isRendering}
            onClick={() => void buildSchematic()}
            type="button"
          >
            <span className="button-content">
              <HammerIcon />
              {isRendering ? "Building…" : "Build"}
            </span>
          </button>
        </div>
      </header>

      <div className="app-grid">
        <BlockPalette
          definitions={catalog}
          onInsert={(definitionId) => {
            const definition = catalog.find((item) => item.id === definitionId);
            if (definition) void insertDefinition(definition);
          }}
        >
          <label className="example-loader">
            <span className="visually-hidden">Load example diagram</span>
            <select
              aria-label="Load example diagram"
              className="example-select"
              defaultValue=""
              disabled={!isEditorReady}
              onChange={(event) => {
                const exampleId = event.currentTarget.value;
                event.currentTarget.value = "";
                void loadExample(exampleId);
              }}
            >
              <option disabled value="">
                Load Example
              </option>
              {examples.map((example) => (
                <option key={example.id} value={example.id}>
                  {example.title}
                </option>
              ))}
            </select>
          </label>
        </BlockPalette>

        <section
          className="canvas-panel"
          aria-label="System block canvas"
          onDragOver={(event) => {
            if (event.dataTransfer.types.includes(SYSTEM_BLOCK_DRAG_MIME)) {
              event.preventDefault();
              event.dataTransfer.dropEffect = "copy";
            }
          }}
          onDrop={(event) => {
            const definitionId = event.dataTransfer.getData(
              SYSTEM_BLOCK_DRAG_MIME,
            );
            if (!definitionId) return;
            event.preventDefault();
            const definition = catalog.find((item) => item.id === definitionId);
            if (definition) {
              void insertDefinition(definition, {
                x: event.clientX,
                y: event.clientY,
              });
            }
          }}
        >
          <div className="flow-canvas" ref={canvasRef} />
          <div className="canvas-toolbar">
            <span className="toolbar-label">
              {snapshot.blocks.length}{" "}
              {snapshot.blocks.length === 1 ? "block" : "blocks"}
              {hasAutomaticPower ? " · automatic power" : ""} · {dataLinkCount}{" "}
              {dataLinkCount === 1 ? "data link" : "data links"}
            </span>
            <span className="toolbar-separator" />
            <button
              aria-label="Fit graph to canvas"
              className="icon-button"
              onClick={() => void controllerRef.current?.zoomToFit()}
              title="Fit graph"
              type="button"
            >
              <FitIcon />
            </button>
            <button
              aria-label="Reset loaded example"
              className="icon-button"
              onClick={() => void resetDesign()}
              title="Reset loaded example"
              type="button"
            >
              <ResetIcon />
            </button>
          </div>
          <div className="drop-hint">
            <PdfIcon height={14} width={14} />
            Use the gear to change a part · drag to connect sockets
          </div>

          {notice && (
            <div className="notice" data-tone={notice.tone} role="status">
              {notice.message}
            </div>
          )}
        </section>

        <OutputPanel
          hasSchematic={schematicSheets.length > 0}
          onDownloadAltiumProject={downloadAltiumProject}
          onDownloadCircuitJson={downloadCircuitJson}
          onDownloadKicadProject={downloadKicadProject}
          onDownloadSchematicPdf={downloadPdf}
          onDownloadTscircuitTsxZip={downloadTscircuitTsxZip}
          schematicSheetCount={schematicSheets.length}
          tsx={generatedArtifacts.tsx}
        />
      </div>

      {pickerBlock && pickerDefinition && (
        <SubcircuitPickerModal
          currentDefinition={pickerDefinition}
          definitions={catalog}
          onClose={() => setPickerBlockId(undefined)}
          onSelect={replaceSelectedBlock}
        />
      )}
    </main>
  );
}