init projects
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
# mkdocs-schematic-footprint-viewer
|
||||||
|
|
||||||
|
A Zensical/Python-Markdown extension that renders an interactive schematic and footprint viewer in Markdown.
|
||||||
|
|
||||||
|
Use this shortcode anywhere in Markdown and pass the JSON file at the call site:
|
||||||
|
|
||||||
|
```md
|
||||||
|
{% schematic-footprint json="files/VL53L0X/VL53L0X_merged.json" %}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install mkdocs-schematic-footprint-viewer
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[test]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zensical Configuration
|
||||||
|
|
||||||
|
Zensical's public plugin/module API is still evolving, so this package integrates through the stable Python-Markdown extension path that Zensical supports.
|
||||||
|
|
||||||
|
Add the extension to `zensical.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.markdown_extensions.schematic_footprint_viewer]
|
||||||
|
default_json = "merged.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use the shortcode in Markdown:
|
||||||
|
|
||||||
|
```md
|
||||||
|
{% schematic-footprint json="files/VL53L0X/VL53L0X_merged.json" %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run Zensical:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zensical serve
|
||||||
|
```
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zensical build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shortcode Options
|
||||||
|
|
||||||
|
The JSON path belongs in the Markdown shortcode:
|
||||||
|
|
||||||
|
```md
|
||||||
|
{% schematic-footprint json="merged.json" %}
|
||||||
|
```
|
||||||
|
|
||||||
|
`src` is also accepted as an alias:
|
||||||
|
|
||||||
|
```md
|
||||||
|
{% schematic-footprint src="files/MP1584/MP1584_merged.json" %}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can override the box dimensions per shortcode:
|
||||||
|
|
||||||
|
```md
|
||||||
|
{% schematic-footprint json="merged.json" width="24rem" height="20rem" %}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zensical Configuration With Options
|
||||||
|
|
||||||
|
These settings define defaults for every viewer:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.markdown_extensions.schematic_footprint_viewer]
|
||||||
|
default_json = "merged.json"
|
||||||
|
width = "15rem"
|
||||||
|
height = "13rem"
|
||||||
|
konva_url = "https://unpkg.com/konva@9/konva.min.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
If you keep `konva.min.js` locally in your built site, point `konva_url` to that file:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.markdown_extensions.schematic_footprint_viewer]
|
||||||
|
konva_url = "konva.min.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
If your `zensical.toml` already has plugins, keep them separate. For example:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
plugins = [
|
||||||
|
{ search = {} },
|
||||||
|
{ badges = {} },
|
||||||
|
{ document-dates = { position = "top", type = "timeago", exclude = [
|
||||||
|
"index.md",
|
||||||
|
"blog/*",
|
||||||
|
] } },
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.markdown_extensions.schematic_footprint_viewer]
|
||||||
|
default_json = "merged.json"
|
||||||
|
width = "15rem"
|
||||||
|
height = "13rem"
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not add this package to `plugins` in `zensical.toml`; use `markdown_extensions` as shown above.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
The shortcode is replaced during Markdown rendering with a scoped viewer containing two Konva canvases: one for PCB/footprint data and one for schematic data. The viewer fetches the JSON path passed in Markdown.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "mkdocs-schematic-footprint-viewer"
|
||||||
|
version = "0.1.1"
|
||||||
|
description = "Zensical/Python-Markdown extension that renders schematic footprint viewer shortcodes."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
authors = [
|
||||||
|
{ name = "Nearology" }
|
||||||
|
]
|
||||||
|
keywords = ["mkdocs", "zensical", "schematic", "footprint", "viewer"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Topic :: Documentation",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"Markdown>=3.3.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = [
|
||||||
|
"pytest>=7.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/schematic_footprint_viewer"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Zensical/Python-Markdown extension for schematic footprint viewer shortcodes."""
|
||||||
|
|
||||||
|
from .extension import (
|
||||||
|
SchematicFootprintViewerExtension,
|
||||||
|
ViewerOptions,
|
||||||
|
makeExtension,
|
||||||
|
render_viewer,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SchematicFootprintViewerExtension",
|
||||||
|
"ViewerOptions",
|
||||||
|
"makeExtension",
|
||||||
|
"render_viewer",
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,801 @@
|
|||||||
|
"""Python-Markdown extension for schematic footprint viewer shortcodes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
from markdown import Markdown
|
||||||
|
from markdown.extensions import Extension
|
||||||
|
from markdown.preprocessors import Preprocessor
|
||||||
|
|
||||||
|
|
||||||
|
SHORTCODE_RE = re.compile(r"{%\s*schematic-footprint(?P<attrs>.*?)\s*%}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ViewerOptions:
|
||||||
|
"""Runtime settings for the generated viewer."""
|
||||||
|
|
||||||
|
json_path: str
|
||||||
|
width: str = "15rem"
|
||||||
|
height: str = "13rem"
|
||||||
|
konva_url: str = "https://unpkg.com/konva@9/konva.min.js"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_shortcode_attrs(raw_attrs: str, default_json: str) -> dict[str, str]:
|
||||||
|
attrs: dict[str, str] = {}
|
||||||
|
for token in shlex.split(raw_attrs.strip()):
|
||||||
|
if "=" in token:
|
||||||
|
key, value = token.split("=", 1)
|
||||||
|
attrs[key.strip()] = value.strip()
|
||||||
|
elif token:
|
||||||
|
attrs.setdefault("json", token)
|
||||||
|
|
||||||
|
if "src" in attrs and "json" not in attrs:
|
||||||
|
attrs["json"] = attrs["src"]
|
||||||
|
attrs.setdefault("json", default_json)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
def _script_data(value: str) -> str:
|
||||||
|
return escape(value, quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def render_viewer(options: ViewerOptions) -> str:
|
||||||
|
"""Render the schematic/footprint viewer shell."""
|
||||||
|
|
||||||
|
json_path = _script_data(options.json_path)
|
||||||
|
width = _script_data(options.width)
|
||||||
|
height = _script_data(options.height)
|
||||||
|
konva_url = _script_data(options.konva_url)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="schematic-footprint-viewer" data-sfv-json="{json_path}" data-sfv-konva="{konva_url}" style="--sfv-box-w:{width};--sfv-box-h:{height};">
|
||||||
|
<div class="sfv-viewer-box" data-sfv-pcb-box>
|
||||||
|
<div class="sfv-box-header">
|
||||||
|
<span class="sfv-box-label sfv-pcb-label">PCB</span>
|
||||||
|
<div class="sfv-box-controls">
|
||||||
|
<button class="sfv-ctrl-btn" type="button" data-sfv-fit-pcb>Fit</button>
|
||||||
|
<span class="sfv-zoom-pill" data-sfv-pcb-zoom>100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sfv-box-canvas-wrap" style="background:#000000">
|
||||||
|
<div data-sfv-pcb-canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sfv-viewer-box" data-sfv-sch-box>
|
||||||
|
<div class="sfv-box-header">
|
||||||
|
<span class="sfv-box-label sfv-sch-label">SCHEMATIC</span>
|
||||||
|
<div class="sfv-box-controls">
|
||||||
|
<button class="sfv-ctrl-btn" type="button" data-sfv-fit-sch>Fit</button>
|
||||||
|
<span class="sfv-zoom-pill" data-sfv-sch-zoom>100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sfv-box-canvas-wrap" style="background:#ffffff">
|
||||||
|
<div data-sfv-sch-canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sfv-loading"><span>PARSING...</span></div>
|
||||||
|
<div class="sfv-tooltip"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {{
|
||||||
|
const root = document.currentScript.previousElementSibling;
|
||||||
|
const konvaUrl = root.dataset.sfvKonva;
|
||||||
|
|
||||||
|
function boot() {{
|
||||||
|
if (window.SchematicFootprintViewer) {{
|
||||||
|
window.SchematicFootprintViewer.mount(root);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
window.__sfvPending = window.__sfvPending || [];
|
||||||
|
window.__sfvPending.push(root);
|
||||||
|
if (window.__sfvLoading) return;
|
||||||
|
window.__sfvLoading = true;
|
||||||
|
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = `{VIEWER_CSS}`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = konvaUrl;
|
||||||
|
script.onload = function() {{
|
||||||
|
installSchematicFootprintViewer();
|
||||||
|
window.__sfvPending.splice(0).forEach(function(el) {{
|
||||||
|
window.SchematicFootprintViewer.mount(el);
|
||||||
|
}});
|
||||||
|
}};
|
||||||
|
script.onerror = function() {{
|
||||||
|
root.querySelector(".sfv-loading span").textContent = "KONVA LOAD FAILED";
|
||||||
|
root.querySelector(".sfv-loading").classList.add("active");
|
||||||
|
}};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}}
|
||||||
|
|
||||||
|
function installSchematicFootprintViewer() {{
|
||||||
|
if (window.SchematicFootprintViewer) return;
|
||||||
|
window.SchematicFootprintViewer = {{ mount }};
|
||||||
|
|
||||||
|
function mount(root) {{
|
||||||
|
const $ = (selector) => root.querySelector(selector);
|
||||||
|
const jsonPath = root.dataset.sfvJson;
|
||||||
|
const pcbCanvas = $("[data-sfv-pcb-canvas]");
|
||||||
|
const schCanvas = $("[data-sfv-sch-canvas]");
|
||||||
|
const loading = $(".sfv-loading");
|
||||||
|
const tip = $(".sfv-tooltip");
|
||||||
|
|
||||||
|
let pcbStage = null;
|
||||||
|
let schStage = null;
|
||||||
|
let pcbLayers = {{}};
|
||||||
|
let schLayers = {{}};
|
||||||
|
let pcbTransform = {{ x: 0, y: 0, scale: 1 }};
|
||||||
|
let schTransform = {{ x: 0, y: 0, scale: 1 }};
|
||||||
|
let pcbBounds = null;
|
||||||
|
let schBounds = null;
|
||||||
|
const pinRegistry = {{}};
|
||||||
|
|
||||||
|
const ZOOM_MIN = 1;
|
||||||
|
const ZOOM_MAX = 5;
|
||||||
|
const HL_COLOR = "#00e5ff";
|
||||||
|
const HL_SHADOW = "#00e5ff";
|
||||||
|
const PCB_BASE = "#ff0000";
|
||||||
|
const SCH_BASE = "#ff0000";
|
||||||
|
|
||||||
|
function showTip(text, evt) {{
|
||||||
|
tip.textContent = text;
|
||||||
|
tip.style.left = evt.clientX + 14 + "px";
|
||||||
|
tip.style.top = evt.clientY + 14 + "px";
|
||||||
|
tip.style.display = "block";
|
||||||
|
}}
|
||||||
|
function hideTip() {{
|
||||||
|
tip.style.display = "none";
|
||||||
|
}}
|
||||||
|
root.addEventListener("mousemove", function(e) {{
|
||||||
|
if (tip.style.display === "block") {{
|
||||||
|
tip.style.left = e.clientX + 14 + "px";
|
||||||
|
tip.style.top = e.clientY + 14 + "px";
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
function highlightPin(dsg, tooltipText, evt) {{
|
||||||
|
const entry = pinRegistry[dsg];
|
||||||
|
if (!entry) return;
|
||||||
|
if (entry.pcb) {{
|
||||||
|
entry.pcb.stroke(HL_COLOR);
|
||||||
|
entry.pcb.fill(HL_COLOR + "50");
|
||||||
|
entry.pcb.shadowColor(HL_SHADOW);
|
||||||
|
entry.pcb.shadowBlur(12);
|
||||||
|
entry.pcb.shadowOpacity(0.9);
|
||||||
|
pcbLayers.pin.draw();
|
||||||
|
pcbLayers.pad.draw();
|
||||||
|
}}
|
||||||
|
if (entry.sch) {{
|
||||||
|
entry.sch.getChildren().forEach(function(child) {{
|
||||||
|
if (child.className === "Rect") {{
|
||||||
|
child.fill(HL_COLOR);
|
||||||
|
child.shadowColor(HL_SHADOW);
|
||||||
|
child.shadowBlur(10);
|
||||||
|
child.shadowOpacity(0.9);
|
||||||
|
}}
|
||||||
|
if (child.className === "Line") {{
|
||||||
|
child.stroke(HL_COLOR);
|
||||||
|
child.shadowColor(HL_SHADOW);
|
||||||
|
child.shadowBlur(8);
|
||||||
|
child.shadowOpacity(0.8);
|
||||||
|
}}
|
||||||
|
if (child.className === "Text") child.fill(HL_COLOR + "cc");
|
||||||
|
}});
|
||||||
|
schLayers.pin.draw();
|
||||||
|
}}
|
||||||
|
if (tooltipText && evt) showTip(tooltipText, evt);
|
||||||
|
}}
|
||||||
|
|
||||||
|
function unhighlightPin(dsg) {{
|
||||||
|
const entry = pinRegistry[dsg];
|
||||||
|
if (!entry) return;
|
||||||
|
if (entry.pcb) {{
|
||||||
|
entry.pcb.fill(PCB_BASE);
|
||||||
|
entry.pcb.stroke(PCB_BASE);
|
||||||
|
entry.pcb.shadowBlur(0);
|
||||||
|
entry.pcb.shadowOpacity(0);
|
||||||
|
pcbLayers.pin.draw();
|
||||||
|
pcbLayers.pad.draw();
|
||||||
|
}}
|
||||||
|
if (entry.sch) {{
|
||||||
|
entry.sch.getChildren().forEach(function(child) {{
|
||||||
|
if (child.className === "Rect") {{
|
||||||
|
child.fill(SCH_BASE);
|
||||||
|
child.shadowBlur(0);
|
||||||
|
child.shadowOpacity(0);
|
||||||
|
}}
|
||||||
|
if (child.className === "Line") {{
|
||||||
|
child.stroke(SCH_BASE);
|
||||||
|
child.shadowBlur(0);
|
||||||
|
child.shadowOpacity(0);
|
||||||
|
}}
|
||||||
|
if (child.className === "Text") child.fill("black");
|
||||||
|
}});
|
||||||
|
schLayers.pin.draw();
|
||||||
|
}}
|
||||||
|
hideTip();
|
||||||
|
}}
|
||||||
|
|
||||||
|
function clampTransform(transform, bounds, stageW, stageH) {{
|
||||||
|
if (!bounds) return;
|
||||||
|
const margin = 40;
|
||||||
|
const s = transform.scale;
|
||||||
|
const cx1 = transform.x + bounds.x * s;
|
||||||
|
const cy1 = transform.y + bounds.y * s;
|
||||||
|
const cx2 = cx1 + bounds.w * s;
|
||||||
|
const cy2 = cy1 + bounds.h * s;
|
||||||
|
if (cx2 < margin) transform.x += margin - cx2;
|
||||||
|
if (cx1 > stageW - margin) transform.x -= cx1 - (stageW - margin);
|
||||||
|
if (cy2 < margin) transform.y += margin - cy2;
|
||||||
|
if (cy1 > stageH - margin) transform.y -= cy1 - (stageH - margin);
|
||||||
|
}}
|
||||||
|
|
||||||
|
function applyPcbTransform() {{
|
||||||
|
Object.values(pcbLayers).forEach(function(layer) {{
|
||||||
|
layer.x(pcbTransform.x);
|
||||||
|
layer.y(pcbTransform.y);
|
||||||
|
layer.scaleX(pcbTransform.scale);
|
||||||
|
layer.scaleY(pcbTransform.scale);
|
||||||
|
layer.draw();
|
||||||
|
}});
|
||||||
|
$("[data-sfv-pcb-zoom]").textContent = Math.round(pcbTransform.scale * 100) + "%";
|
||||||
|
}}
|
||||||
|
|
||||||
|
function applySchTransform() {{
|
||||||
|
Object.values(schLayers).forEach(function(layer) {{
|
||||||
|
layer.x(schTransform.x);
|
||||||
|
layer.y(schTransform.y);
|
||||||
|
layer.scaleX(schTransform.scale);
|
||||||
|
layer.scaleY(schTransform.scale);
|
||||||
|
layer.draw();
|
||||||
|
}});
|
||||||
|
$("[data-sfv-sch-zoom]").textContent = Math.round(schTransform.scale * 100) + "%";
|
||||||
|
}}
|
||||||
|
|
||||||
|
$("[data-sfv-fit-pcb]").addEventListener("click", function() {{
|
||||||
|
pcbTransform = {{ x: 0, y: 0, scale: 1 }};
|
||||||
|
applyPcbTransform();
|
||||||
|
}});
|
||||||
|
$("[data-sfv-fit-sch]").addEventListener("click", function() {{
|
||||||
|
schTransform = {{ x: 0, y: 0, scale: 1 }};
|
||||||
|
applySchTransform();
|
||||||
|
}});
|
||||||
|
|
||||||
|
function getStageSizePx(el) {{
|
||||||
|
return {{ w: el.clientWidth || 480, h: el.clientHeight || 384 }};
|
||||||
|
}}
|
||||||
|
|
||||||
|
function initStages() {{
|
||||||
|
if (pcbStage) pcbStage.destroy();
|
||||||
|
if (schStage) schStage.destroy();
|
||||||
|
|
||||||
|
const pcbSz = getStageSizePx(pcbCanvas);
|
||||||
|
const schSz = getStageSizePx(schCanvas);
|
||||||
|
pcbStage = new Konva.Stage({{ container: pcbCanvas, width: pcbSz.w, height: pcbSz.h }});
|
||||||
|
schStage = new Konva.Stage({{ container: schCanvas, width: schSz.w, height: schSz.h }});
|
||||||
|
|
||||||
|
pcbLayers = {{
|
||||||
|
board: new Konva.Layer(),
|
||||||
|
track: new Konva.Layer(),
|
||||||
|
pad: new Konva.Layer(),
|
||||||
|
hole: new Konva.Layer(),
|
||||||
|
via: new Konva.Layer(),
|
||||||
|
pin: new Konva.Layer(),
|
||||||
|
comp: new Konva.Layer(),
|
||||||
|
}};
|
||||||
|
schLayers = {{
|
||||||
|
line: new Konva.Layer(),
|
||||||
|
poly: new Konva.Layer(),
|
||||||
|
arc: new Konva.Layer(),
|
||||||
|
comp: new Konva.Layer(),
|
||||||
|
pin: new Konva.Layer(),
|
||||||
|
label: new Konva.Layer(),
|
||||||
|
}};
|
||||||
|
Object.values(pcbLayers).forEach((layer) => pcbStage.add(layer));
|
||||||
|
Object.values(schLayers).forEach((layer) => schStage.add(layer));
|
||||||
|
setupInteraction(pcbStage, function() {{ return pcbTransform; }}, function(v) {{ pcbTransform = v; }}, function() {{ return pcbBounds; }}, applyPcbTransform);
|
||||||
|
setupInteraction(schStage, function() {{ return schTransform; }}, function(v) {{ schTransform = v; }}, function() {{ return schBounds; }}, applySchTransform);
|
||||||
|
setupResize();
|
||||||
|
}}
|
||||||
|
|
||||||
|
function setupInteraction(stage, getTransform, setTransform, getBounds, applyTransform) {{
|
||||||
|
stage.on("wheel", function(e) {{
|
||||||
|
e.evt.preventDefault();
|
||||||
|
const ptr = stage.getPointerPosition();
|
||||||
|
const transform = getTransform();
|
||||||
|
const old = transform.scale;
|
||||||
|
const mpt = {{ x: (ptr.x - transform.x) / old, y: (ptr.y - transform.y) / old }};
|
||||||
|
const dir = e.evt.deltaY > 0 ? -1 : 1;
|
||||||
|
const ns = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, old * (1 + dir * 0.12)));
|
||||||
|
const next = {{ scale: ns, x: ptr.x - mpt.x * ns, y: ptr.y - mpt.y * ns }};
|
||||||
|
clampTransform(next, getBounds(), stage.width(), stage.height());
|
||||||
|
setTransform(next);
|
||||||
|
applyTransform();
|
||||||
|
}});
|
||||||
|
|
||||||
|
let panStart = null;
|
||||||
|
stage.on("mousedown", function() {{
|
||||||
|
const ptr = stage.getPointerPosition();
|
||||||
|
const transform = getTransform();
|
||||||
|
panStart = {{ mx: ptr.x, my: ptr.y, tx: transform.x, ty: transform.y }};
|
||||||
|
stage.container().style.cursor = "grabbing";
|
||||||
|
}});
|
||||||
|
stage.on("mousemove", function() {{
|
||||||
|
if (!panStart) return;
|
||||||
|
const ptr = stage.getPointerPosition();
|
||||||
|
const transform = getTransform();
|
||||||
|
const next = {{ x: panStart.tx + (ptr.x - panStart.mx), y: panStart.ty + (ptr.y - panStart.my), scale: transform.scale }};
|
||||||
|
clampTransform(next, getBounds(), stage.width(), stage.height());
|
||||||
|
setTransform(next);
|
||||||
|
applyTransform();
|
||||||
|
}});
|
||||||
|
stage.on("mouseup", function() {{
|
||||||
|
panStart = null;
|
||||||
|
stage.container().style.cursor = "default";
|
||||||
|
}});
|
||||||
|
stage.container().addEventListener("mouseleave", function() {{
|
||||||
|
panStart = null;
|
||||||
|
stage.container().style.cursor = "default";
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
function setupResize() {{
|
||||||
|
const ro = new ResizeObserver(function() {{
|
||||||
|
if (pcbStage) {{
|
||||||
|
const s = getStageSizePx(pcbCanvas);
|
||||||
|
pcbStage.width(s.w);
|
||||||
|
pcbStage.height(s.h);
|
||||||
|
}}
|
||||||
|
if (schStage) {{
|
||||||
|
const s = getStageSizePx(schCanvas);
|
||||||
|
schStage.width(s.w);
|
||||||
|
schStage.height(s.h);
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
ro.observe(pcbCanvas);
|
||||||
|
ro.observe(schCanvas);
|
||||||
|
}}
|
||||||
|
|
||||||
|
function createHatchPattern(color, size, lineWidth) {{
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = size || 10;
|
||||||
|
canvas.height = size || 10;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = lineWidth || 1.5;
|
||||||
|
ctx.lineCap = "square";
|
||||||
|
[-canvas.width, 0, canvas.width].forEach(function(offset) {{
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(offset, canvas.height);
|
||||||
|
ctx.lineTo(offset + canvas.width, 0);
|
||||||
|
ctx.stroke();
|
||||||
|
}});
|
||||||
|
return canvas;
|
||||||
|
}}
|
||||||
|
|
||||||
|
function renderPCB(PCB) {{
|
||||||
|
Object.values(pcbLayers).forEach((layer) => layer.destroyChildren());
|
||||||
|
pcbTransform = {{ x: 0, y: 0, scale: 1 }};
|
||||||
|
|
||||||
|
const pads = PCB.pads || [];
|
||||||
|
const tracks = PCB.tracks || [];
|
||||||
|
const pins = PCB.pins || [];
|
||||||
|
const comps = PCB.components || [];
|
||||||
|
const holes = PCB.holes || [];
|
||||||
|
const vias = PCB.vias || [];
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
|
||||||
|
pads.forEach((p) => {{ minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }});
|
||||||
|
tracks.forEach((t) => {{ minX = Math.min(minX, t.x1, t.x2); maxX = Math.max(maxX, t.x1, t.x2); minY = Math.min(minY, t.y1, t.y2); maxY = Math.max(maxY, t.y1, t.y2); }});
|
||||||
|
if (!isFinite(minX)) {{ minX = 0; maxX = 100; minY = 0; maxY = 100; }}
|
||||||
|
|
||||||
|
const margin = 24;
|
||||||
|
const cW = pcbStage.width();
|
||||||
|
const cH = pcbStage.height();
|
||||||
|
const baseScale = Math.min((cW - margin * 2) / (maxX - minX || 1), (cH - margin * 2) / (maxY - minY || 1));
|
||||||
|
const drawnW = (maxX - minX) * baseScale;
|
||||||
|
const drawnH = (maxY - minY) * baseScale;
|
||||||
|
const offX = (cW - drawnW) / 2;
|
||||||
|
const offY = (cH - drawnH) / 2;
|
||||||
|
const tx = (x) => (x - minX) * baseScale + offX;
|
||||||
|
const ty = (y) => (maxY - y) * baseScale + offY;
|
||||||
|
pcbBounds = {{ x: offX, y: offY, w: drawnW, h: drawnH }};
|
||||||
|
|
||||||
|
let boardOutline = null;
|
||||||
|
if (tracks.length) {{
|
||||||
|
const groups = {{}};
|
||||||
|
tracks.forEach((t) => {{ (groups[t.width] = groups[t.width] || []).push(t); }});
|
||||||
|
let bestRatio = -Infinity;
|
||||||
|
Object.values(groups).forEach((group) => {{
|
||||||
|
let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
|
||||||
|
group.forEach((t) => {{ gMinX = Math.min(gMinX, t.x1, t.x2); gMaxX = Math.max(gMaxX, t.x1, t.x2); gMinY = Math.min(gMinY, t.y1, t.y2); gMaxY = Math.max(gMaxY, t.y1, t.y2); }});
|
||||||
|
const ratio = ((gMaxX - gMinX) * (gMaxY - gMinY)) / group.length;
|
||||||
|
if (ratio > bestRatio) {{ bestRatio = ratio; boardOutline = {{ minX: gMinX, minY: gMinY, maxX: gMaxX, maxY: gMaxY }}; }}
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
const hatchPattern = createHatchPattern("#a855f7", 3, 0.75);
|
||||||
|
if (boardOutline) {{
|
||||||
|
pcbLayers.board.add(new Konva.Rect({{ x: tx(boardOutline.minX), y: ty(boardOutline.maxY), width: tx(boardOutline.maxX) - tx(boardOutline.minX), height: ty(boardOutline.minY) - ty(boardOutline.maxY), fillPatternImage: hatchPattern, fillPatternRepeat: "repeat", listening: false }}));
|
||||||
|
}} else {{
|
||||||
|
pcbLayers.board.add(new Konva.Rect({{ x: offX, y: offY, width: drawnW, height: drawnH, fillPatternImage: hatchPattern, fillPatternRepeat: "repeat", listening: false }}));
|
||||||
|
}}
|
||||||
|
|
||||||
|
tracks.forEach((t) => pcbLayers.track.add(new Konva.Line({{ points: [tx(t.x1), ty(t.y1), tx(t.x2), ty(t.y2)], stroke: "Yellow", strokeWidth: Math.max(0.5, t.width * baseScale), lineCap: "round", listening: false }})));
|
||||||
|
holes.forEach((h) => pcbLayers.hole.add(new Konva.Circle({{ x: tx(h.x), y: ty(h.y), radius: Math.max(1, (h.d || 0.5) * baseScale * 0.5), fill: "#1a1a2e", stroke: "#546e7a", strokeWidth: 0.5, listening: false }})));
|
||||||
|
vias.forEach((v) => pcbLayers.via.add(new Konva.Circle({{ x: tx(v.x), y: ty(v.y), radius: Math.max(1.5, (v.d || 1) * baseScale * 0.4), fill: "#263238", stroke: "#80cbc4", strokeWidth: 0.5, listening: false }})));
|
||||||
|
|
||||||
|
pads.forEach((p) => {{
|
||||||
|
const dsg = String(p.pin);
|
||||||
|
const offset = 2.85;
|
||||||
|
const centerX = (minX + maxX) / 2;
|
||||||
|
const pw = Math.max(2, (p.w || 1) * baseScale * 0.6);
|
||||||
|
const ph = Math.max(2, (p.h || 1) * baseScale);
|
||||||
|
const node = new Konva.Rect({{ x: tx(p.x) - pw / 2 + (p.x < centerX ? offset : -offset), y: ty(p.y) - ph / 2, width: pw, height: ph, fill: PCB_BASE, stroke: PCB_BASE, strokeWidth: 0.3 }});
|
||||||
|
if (!pinRegistry[dsg]) pinRegistry[dsg] = {{ pcb: null, sch: null }};
|
||||||
|
pinRegistry[dsg].pcb = node;
|
||||||
|
node.on("mouseenter", (e) => highlightPin(dsg, `[PCB] Pin: ${{dsg}}`, e.evt));
|
||||||
|
node.on("mouseleave", () => unhighlightPin(dsg));
|
||||||
|
pcbLayers.pad.add(node);
|
||||||
|
}});
|
||||||
|
|
||||||
|
comps.forEach((comp) => {{
|
||||||
|
const cx = tx(comp.x), cy = ty(comp.y);
|
||||||
|
const g = new Konva.Group();
|
||||||
|
g.add(new Konva.Line({{ points: [cx - 7, cy, cx + 7, cy], stroke: "#a5d6a7", strokeWidth: 1, opacity: 0.9 }}));
|
||||||
|
g.add(new Konva.Line({{ points: [cx, cy - 7, cx, cy + 7], stroke: "#a5d6a7", strokeWidth: 1, opacity: 0.9 }}));
|
||||||
|
g.add(new Konva.Circle({{ x: cx, y: cy, radius: 3.5, stroke: "#a5d6a7", strokeWidth: 1, fill: "transparent" }}));
|
||||||
|
g.add(new Konva.Text({{ x: cx - 15, y: cy - 14, text: comp.name || "", fontSize: 6, fontFamily: "Share Tech Mono", fill: "#a5d6a7", opacity: 0.9 }}));
|
||||||
|
g.on("mouseenter", (e) => showTip(`${{comp.name || ""}}\\n(${{Number(comp.x).toFixed(2)}}, ${{Number(comp.y).toFixed(2)}})\\n${{comp.layer || ""}}`, e.evt));
|
||||||
|
g.on("mouseleave", hideTip);
|
||||||
|
pcbLayers.comp.add(g);
|
||||||
|
}});
|
||||||
|
|
||||||
|
Object.values(pcbLayers).forEach((layer) => layer.draw());
|
||||||
|
}}
|
||||||
|
|
||||||
|
function renderSchematic(data) {{
|
||||||
|
Object.values(schLayers).forEach((layer) => layer.destroyChildren());
|
||||||
|
schTransform = {{ x: 0, y: 0, scale: 1 }};
|
||||||
|
|
||||||
|
const components = data.components || [];
|
||||||
|
const pins = data.pins || [];
|
||||||
|
const lines = data.lines || [];
|
||||||
|
const rects = data.rects || [];
|
||||||
|
const arcs = data.arcs || [];
|
||||||
|
const labels = data.labels || [];
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
const allPts = [];
|
||||||
|
components.forEach((c) => allPts.push({{ x: c.x, y: c.y }}));
|
||||||
|
pins.forEach((p) => allPts.push({{ x: p.x, y: p.y }}));
|
||||||
|
rects.forEach((r) => {{ allPts.push({{ x: r.x1, y: r.y1 }}); allPts.push({{ x: r.x2, y: r.y2 }}); }});
|
||||||
|
allPts.forEach((p) => {{ minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }});
|
||||||
|
if (!isFinite(minX)) {{ minX = 0; maxX = 1000; minY = 0; maxY = 800; }}
|
||||||
|
|
||||||
|
const margin = 24;
|
||||||
|
const W = schStage.width();
|
||||||
|
const H = schStage.height();
|
||||||
|
const baseScale = Math.min((W - margin * 2) / (maxX - minX || 1), (H - margin * 2) / (maxY - minY || 1));
|
||||||
|
const drawnW = (maxX - minX) * baseScale;
|
||||||
|
const drawnH = (maxY - minY) * baseScale;
|
||||||
|
const offX = (W - drawnW) / 2;
|
||||||
|
const offY = (H - drawnH) / 2;
|
||||||
|
const tx = (x) => (x - minX) * baseScale + offX;
|
||||||
|
const ty = (y) => H - offY - (y - minY) * baseScale;
|
||||||
|
schBounds = {{ x: offX, y: offY, w: drawnW, h: drawnH }};
|
||||||
|
|
||||||
|
rects.forEach((r) => {{
|
||||||
|
const x = tx(Math.min(r.x1, r.x2));
|
||||||
|
const y = ty(Math.max(r.y1, r.y2));
|
||||||
|
const w = Math.abs(tx(r.x2) - tx(r.x1));
|
||||||
|
const h = Math.abs(ty(r.y2) - ty(r.y1));
|
||||||
|
schLayers.comp.add(new Konva.Rect({{ x, y, width: w, height: h, stroke: "black", fill: "lightyellow", strokeWidth: Math.max(0.5, (r.lineWidth || 1) * baseScale), cornerRadius: 2 }}));
|
||||||
|
}});
|
||||||
|
|
||||||
|
lines.forEach((ln) => {{
|
||||||
|
const pts = (ln.points || []).flatMap((p) => [tx(p.x), ty(p.y)]);
|
||||||
|
if (pts.length < 4) return;
|
||||||
|
schLayers.line.add(new Konva.Line({{ points: pts, stroke: "#4fc3f7", strokeWidth: Math.max(0.3, (ln.lineWidth || 0.5) * baseScale * 0.1), listening: false }}));
|
||||||
|
}});
|
||||||
|
|
||||||
|
arcs.forEach((a) => {{
|
||||||
|
const startAngle = a.startAngle || 0;
|
||||||
|
const endAngle = a.endAngle != null ? a.endAngle : 360;
|
||||||
|
let sweep = endAngle - startAngle;
|
||||||
|
if (sweep <= 0) sweep += 360;
|
||||||
|
schLayers.arc.add(new Konva.Arc({{ x: tx(a.cx), y: ty(a.cy), innerRadius: 0, outerRadius: a.radius * baseScale, angle: sweep, rotation: -startAngle, stroke: "#4fc3f7", strokeWidth: Math.max(0.3, (a.lineWidth || 0.5) * baseScale * 0.08), fill: "transparent", listening: false }}));
|
||||||
|
}});
|
||||||
|
|
||||||
|
pins.forEach((pin) => {{
|
||||||
|
const px = tx(pin.x), py = ty(pin.y);
|
||||||
|
const len = Math.max(6, (pin.length || 20) * baseScale * 0.15);
|
||||||
|
const dir = ((pin.conglomerate || 0) >> 1) & 1;
|
||||||
|
const dx = dir ? -len : len;
|
||||||
|
const dsg = String(pin.designator || "");
|
||||||
|
const g = new Konva.Group();
|
||||||
|
g.add(new Konva.Line({{ points: [px, py, px + dx, py], stroke: SCH_BASE, strokeWidth: 1, opacity: 0.8 }}));
|
||||||
|
g.add(new Konva.Rect({{ x: px - 2, y: py - 2, width: 4, height: 4, fill: SCH_BASE, opacity: 0.9 }}));
|
||||||
|
if (pin.name) {{
|
||||||
|
g.add(new Konva.Text({{ x: px - 28, y: py - 5, width: 52, align: dir ? "right" : "left", text: pin.name, fontSize: 7, fontFamily: "Share Tech Mono,monospace", fill: "black" }}));
|
||||||
|
}}
|
||||||
|
if (dsg) {{
|
||||||
|
if (!pinRegistry[dsg]) pinRegistry[dsg] = {{ pcb: null, sch: null }};
|
||||||
|
pinRegistry[dsg].sch = g;
|
||||||
|
}}
|
||||||
|
g.on("mouseenter", (e) => highlightPin(dsg, `[SCH] Pin: ${{pin.name || "?"}} (${{dsg}})`, e.evt));
|
||||||
|
g.on("mouseleave", () => unhighlightPin(dsg));
|
||||||
|
schLayers.pin.add(g);
|
||||||
|
}});
|
||||||
|
|
||||||
|
labels.forEach((lb) => {{
|
||||||
|
let fontStyle = "normal";
|
||||||
|
let offsetY = 0;
|
||||||
|
const altiumFontSize = lb.fontSize || 10;
|
||||||
|
let renderFontSize;
|
||||||
|
if (altiumFontSize > 10) {{
|
||||||
|
renderFontSize = altiumFontSize * 0.45;
|
||||||
|
fontStyle = "bold";
|
||||||
|
offsetY = 2;
|
||||||
|
}} else {{
|
||||||
|
renderFontSize = altiumFontSize * 0.75;
|
||||||
|
}}
|
||||||
|
schLayers.label.add(new Konva.Text({{ x: tx(lb.x), fontStyle, y: ty(lb.y + altiumFontSize - offsetY), text: lb.text || "", fontSize: renderFontSize, fontFamily: "Share Tech Mono,monospace", fill: "black", listening: false }}));
|
||||||
|
}});
|
||||||
|
|
||||||
|
Object.values(schLayers).forEach((layer) => layer.draw());
|
||||||
|
}}
|
||||||
|
|
||||||
|
function loadFromPath(path) {{
|
||||||
|
loading.classList.add("active");
|
||||||
|
fetch(path)
|
||||||
|
.then(function(res) {{
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${{res.status}}: ${{res.statusText}}`);
|
||||||
|
return res.json();
|
||||||
|
}})
|
||||||
|
.then(function(data) {{
|
||||||
|
Object.keys(pinRegistry).forEach((key) => delete pinRegistry[key]);
|
||||||
|
initStages();
|
||||||
|
if (data.pcb) renderPCB(data.pcb);
|
||||||
|
if (data.schematic) renderSchematic(data.schematic);
|
||||||
|
applyPcbTransform();
|
||||||
|
applySchTransform();
|
||||||
|
}})
|
||||||
|
.catch(function(err) {{
|
||||||
|
console.error("Schematic footprint viewer load failed:", err);
|
||||||
|
loading.querySelector("span").textContent = "JSON LOAD FAILED";
|
||||||
|
}})
|
||||||
|
.finally(function() {{
|
||||||
|
setTimeout(function() {{ loading.classList.remove("active"); }}, 250);
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
loadFromPath(jsonPath);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
boot();
|
||||||
|
}})();
|
||||||
|
</script>
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
VIEWER_CSS = r"""
|
||||||
|
.schematic-footprint-viewer,
|
||||||
|
.schematic-footprint-viewer * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer {
|
||||||
|
--sfv-bg: #0d1117;
|
||||||
|
--sfv-panel: #161b22;
|
||||||
|
--sfv-border: #21262d;
|
||||||
|
--sfv-accent-pcb: #00e5a0;
|
||||||
|
--sfv-accent-sch: #4fc3f7;
|
||||||
|
--sfv-text: #e6edf3;
|
||||||
|
--sfv-dim: #8b949e;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--sfv-bg);
|
||||||
|
color: var(--sfv-text);
|
||||||
|
font-family: "Share Tech Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-viewer-box {
|
||||||
|
flex: 1 1 0;
|
||||||
|
width: var(--sfv-box-w);
|
||||||
|
height: var(--sfv-box-h);
|
||||||
|
max-width: calc(50% - 0.25rem);
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--sfv-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #090b0c;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04), 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-box-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 10px;
|
||||||
|
height: 2.1rem;
|
||||||
|
background: var(--sfv-panel);
|
||||||
|
border-bottom: 1px solid var(--sfv-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-box-label {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 2.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-pcb-label {
|
||||||
|
color: var(--sfv-accent-pcb);
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-sch-label {
|
||||||
|
color: var(--sfv-accent-sch);
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-box-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-box-canvas-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-box-canvas-wrap > div {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-ctrl-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--sfv-border);
|
||||||
|
color: var(--sfv-dim);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-ctrl-btn:hover {
|
||||||
|
border-color: var(--sfv-accent-pcb);
|
||||||
|
color: var(--sfv-accent-pcb);
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-zoom-pill {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--sfv-accent-pcb);
|
||||||
|
min-width: 34px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-loading {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
background: rgba(13, 17, 23, 0.85);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-loading.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-loading span {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
color: var(--sfv-accent-pcb);
|
||||||
|
animation: sfv-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes sfv-pulse {
|
||||||
|
0%, 100% { opacity: 0.2; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer .sfv-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none;
|
||||||
|
background: var(--sfv-panel);
|
||||||
|
border: 1px solid var(--sfv-accent-pcb);
|
||||||
|
color: var(--sfv-text);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: pre;
|
||||||
|
display: none;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 229, 160, 0.15);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.schematic-footprint-viewer{
|
||||||
|
--sfv-bg: #0d111700!important;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SchematicFootprintPreprocessor(Preprocessor):
|
||||||
|
"""Replace schematic footprint shortcodes before Markdown parsing."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
md: Markdown,
|
||||||
|
default_json: str,
|
||||||
|
width: str,
|
||||||
|
height: str,
|
||||||
|
konva_url: str,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(md)
|
||||||
|
self.default_json = default_json
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.konva_url = konva_url
|
||||||
|
|
||||||
|
def run(self, lines: list[str]) -> list[str]:
|
||||||
|
markdown = "\n".join(lines)
|
||||||
|
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
attrs = _parse_shortcode_attrs(
|
||||||
|
match.group("attrs"),
|
||||||
|
default_json=self.default_json,
|
||||||
|
)
|
||||||
|
return render_viewer(
|
||||||
|
ViewerOptions(
|
||||||
|
json_path=attrs["json"],
|
||||||
|
width=attrs.get("width", self.width),
|
||||||
|
height=attrs.get("height", self.height),
|
||||||
|
konva_url=attrs.get("konva", self.konva_url),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = SHORTCODE_RE.sub(replace, markdown)
|
||||||
|
return rendered.split("\n")
|
||||||
|
|
||||||
|
|
||||||
|
class SchematicFootprintViewerExtension(Extension):
|
||||||
|
"""Python-Markdown extension used by Zensical."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
self.config = {
|
||||||
|
"default_json": ["merged.json", "Default JSON path when shortcode has no json/src attribute."],
|
||||||
|
"width": ["15rem", "Viewer box width."],
|
||||||
|
"height": ["13rem", "Viewer box height."],
|
||||||
|
"konva_url": ["https://unpkg.com/konva@9/konva.min.js", "Konva JavaScript URL."],
|
||||||
|
}
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def extendMarkdown(self, md: Markdown) -> None:
|
||||||
|
md.preprocessors.register(
|
||||||
|
SchematicFootprintPreprocessor(
|
||||||
|
md,
|
||||||
|
default_json=str(self.getConfig("default_json")),
|
||||||
|
width=str(self.getConfig("width")),
|
||||||
|
height=str(self.getConfig("height")),
|
||||||
|
konva_url=str(self.getConfig("konva_url")),
|
||||||
|
),
|
||||||
|
"schematic_footprint_viewer",
|
||||||
|
35,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def makeExtension(**kwargs) -> SchematicFootprintViewerExtension:
|
||||||
|
"""Entry point used by Python-Markdown and Zensical."""
|
||||||
|
|
||||||
|
return SchematicFootprintViewerExtension(**kwargs)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from markdown import Markdown
|
||||||
|
|
||||||
|
from schematic_footprint_viewer import makeExtension
|
||||||
|
from schematic_footprint_viewer.extension import ViewerOptions, render_viewer
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_viewer_outputs_two_canvas_boxes():
|
||||||
|
html = render_viewer(ViewerOptions(json_path="files/demo.json"))
|
||||||
|
|
||||||
|
assert 'class="schematic-footprint-viewer"' in html
|
||||||
|
assert 'data-sfv-json="files/demo.json"' in html
|
||||||
|
assert "data-sfv-pcb-canvas" in html
|
||||||
|
assert "data-sfv-sch-canvas" in html
|
||||||
|
assert "sfv-main" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_replaces_shortcode_with_json_attribute():
|
||||||
|
md = Markdown(extensions=[makeExtension()])
|
||||||
|
|
||||||
|
rendered = md.convert(
|
||||||
|
'Before\n\n{% schematic-footprint json="files/VL53L0X_merged.json" %}\n\nAfter',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "{% schematic-footprint" not in rendered
|
||||||
|
assert "Before" in rendered
|
||||||
|
assert "After" in rendered
|
||||||
|
assert 'data-sfv-json="files/VL53L0X_merged.json"' in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_accepts_src_alias_and_dimensions():
|
||||||
|
md = Markdown(extensions=[makeExtension()])
|
||||||
|
|
||||||
|
rendered = md.convert(
|
||||||
|
'{% schematic-footprint src="merged.json" width="24rem" height="20rem" %}',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'data-sfv-json="merged.json"' in rendered
|
||||||
|
assert "--sfv-box-w:24rem" in rendered
|
||||||
|
assert "--sfv-box-h:20rem" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_uses_default_json_when_no_attribute_is_given():
|
||||||
|
md = Markdown(extensions=[makeExtension(default_json="default.json")])
|
||||||
|
rendered = md.convert("{% schematic-footprint %}")
|
||||||
|
|
||||||
|
assert 'data-sfv-json="default.json"' in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_is_loadable_by_module_name():
|
||||||
|
md = Markdown(extensions=["schematic_footprint_viewer"])
|
||||||
|
rendered = md.convert('{% schematic-footprint json="module.json" %}')
|
||||||
|
|
||||||
|
assert 'data-sfv-json="module.json"' in rendered
|
||||||
Reference in New Issue
Block a user