Files
byre/mac/Sources/Byre/DiagramHTML.swift
T

259 lines
14 KiB
Swift
Raw Normal View History

import CoreText
import SwiftUI
import UniformTypeIdentifiers
private func hex(_ c: Color, _ scheme: ColorScheme) -> String {
var env = EnvironmentValues()
env.colorScheme = scheme
let comps = c.resolve(in: env).cgColor.converted(to: CGColorSpace(name: CGColorSpace.sRGB)!, intent: .defaultIntent, options: nil)!.components!
return "#" + comps.prefix(3).map { String(Int(($0 * 255).rounded()) + 0x100, radix: 16).dropFirst() }.joined()
}
private func num(_ v: CGFloat) -> String {
"\((v * 10).rounded() / 10)"
}
private func esc(_ s: String) -> String {
s.replacingOccurrences(of: "&", with: "&amp;").replacingOccurrences(of: "<", with: "&lt;").replacingOccurrences(of: ">", with: "&gt;").replacingOccurrences(of: "\"", with: "&quot;")
}
private func json(_ v: Any) -> String {
String(decoding: try! JSONSerialization.data(withJSONObject: v), as: UTF8.self).replacingOccurrences(of: "</", with: "<\\/")
}
private func fontFace(_ subset: String) -> String {
let url = Bundle.main.resourceURL!.appending(path: "fonts/atkinson-hyperlegible-next-\(subset)-wght-normal.woff2")
let data = try! Data(contentsOf: url)
return "@font-face{font-family:Atkinson;font-weight:200 800;src:url(data:font/woff2;base64,\(data.base64EncodedString())) format(\"woff2\")}"
}
private func edgePath(_ pts: [CGPoint]) -> String {
guard let first = pts.first else {
return ""
}
var d = "M\(num(first.x)) \(num(first.y))"
if pts.count > 2 {
for i in 1..<(pts.count - 1) {
let a = pts[i - 1]
let b = pts[i]
let c = pts[i + 1]
let lin = hypot(b.x - a.x, b.y - a.y)
let lout = hypot(c.x - b.x, c.y - b.y)
let r = min(6, lin / 2, lout / 2)
let p = CGPoint(x: b.x - (b.x - a.x) / lin * r, y: b.y - (b.y - a.y) / lin * r)
let q = CGPoint(x: b.x + (c.x - b.x) / lout * r, y: b.y + (c.y - b.y) / lout * r)
d += "L\(num(p.x)) \(num(p.y))Q\(num(b.x)) \(num(b.y)) \(num(q.x)) \(num(q.y))"
}
}
let last = pts[pts.count - 1]
return d + "L\(num(last.x)) \(num(last.y))"
}
private func svgPath(_ p: Path) -> String {
var d = ""
p.forEach { e in
switch e {
case .move(let to):
d += "M\(num(to.x)) \(num(to.y))"
case .line(let to):
d += "L\(num(to.x)) \(num(to.y))"
case .quadCurve(let to, let c):
d += "Q\(num(c.x)) \(num(c.y)) \(num(to.x)) \(num(to.y))"
case .curve(let to, let c1, let c2):
d += "C\(num(c1.x)) \(num(c1.y)) \(num(c2.x)) \(num(c2.y)) \(num(to.x)) \(num(to.y))"
case .closeSubpath:
d += "Z"
}
}
return d
}
private func arrowHead(_ from: CGPoint, _ to: CGPoint) -> String {
let angle = atan2(to.y - from.y, to.x - from.x)
let size: CGFloat = 8
let a = CGPoint(x: to.x - size * cos(angle - .pi / 7), y: to.y - size * sin(angle - .pi / 7))
let b = CGPoint(x: to.x - size * cos(angle + .pi / 7), y: to.y - size * sin(angle + .pi / 7))
return "\(num(to.x)),\(num(to.y)) \(num(a.x)),\(num(a.y)) \(num(b.x)),\(num(b.y))"
}
@MainActor
private func levelSVG(_ layout: BoxLayout) -> String {
let titleLine = textSize("X", titleFont).height
let detailLine = textSize("X", detailFont).height
var svg = "<g class=\"level\" data-k=\"\(esc(layout.level))\">"
for b in layout.boxes {
let r = b.rect
let f = fill(b)
let kind = switch b.kind {
case .box: ""
case .frame: " frame"
case .ghost: " ghost"
}
svg += "<g class=\"box\(kind)\" data-id=\"\(esc(b.node.id))\" style=\"--l:\(hex(f, .light));--d:\(hex(f, .dark))\">"
svg += "<rect x=\"\(num(r.minX))\" y=\"\(num(r.minY))\" width=\"\(num(r.width))\" height=\"\(num(r.height))\" rx=\"\(num(corner(b)))\"/>"
if b.node.opens {
let m = markRect(r)
svg += "<g class=\"mark\"><rect x=\"\(num(m.minX))\" y=\"\(num(m.minY))\" width=\"\(num(m.width))\" height=\"\(num(m.height))\"/><path d=\"\(svgPath(openMark(r)))\"/></g>"
}
var y = r.minY + padY
svg += "<text class=\"title\" x=\"\(num(r.minX + padX))\" y=\"\(num(y + CTFontGetAscent(titleFont)))\">\(esc(b.node.title))</text>"
y += titleLine + titleGap
for d in b.node.details {
svg += "<text class=\"detail\" x=\"\(num(r.minX + padX))\" y=\"\(num(y + CTFontGetAscent(detailFont)))\">\(esc(d))</text>"
y += detailLine
}
svg += "</g>"
}
for (i, e) in layout.edges.enumerated() {
svg += "<g class=\"edge\" data-i=\"\(i)\">"
svg += "<path d=\"\(edgePath(shaft(e.points)))\"\(e.edge.dashed ? " stroke-dasharray=\"5 4\"" : "")/>"
if e.points.count >= 2 {
svg += "<polygon points=\"\(arrowHead(e.points[e.points.count - 2], e.points[e.points.count - 1]))\"/>"
}
if let label = e.edge.label, let rect = e.labelRect {
svg += "<text x=\"\(num(rect.minX))\" y=\"\(num(rect.minY + CTFontGetAscent(edgeLabelFont)))\">\(esc(label))</text>"
}
svg += "</g>"
}
return svg + "</g>"
}
@MainActor
func diagramHTML(_ diagram: DiagramLayout) -> String {
let colors: [(String, Color)] = [("bg", Palette.bg), ("border", Palette.border), ("text", Palette.text), ("muted", Palette.muted), ("accent", Palette.accent)]
let light = colors.map { "--\($0.0):\(hex($0.1, .light))" }.joined(separator: ";")
let dark = colors.map { "--\($0.0):\(hex($0.1, .dark))" }.joined(separator: ";")
let svg = diagram.order.map { levelSVG(diagram.levels[$0]!) }.joined()
var levels: [String: Any] = [:]
for (key, layout) in diagram.levels {
let kinds: [BoxKind: String] = [.box: "box", .frame: "frame", .ghost: "ghost"]
levels[key] = [
"title": diagram.title(key),
"boxes": layout.boxes.map { ["id": $0.node.id, "parent": $0.parent.map { $0 as Any } ?? NSNull(), "kind": kinds[$0.kind]!, "opens": $0.node.opens] },
"edges": layout.edges.map { ["from": $0.edge.from, "to": $0.edge.to] },
"presets": layout.presets.map { ["name": $0.name, "ids": Array($0.ids)] },
"width": layout.size.width,
"height": layout.size.height,
]
}
let data: [String: Any] = ["levels": levels, "homes": diagram.homes]
return """
<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Diagram</title>
<style>
\(fontFace("latin-ext"))
\(fontFace("latin"))
:root{\(light)}
@media (prefers-color-scheme:dark){:root{\(dark)}.box{--l:var(--d)!important}}
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font-family:Atkinson,-apple-system,sans-serif}
body{display:flex;flex-direction:column}
nav{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:12px 12px 0;font:700 13px Atkinson,-apple-system,sans-serif}
#trail:empty,#bar[hidden]{display:none}
#bar{min-height:27px}
nav .sep{color:var(--muted)}
nav .grow{flex:1}
nav a{color:var(--accent);cursor:pointer}
button{font:600 13px Atkinson,-apple-system,sans-serif;color:var(--text);background:transparent;border:1px solid var(--border);border-radius:6px;padding:4px 10px;cursor:pointer}
button.on{color:var(--bg);background:var(--accent);border-color:var(--accent)}
svg{flex:1;width:100%;min-height:0;touch-action:none;cursor:grab;user-select:none;-webkit-user-select:none}
.level{display:none}
.level.shown{display:inline}
.box rect{fill:var(--l);stroke:var(--border);stroke-width:1}
.box.frame rect{fill:none}
.box.ghost rect{fill:none;stroke:var(--muted);stroke-dasharray:3 3}
.box.ghost .title{fill:var(--muted)}
.box.lit rect{stroke:var(--accent);stroke-width:1.5}
.box.dim{opacity:.3}
.box{cursor:pointer}
.box.frame{cursor:inherit}
.box .mark rect{fill:transparent;stroke:none}
2026-10-23 13:34:56 -07:00
.mark path{fill:none;stroke:var(--accent);stroke-width:1.6;stroke-linecap:round}
.title{font-size:\(num(CTFontGetSize(titleFont)))px;font-weight:600;fill:var(--text)}
.detail{font-size:\(num(CTFontGetSize(detailFont)))px;fill:var(--muted)}
.edge{pointer-events:none}
.edge path{fill:none;stroke:var(--muted);stroke-width:1.3;stroke-linecap:round;stroke-linejoin:round}
.edge polygon{fill:var(--muted)}
.edge text{font-size:\(num(CTFontGetSize(edgeLabelFont)))px;fill:var(--muted)}
.edge.lit path{stroke:var(--accent);stroke-width:2}
.edge.lit polygon,.edge.lit text{fill:var(--accent)}
.edge.faint{opacity:.2}
</style></head><body>
<nav id="trail"></nav>
<nav id="bar"></nav>
<svg xmlns="http://www.w3.org/2000/svg"><g id="world">\(svg)</g></svg>
<script>
const D=\(json(data));
const svg=document.querySelector("svg"),world=document.getElementById("world");
let level="",picked=null,preset=null,L,parent,kind;
const anc=id=>{const o=[];for(let p=parent[id];p!=null;p=parent[p])o.push(p);return o};
const shown=()=>[...document.querySelectorAll(".level")].find(g=>g.dataset.k===level);
const trail=()=>{const o=[level];while(o[o.length-1]!=="")o.push(D.homes[o[o.length-1]]);return o.reverse()};
const el=(tag,cls,text,click)=>{const e=document.createElement(tag);if(cls)e.className=cls;e.textContent=text;if(click)e.onclick=click;return e};
function go(to,sel){level=to;picked=sel;preset=null;L=D.levels[level];parent={};kind={};
L.boxes.forEach(b=>{parent[b.id]=b.parent;kind[b.id]=b.kind});
document.querySelectorAll(".level").forEach(g=>g.classList.toggle("shown",g.dataset.k===level));
const t=document.getElementById("trail");t.replaceChildren();
if(level!==""){const ls=trail();ls.forEach((l,i)=>{if(i>0)t.append(el("span","sep","›"));
t.append(l===level?el("span","",D.levels[l].title):el("a","",D.levels[l].title,()=>go(l,ls[i+1])))})}
render();fit()}
function render(){
let boxes=null,edges=new Set(),outlined=new Set();
if(picked!=null){boxes=new Set([picked]);outlined=new Set([picked]);L.edges.forEach((e,i)=>{if(e.from==picked||e.to==picked){edges.add(i);boxes.add(e.from);boxes.add(e.to)}})}
else if(preset!=null){const ids=L.presets[preset].ids;boxes=new Set(ids);outlined=boxes;L.edges.forEach((e,i)=>{if(boxes.has(e.from)&&boxes.has(e.to))edges.add(i)})}
const g=shown();
g.querySelectorAll(".box").forEach(g=>{const id=g.dataset.id;
g.classList.toggle("lit",outlined.has(id));
const dim=!!boxes&&!boxes.has(id)&&!anc(id).some(a=>boxes.has(a))&&![...boxes].some(b=>anc(b).includes(id));
g.classList.toggle("dim",dim)});
g.querySelectorAll(".edge").forEach(g=>{const i=+g.dataset.i;g.classList.toggle("lit",edges.has(i));g.classList.toggle("faint",!!boxes&&!edges.has(i))});
const bar=document.getElementById("bar");bar.replaceChildren();
bar.hidden=!L.presets.length&&!L.boxes.some(b=>b.kind==="box"&&b.opens);
L.presets.forEach((p,i)=>{const b=el("button",i===preset?"on":"",p.name,()=>{picked=null;preset=preset===i?null:i;render()});bar.append(b)});
const opening=picked!=null&&L.boxes.find(b=>b.id===picked&&b.kind==="box"&&b.opens);
if(opening){bar.append(el("span","grow",""));bar.append(el("button","on","Open "+D.levels[picked].title+" ›",()=>go(picked,null)))}
}
let s=1,tx=0,ty=0;
const bound=(t,view,size)=>size<=view?(view-size)/2:Math.min(0,Math.max(view-size,t));
const apply=()=>{const r=svg.getBoundingClientRect();tx=bound(tx,r.width,L.width*s);ty=bound(ty,r.height,L.height*s);world.setAttribute("transform",`translate(${tx} ${ty}) scale(${s})`)};
function fit(){const r=svg.getBoundingClientRect();s=Math.min(2,r.width/L.width,r.height/L.height);tx=(r.width-L.width*s)/2;ty=(r.height-L.height*s)/2;apply()}
function zoom(f,cx,cy){const n=Math.min(6,Math.max(.25,s*f));tx=cx-(cx-tx)*n/s;ty=cy-(cy-ty)*n/s;s=n;apply()}
const local=e=>{const r=svg.getBoundingClientRect();return[e.clientX-r.left,e.clientY-r.top]};
svg.addEventListener("wheel",e=>{e.preventDefault();if(e.ctrlKey){const[x,y]=local(e);zoom(Math.exp(-e.deltaY*.01),x,y)}else{tx-=e.deltaX;ty-=e.deltaY;apply()}},{passive:false});
const ptrs=new Map();let moved=0,pinch=0;
svg.addEventListener("pointerdown",e=>{svg.setPointerCapture(e.pointerId);ptrs.set(e.pointerId,local(e));if(ptrs.size==1)moved=0;pinch=0});
svg.addEventListener("pointermove",e=>{if(!ptrs.has(e.pointerId))return;const p=local(e),o=ptrs.get(e.pointerId);
if(ptrs.size==1){tx+=p[0]-o[0];ty+=p[1]-o[1];moved+=Math.abs(p[0]-o[0])+Math.abs(p[1]-o[1]);apply();ptrs.set(e.pointerId,p);return}
ptrs.set(e.pointerId,p);moved=99;const[a,b]=[...ptrs.values()];const d=Math.hypot(a[0]-b[0],a[1]-b[1]);
if(pinch)zoom(d/pinch,(a[0]+b[0])/2,(a[1]+b[1])/2);pinch=d});
const up=e=>{ptrs.delete(e.pointerId);pinch=0};
svg.addEventListener("pointerup",e=>{const tap=ptrs.size==1&&moved<4;up(e);if(!tap)return;
const g=document.elementsFromPoint(e.clientX,e.clientY).find(el=>el.closest&&el.closest(".box"));
const id=g?g.closest(".box").dataset.id:null;preset=null;
if(id!=null&&kind[id]==="ghost"){go(D.homes[id],id);return}
if(id!=null&&kind[id]==="box"&&g.closest(".mark")){go(id,null);return}
picked=id==null||kind[id]==="frame"||id==picked?null:id;render()});
svg.addEventListener("pointercancel",up);
addEventListener("resize",fit);
go("",null);
</script></body></html>
"""
}
struct DiagramHTMLFile: FileDocument {
static let readableContentTypes: [UTType] = [.html]
let html: String
init(html: String) {
self.html = html
}
init(configuration: ReadConfiguration) throws {
throw CocoaError(.fileReadUnsupportedScheme)
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: Data(html.utf8))
}
}