150 lines
9.3 KiB
Swift
150 lines
9.3 KiB
Swift
import AppKit
|
|
|
|
/// Resolution-independent diagram renderer shared by PDF, PNG, SVG and macOS printing.
|
|
/// The exported palette is intentionally independent of the user's macOS appearance.
|
|
final class SchemaDiagramPrintView: NSView {
|
|
struct Column { let name: String; let type: String; let primary: Bool }
|
|
struct Entry { let name: String; let isView: Bool; let columns: [Column]; let origin: CGPoint }
|
|
struct Link { let from: String; let to: String; let column: String; let target: String }
|
|
|
|
let database: String
|
|
let diagram: String
|
|
let entries: [Entry]
|
|
let links: [Link]
|
|
let theme: String
|
|
private let cardWidth: CGFloat = 260
|
|
private let headerHeight: CGFloat = 34
|
|
private let rowHeight: CGFloat = 19
|
|
private let margin: CGFloat = 55
|
|
private let titleHeight: CGFloat = 76
|
|
private let navy = NSColor(calibratedRed: 0.12, green: 0.20, blue: 0.34, alpha: 1)
|
|
private var blue: NSColor {
|
|
switch theme {
|
|
case "Slate": return NSColor(calibratedRed: 0.30, green: 0.38, blue: 0.49, alpha: 1)
|
|
case "Forest": return NSColor(calibratedRed: 0.16, green: 0.43, blue: 0.36, alpha: 1)
|
|
case "Plum": return NSColor(calibratedRed: 0.42, green: 0.32, blue: 0.55, alpha: 1)
|
|
default: return NSColor(calibratedRed: 0.12, green: 0.38, blue: 0.66, alpha: 1)
|
|
}
|
|
}
|
|
private var violet: NSColor { blue.blended(withFraction: 0.25, of: .systemPurple) ?? blue }
|
|
private let ink = NSColor(calibratedRed: 0.16, green: 0.23, blue: 0.32, alpha: 1)
|
|
private let muted = NSColor(calibratedRed: 0.43, green: 0.49, blue: 0.57, alpha: 1)
|
|
private let border = NSColor(calibratedRed: 0.79, green: 0.83, blue: 0.88, alpha: 1)
|
|
|
|
init(database: String, diagram: String, entries: [Entry], links: [Link], theme: String = "Ocean") {
|
|
self.database = database
|
|
self.diagram = diagram
|
|
self.entries = entries
|
|
self.links = links
|
|
self.theme = theme
|
|
let width = max(900, (entries.map { $0.origin.x + 260 }.max() ?? 750) + 110)
|
|
let height = max(650, (entries.map { $0.origin.y + CGFloat($0.columns.count) * 19 + 46 }.max() ?? 500) + 130)
|
|
super.init(frame: CGRect(x: 0, y: 0, width: width, height: height))
|
|
}
|
|
required init?(coder: NSCoder) { nil }
|
|
override var isFlipped: Bool { true }
|
|
private func cardHeight(_ entry: Entry) -> CGFloat { headerHeight + CGFloat(entry.columns.count) * rowHeight + 12 }
|
|
private func cardRect(_ entry: Entry) -> CGRect {
|
|
CGRect(x: entry.origin.x + margin, y: entry.origin.y + titleHeight,
|
|
width: cardWidth, height: cardHeight(entry))
|
|
}
|
|
private func text(_ string: String, at point: CGPoint, font: NSFont, color: NSColor) {
|
|
(string as NSString).draw(at: point, withAttributes: [.font: font, .foregroundColor: color])
|
|
}
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
super.draw(dirtyRect)
|
|
NSColor.white.setFill()
|
|
NSBezierPath(rect: bounds).fill()
|
|
navy.setFill()
|
|
NSBezierPath(rect: CGRect(x: 0, y: 0, width: bounds.width, height: 8)).fill()
|
|
text(database, at: CGPoint(x: margin, y: 25), font: .systemFont(ofSize: 22, weight: .bold), color: navy)
|
|
text("SCHEMA DIAGRAM / \(diagram)", at: CGPoint(x: margin, y: 54), font: .systemFont(ofSize: 10, weight: .medium), color: muted)
|
|
text("\(entries.count) objects • \(links.count) relationships", at: CGPoint(x: margin, y: bounds.height - 27), font: .systemFont(ofSize: 10), color: muted)
|
|
let byName = Dictionary(uniqueKeysWithValues: entries.map { ($0.name, $0) })
|
|
for link in links {
|
|
guard let source = byName[link.from], let target = byName[link.to] else { continue }
|
|
let a = cardRect(source), b = cardRect(target)
|
|
let toRight = a.midX <= b.midX
|
|
let start = CGPoint(x: toRight ? a.maxX : a.minX, y: a.midY)
|
|
let end = CGPoint(x: toRight ? b.minX : b.maxX, y: b.midY)
|
|
let curve = NSBezierPath()
|
|
curve.move(to: start)
|
|
curve.curve(to: end,
|
|
controlPoint1: CGPoint(x: start.x + (toRight ? 55 : -55), y: start.y),
|
|
controlPoint2: CGPoint(x: end.x + (toRight ? -55 : 55), y: end.y))
|
|
NSColor(calibratedRed: 0.39, green: 0.53, blue: 0.69, alpha: 1).setStroke()
|
|
curve.lineWidth = 1.7
|
|
curve.stroke()
|
|
}
|
|
for entry in entries {
|
|
let rect = cardRect(entry)
|
|
NSColor.white.setFill()
|
|
NSBezierPath(roundedRect: rect, xRadius: 7, yRadius: 7).fill()
|
|
border.setStroke()
|
|
NSBezierPath(roundedRect: rect, xRadius: 7, yRadius: 7).stroke()
|
|
let header = CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: headerHeight)
|
|
(entry.isView ? violet : blue).setFill()
|
|
NSBezierPath(rect: header).fill()
|
|
text(entry.name, at: CGPoint(x: rect.minX + 12, y: rect.minY + 9),
|
|
font: .systemFont(ofSize: 12, weight: .semibold), color: .white)
|
|
for (i, column) in entry.columns.enumerated() {
|
|
let y = rect.minY + headerHeight + CGFloat(i) * rowHeight
|
|
if i.isMultiple(of: 2) {
|
|
NSColor(calibratedRed: 0.96, green: 0.97, blue: 0.99, alpha: 1).setFill()
|
|
NSBezierPath(rect: CGRect(x: rect.minX + 1, y: y, width: rect.width - 2, height: rowHeight)).fill()
|
|
}
|
|
let label = (column.primary ? "◆ " : " ") + column.name
|
|
text(String(label.prefix(29)), at: CGPoint(x: rect.minX + 9, y: y + 3),
|
|
font: .systemFont(ofSize: 10, weight: column.primary ? .semibold : .regular), color: ink)
|
|
let type = String(column.type.prefix(22)) as NSString
|
|
let attrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 9), .foregroundColor: muted]
|
|
let typeWidth = type.size(withAttributes: attrs).width
|
|
type.draw(at: CGPoint(x: rect.maxX - typeWidth - 10, y: y + 4), withAttributes: attrs)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func escape(_ text: String) -> String {
|
|
text.replacingOccurrences(of: "&", with: "&")
|
|
.replacingOccurrences(of: "<", with: "<")
|
|
.replacingOccurrences(of: ">", with: ">")
|
|
.replacingOccurrences(of: "\"", with: """)
|
|
}
|
|
func svgDocument() -> String {
|
|
var svg = """
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="\(Int(bounds.width))" height="\(Int(bounds.height))" viewBox="0 0 \(Int(bounds.width)) \(Int(bounds.height))">
|
|
<rect width="100%" height="100%" fill="#ffffff"/>
|
|
<rect width="100%" height="8" fill="#1f3457"/>
|
|
<text x="55" y="44" font-family="-apple-system,Arial,sans-serif" font-size="22" font-weight="700" fill="#1f3457">\(escape(database))</text>
|
|
<text x="55" y="66" font-family="Arial,sans-serif" font-size="10" fill="#6e7d91">SCHEMA DIAGRAM / \(escape(diagram))</text>
|
|
"""
|
|
let byName = Dictionary(uniqueKeysWithValues: entries.map { ($0.name, $0) })
|
|
for link in links {
|
|
guard let source = byName[link.from], let target = byName[link.to] else { continue }
|
|
let a = cardRect(source), b = cardRect(target), right = a.midX <= b.midX
|
|
let sx = right ? a.maxX : a.minX, ex = right ? b.minX : b.maxX
|
|
let delta: CGFloat = right ? 55 : -55
|
|
svg += "<path d=\"M \(sx) \(a.midY) C \(sx + delta) \(a.midY), \(ex - delta) \(b.midY), \(ex) \(b.midY)\" fill=\"none\" stroke=\"#6488af\" stroke-width=\"1.7\"/>\n"
|
|
}
|
|
let svgBlue = blue.usingColorSpace(.deviceRGB) ?? blue
|
|
let svgViolet = violet.usingColorSpace(.deviceRGB) ?? violet
|
|
func hex(_ color: NSColor) -> String {
|
|
String(format: "#%02X%02X%02X", Int(color.redComponent * 255), Int(color.greenComponent * 255), Int(color.blueComponent * 255))
|
|
}
|
|
for entry in entries {
|
|
let r = cardRect(entry)
|
|
svg += "<rect x=\"\(r.minX)\" y=\"\(r.minY)\" width=\"\(r.width)\" height=\"\(r.height)\" rx=\"7\" fill=\"white\" stroke=\"#cbd4e0\"/>\n"
|
|
svg += "<rect x=\"\(r.minX)\" y=\"\(r.minY)\" width=\"\(r.width)\" height=\"34\" fill=\"\(entry.isView ? hex(svgViolet) : hex(svgBlue))\"/>\n"
|
|
svg += "<text x=\"\(r.minX + 12)\" y=\"\(r.minY + 23)\" font-family=\"Arial,sans-serif\" font-size=\"12\" font-weight=\"600\" fill=\"white\">\(escape(entry.name))</text>\n"
|
|
for (index, column) in entry.columns.enumerated() {
|
|
let y = r.minY + 34 + CGFloat(index) * 19
|
|
if index.isMultiple(of: 2) { svg += "<rect x=\"\(r.minX + 1)\" y=\"\(y)\" width=\"\(r.width - 2)\" height=\"19\" fill=\"#f5f8fc\"/>\n" }
|
|
svg += "<text x=\"\(r.minX + 9)\" y=\"\(y + 13)\" font-family=\"Arial,sans-serif\" font-size=\"10\" fill=\"#293b52\">\(escape((column.primary ? "◆ " : " ") + column.name))</text>\n"
|
|
svg += "<text x=\"\(r.maxX - 9)\" y=\"\(y + 13)\" text-anchor=\"end\" font-family=\"Arial,sans-serif\" font-size=\"9\" fill=\"#6e7d91\">\(escape(column.type))</text>\n"
|
|
}
|
|
}
|
|
svg += "<text x=\"55\" y=\"\(bounds.height - 20)\" font-family=\"Arial,sans-serif\" font-size=\"10\" fill=\"#6e7d91\">\(entries.count) objects / \(links.count) relationships</text>\n</svg>"
|
|
return svg
|
|
}
|
|
}
|