256 lines
13 KiB
Swift
256 lines
13 KiB
Swift
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: "&").replacingOccurrences(of: "<", with: "<").replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "\"", with: """)
|
||
}
|
||
|
||
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}
|
||
nav:empty{display:none}
|
||
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}
|
||
.mark path{fill:none;stroke:var(--muted);stroke-width:1.2}
|
||
.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();
|
||
if(picked!=null){boxes=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);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",!!boxes&&boxes.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();
|
||
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 apply=()=>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))
|
||
}
|
||
}
|