SPB Git

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%
9.1 KB · 270 lines swift
Raw Blame History
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