spb/metrika Public
Stata-class statistics, GPU-accelerated by Apple Silicon. Native Swift — no Electron, no Python runtime, no compromises.
Swift 92.4%
HTML 3.3%
R 3%
Shell 1.3%
1//2// MetalScatterView.swift3// Metrika4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.8//910import MetalKit11import SwiftUI12import ZQGraphics1314/// Metal point-sprite renderer for scatter plots past the Swift Charts15/// threshold (CLAUDE.md §7 pane 4). Unified memory: the packed vertex16/// buffer is written once; pan (scroll) and zoom (pinch or scroll+⌥)17/// only touch a 24-byte uniform.18struct MetalScatterPane: View {19 let plot: ZQPlotSpec2021 var body: some View {22 VStack(spacing: 0) {23 MetalScatterView(plot: plot)24 Divider()25 HStack {26 Text("\(plot.series.reduce(0) { $0 + $1.x.count }) points — Metal renderer")27 Spacer()28 Text("scroll to pan · pinch or ⌥-scroll to zoom · double-click to reset")29 }30 .font(.caption)31 .foregroundStyle(.secondary)32 .padding(6)33 }34 }35}3637struct MetalScatterView: NSViewRepresentable {38 let plot: ZQPlotSpec3940 func makeCoordinator() -> Renderer { Renderer() }4142 func makeNSView(context: Context) -> ScatterMTKView {43 let view = ScatterMTKView()44 view.device = MTLCreateSystemDefaultDevice()45 view.delegate = context.coordinator46 view.renderer = context.coordinator47 view.enableSetNeedsDisplay = true48 view.isPaused = true49 view.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0)50 view.layer?.isOpaque = false51 context.coordinator.attach(view: view)52 context.coordinator.load(plot: plot)53 return view54 }5556 func updateNSView(_ view: ScatterMTKView, context: Context) {57 context.coordinator.load(plot: plot)58 view.needsDisplay = true59 }6061 /// MTKView subclass owning the pan/zoom gestures.62 final class ScatterMTKView: MTKView {63 weak var renderer: Renderer?6465 override func scrollWheel(with event: NSEvent) {66 if event.modifierFlags.contains(.option) {67 renderer?.zoom(by: 1 + event.scrollingDeltaY * 0.01)68 } else {69 renderer?.pan(70 deltaX: event.scrollingDeltaX,71 deltaY: event.scrollingDeltaY,72 viewSize: bounds.size73 )74 }75 needsDisplay = true76 }7778 override func magnify(with event: NSEvent) {79 renderer?.zoom(by: 1 + event.magnification)80 needsDisplay = true81 }8283 override func mouseDown(with event: NSEvent) {84 if event.clickCount == 2 {85 renderer?.resetViewport()86 needsDisplay = true87 }88 }89 }9091 /// Pipeline + vertex buffer + viewport state.92 @MainActor93 final class Renderer: NSObject, MTKViewDelegate {94 private struct Uniforms {95 var center: SIMD2<Float>96 var scale: SIMD2<Float>97 var pointSize: Float98 }99100 private var pipeline: MTLRenderPipelineState?101 private var commandQueue: MTLCommandQueue?102 private var vertexBuffer: MTLBuffer?103 private var pointCount = 0104 private var loadedPlotSignature = 0105106 // Data bounds and viewport state.107 private var dataCenter = SIMD2<Float>(0, 0)108 private var dataHalfSpan = SIMD2<Float>(1, 1)109 private var zoomLevel: Float = 1110 private var panOffset = SIMD2<Float>(0, 0)111 private var aspect: Float = 1112113 func attach(view: MTKView) {114 guard let device = view.device else { return }115 commandQueue = device.makeCommandQueue()116 guard let library = device.makeDefaultLibrary(),117 let vertexFunction = library.makeFunction(name: "scatterVertex"),118 let fragmentFunction = library.makeFunction(name: "scatterFragment")119 else { return }120121 let descriptor = MTLRenderPipelineDescriptor()122 descriptor.vertexFunction = vertexFunction123 descriptor.fragmentFunction = fragmentFunction124 descriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat125 descriptor.colorAttachments[0].isBlendingEnabled = true126 descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha127 descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha128 descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one129 descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha130 pipeline = try? device.makeRenderPipelineState(descriptor: descriptor)131 }132133 /// Rebuilds the vertex buffer when the plot actually changes.134 func load(plot: ZQPlotSpec) {135 let signature = plot.series.reduce(plot.series.count) {136 $0 &* 31 &+ $1.x.count137 }138 guard signature != loadedPlotSignature,139 let device = commandQueue?.device else { return }140 loadedPlotSignature = signature141142 let palette: [SIMD4<UInt8>] = [143 .init(79, 140, 255, 220), .init(255, 149, 0, 220),144 .init(52, 199, 89, 220), .init(175, 82, 222, 220),145 .init(255, 59, 48, 220), .init(90, 200, 250, 220),146 ]147148 var minX = Float.greatestFiniteMagnitude, maxX = -Float.greatestFiniteMagnitude149 var minY = Float.greatestFiniteMagnitude, maxY = -Float.greatestFiniteMagnitude150 var packed = [UInt8]()151 var count = 0152 for (seriesIndex, series) in plot.series.enumerated() {153 let color = palette[seriesIndex % palette.count]154 for i in 0..<series.x.count {155 let x = Float(series.x[i])156 let y = Float(series.y[i])157 guard x.isFinite, y.isFinite else { continue }158 minX = min(minX, x); maxX = max(maxX, x)159 minY = min(minY, y); maxY = max(maxY, y)160 withUnsafeBytes(of: x) { packed.append(contentsOf: $0) }161 withUnsafeBytes(of: y) { packed.append(contentsOf: $0) }162 packed.append(contentsOf: [color.x, color.y, color.z, color.w])163 count += 1164 }165 }166 guard count > 0 else { return }167 pointCount = count168 vertexBuffer = packed.withUnsafeBytes {169 device.makeBuffer(bytes: $0.baseAddress!, length: $0.count)170 }171172 dataCenter = SIMD2((minX + maxX) / 2, (minY + maxY) / 2)173 dataHalfSpan = SIMD2(174 max((maxX - minX) / 2, 1e-9) * 1.05,175 max((maxY - minY) / 2, 1e-9) * 1.05176 )177 resetViewport()178 }179180 func resetViewport() {181 zoomLevel = 1182 panOffset = .zero183 }184185 func zoom(by factor: Double) {186 zoomLevel = min(max(zoomLevel * Float(factor), 0.1), 10_000)187 }188189 func pan(deltaX: Double, deltaY: Double, viewSize: CGSize) {190 guard viewSize.width > 0, viewSize.height > 0 else { return }191 panOffset.x -= Float(deltaX) * 2 * dataHalfSpan.x192 / (zoomLevel * Float(viewSize.width))193 panOffset.y += Float(deltaY) * 2 * dataHalfSpan.y194 / (zoomLevel * Float(viewSize.height))195 }196197 nonisolated func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}198199 nonisolated func draw(in view: MTKView) {200 MainActor.assumeIsolated {201 render(in: view)202 }203 }204205 private func render(in view: MTKView) {206 guard let pipeline,207 let vertexBuffer,208 pointCount > 0,209 let descriptor = view.currentRenderPassDescriptor,210 let drawable = view.currentDrawable,211 let commandBuffer = commandQueue?.makeCommandBuffer(),212 let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)213 else { return }214215 var uniforms = Uniforms(216 center: dataCenter + panOffset,217 scale: SIMD2(218 zoomLevel / dataHalfSpan.x,219 zoomLevel / dataHalfSpan.y220 ),221 pointSize: Float(max(2, 6 - log10(Double(max(pointCount, 10)))))222 )223 encoder.setRenderPipelineState(pipeline)224 encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)225 encoder.setVertexBytes(&uniforms, length: MemoryLayout<Uniforms>.size, index: 1)226 encoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: pointCount)227 encoder.endEncoding()228 commandBuffer.present(drawable)229 commandBuffer.commit()230 }231 }232}233