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

190 lines
10 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: "&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 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
func diagramHTML(_ layout: BoxLayout) -> 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 titleLine = textSize("X", titleFont).height
let detailLine = textSize("X", detailFont).height
var svg = ""
for b in layout.boxes {
let r = b.rect
let f = fill(b)
svg += "<g class=\"box\" 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=\"\(b.depth == 0 ? 8 : 6)\"/>"
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>"
}
let data: [String: Any] = [
"boxes": layout.boxes.map { ["id": $0.node.id, "parent": $0.parent.map { $0 as Any } ?? NSNull()] },
"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 buttons = layout.presets.enumerated().map { "<button data-p=\"\($0.offset)\">\(esc($0.element.name))</button>" }.joined()
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;gap:6px;padding:12px 12px 0}
nav:empty{display:none}
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}
.box rect{fill:var(--l);stroke:var(--border);stroke-width:1}
.box.lit rect{stroke:var(--accent);stroke-width:1.5}
.box.dim{opacity:.3}
.box{cursor:pointer}
.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>\(buttons)</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");
const parent={};D.boxes.forEach(b=>parent[b.id]=b.parent);
const anc=id=>{const o=[];for(let p=parent[id];p!=null;p=parent[p])o.push(p);return o};
let picked=null,preset=null;
function render(){
let boxes=null,edges=new Set();
if(picked!=null){boxes=new Set([picked]);D.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=D.presets[preset].ids;boxes=new Set(ids);D.edges.forEach((e,i)=>{if(boxes.has(e.from)&&boxes.has(e.to))edges.add(i)})}
document.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)});
document.querySelectorAll(".edge").forEach(g=>{const i=+g.dataset.i;g.classList.toggle("lit",edges.has(i));g.classList.toggle("faint",!!boxes&&!edges.has(i))});
document.querySelectorAll("nav button").forEach(b=>b.classList.toggle("on",+b.dataset.p===preset));
}
document.querySelectorAll("nav button").forEach(b=>b.onclick=()=>{const p=+b.dataset.p;picked=null;preset=preset===p?null:p;render()});
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/D.width,r.height/D.height);tx=(r.width-D.width*s)/2;ty=(r.height-D.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;picked=id==picked?null:id;render()});
svg.addEventListener("pointercancel",up);
addEventListener("resize",fit);
fit();
</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))
}
}