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

259 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
.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))
}
}