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// DataBrowserView.swift3// Metrika4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.8//910import AppKit11import SwiftUI12import ZQData1314/// Data browser pane (CLAUDE.md §7, pane 3): virtualized grid over the15/// working dataset via an NSTableView bridge (SwiftUI Table degrades past16/// ~100k rows), sortable columns, and a filter bar that compiles to an17/// `if` expression through the real parser.18struct DataBrowserPane: View {19 @Environment(SessionModel.self) private var model20 @State private var filterText = ""21 @State private var filterError: String?22 @State private var filteredRows: [Int]?23 @State private var sortColumn: String?24 @State private var sortAscending = true2526 var body: some View {27 VStack(spacing: 0) {28 HStack(spacing: 8) {29 Image(systemName: "line.3.horizontal.decrease.circle")30 .foregroundStyle(.secondary)31 TextField(32 "Filter — e.g. revenue > 100 & region == 2",33 text: $filterText34 )35 .textFieldStyle(.plain)36 .fontDesign(.monospaced)37 .onSubmit { Task { await applyFilter() } }38 if filteredRows != nil {39 Button("Clear") {40 filterText = ""41 filteredRows = nil42 filterError = nil43 }44 .controlSize(.small)45 }46 }47 .padding(8)4849 if let filterError {50 Text(filterError)51 .font(.caption)52 .foregroundStyle(.red)53 .frame(maxWidth: .infinity, alignment: .leading)54 .padding(.horizontal, 10)55 .padding(.bottom, 4)56 }5758 Divider()5960 DataGrid(61 frame: model.frame,62 rows: visibleRows,63 sortColumn: $sortColumn,64 sortAscending: $sortAscending65 )6667 Divider()68 Text(statusLine)69 .font(.caption)70 .foregroundStyle(.secondary)71 .frame(maxWidth: .infinity, alignment: .leading)72 .padding(6)73 }74 }7576 private var statusLine: String {77 let total = model.frame.rowCount78 if let filteredRows {79 return "\(filteredRows.count) of \(total) observations"80 }81 return "\(total) observations, \(model.frame.columns.count) variables"82 }8384 /// Filtered (or all) row indices, in the current sort order.85 private var visibleRows: [Int] {86 let base = filteredRows ?? Array(0..<model.frame.rowCount)87 guard let sortColumn,88 let column = model.frame.column(named: sortColumn) else {89 return base90 }91 switch column.data {92 case .float64(let values, let missing):93 // Missing values sort last regardless of direction.94 return base.sorted { a, b in95 if missing[a] != missing[b] { return missing[b] }96 if missing[a] { return false }97 return sortAscending ? values[a] < values[b] : values[a] > values[b]98 }99 case .string(let values):100 return base.sorted { a, b in101 switch (values[a], values[b]) {102 case (nil, nil): return false103 case (nil, _): return false104 case (_, nil): return true105 case (let x?, let y?):106 return sortAscending ? x < y : x > y107 }108 }109 }110 }111112 private func applyFilter() async {113 let text = filterText.trimmingCharacters(in: .whitespaces)114 guard !text.isEmpty else {115 filteredRows = nil116 filterError = nil117 return118 }119 switch await model.filterMask(text) {120 case .success(let mask):121 filteredRows = (0..<mask.count).filter { mask[$0] }122 filterError = nil123 case .failure(let error):124 filterError = error.description125 }126 }127}128129/// NSTableView bridge. NSTableView virtualizes rows natively, so scrolling130/// stays smooth regardless of dataset size; cells are plain reused131/// NSTextFields indexing directly into the columnar buffers.132private struct DataGrid: NSViewRepresentable {133 let frame: ZQDataFrame134 let rows: [Int]135 @Binding var sortColumn: String?136 @Binding var sortAscending: Bool137138 func makeCoordinator() -> Coordinator {139 Coordinator(self)140 }141142 func makeNSView(context: Context) -> NSScrollView {143 let table = NSTableView()144 table.usesAlternatingRowBackgroundColors = true145 table.allowsColumnReordering = false146 table.rowHeight = 20147 table.dataSource = context.coordinator148 table.delegate = context.coordinator149 context.coordinator.table = table150151 let scroll = NSScrollView()152 scroll.documentView = table153 scroll.hasVerticalScroller = true154 scroll.hasHorizontalScroller = true155 return scroll156 }157158 func updateNSView(_ scroll: NSScrollView, context: Context) {159 let coordinator = context.coordinator160 coordinator.parent = self161 guard let table = coordinator.table else { return }162163 // Rebuild columns only when the variable list changes.164 let names = frame.columnNames165 if coordinator.columnNames != names {166 coordinator.columnNames = names167 for column in table.tableColumns.reversed() {168 table.removeTableColumn(column)169 }170 let indexColumn = NSTableColumn(171 identifier: NSUserInterfaceItemIdentifier("__obs")172 )173 indexColumn.title = ""174 indexColumn.width = 52175 table.addTableColumn(indexColumn)176 for name in names {177 let column = NSTableColumn(178 identifier: NSUserInterfaceItemIdentifier(name)179 )180 column.title = name181 column.width = 110182 column.sortDescriptorPrototype = NSSortDescriptor(183 key: name, ascending: true184 )185 table.addTableColumn(column)186 }187 }188 table.reloadData()189 }190191 @MainActor192 final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate {193 var parent: DataGrid194 weak var table: NSTableView?195 var columnNames: [String] = []196197 init(_ parent: DataGrid) {198 self.parent = parent199 }200201 func numberOfRows(in tableView: NSTableView) -> Int {202 parent.rows.count203 }204205 func tableView(206 _ tableView: NSTableView,207 viewFor tableColumn: NSTableColumn?,208 row: Int209 ) -> NSView? {210 guard let tableColumn, row < parent.rows.count else { return nil }211 let identifier = tableColumn.identifier212 let field: NSTextField213 if let reused = tableView.makeView(214 withIdentifier: identifier, owner: self215 ) as? NSTextField {216 field = reused217 } else {218 field = NSTextField(labelWithString: "")219 field.identifier = identifier220 field.font = .monospacedSystemFont(ofSize: 11, weight: .regular)221 field.lineBreakMode = .byTruncatingTail222 }223224 let dataRow = parent.rows[row]225 if identifier.rawValue == "__obs" {226 field.stringValue = "\(dataRow + 1)"227 field.textColor = .secondaryLabelColor228 return field229 }230 guard let column = parent.frame.column(named: identifier.rawValue) else {231 field.stringValue = ""232 return field233 }234 switch column.data {235 case .float64(let values, let missing):236 if missing[dataRow] {237 field.stringValue = "."238 field.textColor = .secondaryLabelColor239 } else {240 field.stringValue = Self.format(values[dataRow])241 field.textColor = .labelColor242 }243 field.alignment = .right244 case .string(let values):245 field.stringValue = values[dataRow] ?? ""246 field.textColor = .labelColor247 field.alignment = .left248 }249 return field250 }251252 func tableView(253 _ tableView: NSTableView,254 sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]255 ) {256 guard let descriptor = tableView.sortDescriptors.first,257 let key = descriptor.key else { return }258 parent.sortColumn = key259 parent.sortAscending = descriptor.ascending260 }261262 static func format(_ value: Double) -> String {263 if value == value.rounded(), abs(value) < 1e15 {264 return String(Int64(value))265 }266 return String(format: "%.6g", value)267 }268 }269}270