Created
September 25, 2025 13:38
-
-
Save ryancoughlin/1c7dc1cd5cdfa8ed6bd9e52179d39e35 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| import MapboxMaps | |
| import Turf | |
| struct MapLayersView: MapContent { | |
| @Bindable var appViewModel: AppViewModel | |
| @Bindable var stationsViewModel: StationsViewModel | |
| @Bindable var globalLayerManager: GlobalLayerManager | |
| var overlayManager: OverlayManager? | |
| var toolsManager: MapToolsManager? | |
| // Optional overrides for split view | |
| let overrideDataset: Dataset? | |
| let overrideEntry: TimeEntry? | |
| let isSplitView: Bool | |
| let isOnline: Bool | |
| let selectedColorscale: Colorscale | |
| let datasetManager: DatasetManager | |
| @State private var appPreferences = AppPreferences.shared | |
| // Computed properties for dataset and entry | |
| private var selectedDataset: Dataset? { | |
| overrideDataset ?? datasetManager.selectedDataset | |
| } | |
| private var selectedEntry: TimeEntry? { | |
| overrideEntry ?? datasetManager.selectedEntry | |
| } | |
| // Default initializer for regular map view | |
| init( | |
| appViewModel: AppViewModel, | |
| stationsViewModel: StationsViewModel, | |
| globalLayerManager: GlobalLayerManager, | |
| overlayManager: OverlayManager, | |
| toolsManager: MapToolsManager, | |
| datasetManager: DatasetManager, | |
| isOnline: Bool, | |
| selectedColorscale: Colorscale | |
| ) { | |
| self.appViewModel = appViewModel | |
| self.stationsViewModel = stationsViewModel | |
| self.globalLayerManager = globalLayerManager | |
| self.overlayManager = overlayManager | |
| self.toolsManager = toolsManager | |
| self.datasetManager = datasetManager | |
| self.overrideDataset = nil | |
| self.overrideEntry = nil | |
| self.isSplitView = false | |
| self.isOnline = isOnline | |
| self.selectedColorscale = selectedColorscale | |
| } | |
| // Split view initializer | |
| init( | |
| appViewModel: AppViewModel, | |
| stationsViewModel: StationsViewModel, | |
| globalLayerManager: GlobalLayerManager, | |
| datasetManager: DatasetManager, | |
| dataset: Dataset?, | |
| entry: TimeEntry?, | |
| isOnline: Bool, | |
| selectedColorscale: Colorscale | |
| ) { | |
| self.appViewModel = appViewModel | |
| self.stationsViewModel = stationsViewModel | |
| self.globalLayerManager = globalLayerManager | |
| self.datasetManager = datasetManager | |
| self.overlayManager = nil | |
| self.toolsManager = nil | |
| self.overrideDataset = dataset | |
| self.overrideEntry = entry | |
| self.isSplitView = true | |
| self.isOnline = isOnline | |
| self.selectedColorscale = selectedColorscale | |
| } | |
| var body: some MapContent { | |
| // 1. Core dataset layers (only when we have valid data) | |
| if let dataset = selectedDataset, | |
| let entry = selectedEntry, | |
| let region = appViewModel.selectedRegion { | |
| renderDatasetLayers(dataset: dataset, entry: entry, region: region) | |
| } | |
| // 2. Overlay dataset layers (only for regular view) | |
| if !isSplitView, | |
| let overlayManager = overlayManager, | |
| overlayManager.hasActiveOverlays { | |
| OverlayLayersContent( | |
| overlayManager: overlayManager, | |
| cacheService: appViewModel.cacheService, | |
| appViewModel: appViewModel, | |
| datasetManager: datasetManager | |
| ) | |
| } | |
| // 3. Global supporting layers (above primitive layers) | |
| GlobalLayers( | |
| preferencesManager: appPreferences, | |
| globalLayerManager: globalLayerManager, | |
| stationsViewModel: stationsViewModel, | |
| tournamentManager: appViewModel.tournamentManager | |
| ) | |
| // 4. Region outline (if region is selected) | |
| if let selectedRegion = appViewModel.selectedRegion { | |
| RegionOutlineView( | |
| polygon: MapRegion.getPolygon(for: selectedRegion), | |
| cacheService: appViewModel.cacheService | |
| ) | |
| } | |
| } | |
| // MARK: - Dataset Layer Rendering | |
| @MapContentBuilder | |
| private func renderDatasetLayers(dataset: Dataset, entry: TimeEntry, region: RegionMetadata) -> some MapContent { | |
| let coordinates = MapRegion.getImageCoordinates(for: region) | |
| let filterState = datasetManager.currentFilter | |
| // 1. Data layer (always mandatory for crosshair reading) | |
| renderDataLayer(entry: entry) | |
| // 2. Visual layer (COG > Image) | |
| renderVisualLayer(dataset: dataset, entry: entry, coordinates: coordinates, filterState: filterState) | |
| // 3. Contour layer (if dataset supports contours and data exists) | |
| if datasetSupportsContours(dataset) && hasContourData(entry) { | |
| renderContourLayer(dataset: dataset, entry: entry) | |
| } | |
| // 4. Special layers (replace standard rendering) | |
| renderSpecialLayers(dataset: dataset, entry: entry) | |
| } | |
| // MARK: - Helper Methods | |
| private func datasetSupportsContours(_ dataset: Dataset) -> Bool { | |
| return dataset.type != .eddys && datasetManager.isLayerEnabled(.contour) | |
| } | |
| private func hasContourData(_ entry: TimeEntry) -> Bool { | |
| return (entry.layers.pmtiles_contours != nil && !entry.layers.pmtiles_contours!.url.isEmpty) || | |
| (entry.layers.contours != nil && !entry.layers.contours!.isEmpty) | |
| } | |
| @MapContentBuilder | |
| private func renderDataLayer(entry: TimeEntry) -> some MapContent { | |
| // STATIC SOURCES - Always create with stable IDs, Mapbox will reuse them | |
| if let pmtilesData = entry.layers.pmtiles_data, !pmtilesData.url.isEmpty, !pmtilesData.source.isEmpty { | |
| // PMTiles available - use it | |
| let martinURL = "\(AppConstants.pmtilesBaseURL)/\(pmtilesData.source)/{z}/{x}/{y}" | |
| let _ = print("๐ [MapLayersView] Using PMTiles data: \(martinURL)") | |
| VectorSource(id: "static-pmtiles-data-source") | |
| .tiles([martinURL]) | |
| .maxzoom(8) | |
| CircleLayer(id: "static-data-layer", source: "static-pmtiles-data-source") | |
| .sourceLayer(pmtilesData.layer) | |
| .circleRadius(1) | |
| .circleOpacity(0) | |
| .slot(.middle) | |
| } else if let dataURL = entry.layers.data, !dataURL.isEmpty { | |
| // PMTiles not available - use GeoJSON fallback | |
| let _ = print("๐ [MapLayersView] Using GeoJSON data fallback: \(dataURL)") | |
| let resolvedURL = appViewModel.cacheService.resolveURL(dataURL) | |
| if let urlObject = URL(string: resolvedURL) { | |
| GeoJSONSource(id: "static-data-source") | |
| .data(.url(urlObject)) | |
| CircleLayer(id: "static-data-layer", source: "static-data-source") | |
| .circleRadius(1) | |
| .circleOpacity(0) | |
| .slot(.middle) | |
| } | |
| } | |
| } | |
| @MapContentBuilder | |
| private func renderVisualLayer(dataset: Dataset, entry: TimeEntry, coordinates: [[Double]], filterState: DatasetFilterState) -> some MapContent { | |
| if !isOnline { | |
| // Offline: Use image | |
| if let imageURL = entry.layers.image, !imageURL.isEmpty { | |
| let resolvedURL = appViewModel.cacheService.resolveURL(imageURL) | |
| ImageSource(id: "static-image-source") | |
| .url(resolvedURL) | |
| .coordinates(coordinates) | |
| RasterLayer(id: "static-image-layer", source: "static-image-source") | |
| .rasterOpacity(1.0) | |
| .rasterFadeDuration(0.3) | |
| .rasterOpacityTransition(StyleTransition(duration: 0.3, delay: 0)) | |
| .slot(.middle) | |
| } | |
| } else { | |
| // Online: COG > Image | |
| if let cogURL = entry.layers.cog, !cogURL.isEmpty, dataset.type != .chlorophyll { | |
| let renderState = toolsManager?.layerRenderManager.renderState(for: dataset.id) | |
| let shouldUseCOG = renderState?.shouldUseCOGTiles ?? true | |
| if shouldUseCOG, let ranges = entry.ranges, let range = ranges[dataset.rangeKey], | |
| let min = range.min, let max = range.max { | |
| let entryRange = min...max | |
| let tileURL = generateCOGTileURL( | |
| cogURL: cogURL, | |
| filterState: filterState, | |
| datasetType: dataset.type, | |
| entryRange: entryRange | |
| ) | |
| if let tileURL = tileURL { | |
| RasterSource(id: "static-cog-source") | |
| .tiles([tileURL]) | |
| .minzoom(0) | |
| .maxzoom(16) | |
| RasterLayer(id: "static-cog-layer", source: "static-cog-source") | |
| .rasterOpacity(1.0) | |
| .rasterFadeDuration(0.3) | |
| .rasterOpacityTransition(StyleTransition(duration: 0.3, delay: 0)) | |
| .slot(.middle) | |
| } | |
| } | |
| } else if let imageURL = entry.layers.image, !imageURL.isEmpty { | |
| // Fallback to image | |
| let resolvedURL = appViewModel.cacheService.resolveURL(imageURL) | |
| ImageSource(id: "static-image-source") | |
| .url(resolvedURL) | |
| .coordinates(coordinates) | |
| RasterLayer(id: "static-image-layer", source: "static-image-source") | |
| .rasterOpacity(1.0) | |
| .rasterFadeDuration(0.3) | |
| .rasterOpacityTransition(StyleTransition(duration: 0.3, delay: 0)) | |
| .slot(.middle) | |
| } | |
| } | |
| } | |
| @MapContentBuilder | |
| private func renderContourLayer(dataset: Dataset, entry: TimeEntry) -> some MapContent { | |
| // STATIC SOURCES - Always create with stable IDs, Mapbox will reuse them | |
| if let pmtilesContours = entry.layers.pmtiles_contours, !pmtilesContours.url.isEmpty, !pmtilesContours.source.isEmpty { | |
| // PMTiles available - use it | |
| let martinURL = "\(AppConstants.pmtilesBaseURL)/\(pmtilesContours.source)/{z}/{x}/{y}" | |
| let _ = print("๐ [MapLayersView] Using PMTiles: \(martinURL)") | |
| VectorSource(id: "static-contour-source") | |
| .tiles([martinURL]) | |
| .maxzoom(8) | |
| let selectedRange = datasetManager.datasetLayerManager.contourRange(for: dataset.type) | |
| let contourState = ContourLayerState( | |
| url: martinURL, | |
| color: datasetManager.datasetLayerManager.contourColor(for: dataset.type), | |
| opacity: datasetManager.datasetLayerManager.contourOpacity(for: dataset.type), | |
| minValue: selectedRange.min, | |
| maxValue: selectedRange.max, | |
| datasetType: dataset.type, | |
| filter: createContourFilterState(for: dataset, entry: entry), | |
| cacheService: appViewModel.cacheService, | |
| ) | |
| ContourLayerFactory.makeLayer(for: dataset.type, state: contourState) | |
| } else if let contoursURL = entry.layers.contours, !contoursURL.isEmpty { | |
| // PMTiles not available - use GeoJSON fallback | |
| let _ = print("๐ [MapLayersView] Using GeoJSON fallback: \(contoursURL)") | |
| let resolvedURL = appViewModel.cacheService.resolveURL(contoursURL) | |
| if let urlObject = URL(string: resolvedURL) { | |
| GeoJSONSource(id: "static-contour-source") | |
| .data(.url(urlObject)) | |
| } | |
| let selectedRange = datasetManager.datasetLayerManager.contourRange(for: dataset.type) | |
| let contourState = ContourLayerState( | |
| url: contoursURL, | |
| color: datasetManager.datasetLayerManager.contourColor(for: dataset.type), | |
| opacity: datasetManager.datasetLayerManager.contourOpacity(for: dataset.type), | |
| minValue: selectedRange.min, | |
| maxValue: selectedRange.max, | |
| datasetType: dataset.type, | |
| filter: createContourFilterState(for: dataset, entry: entry), | |
| cacheService: appViewModel.cacheService, | |
| ) | |
| ContourLayerFactory.makeLayer(for: dataset.type, state: contourState) | |
| } | |
| } | |
| /// Creates contour filter state that syncs with dataset filter | |
| private func createContourFilterState(for dataset: Dataset, entry: TimeEntry) -> ContourFilterState? { | |
| // Get the data range for this dataset | |
| guard let range = dataset.getDataRange(from: entry) else { | |
| return nil | |
| } | |
| // Create reactive contour filter state that syncs with dataset filter | |
| let datasetFilterState = datasetManager.currentFilter | |
| return ContourFilterState.createReactive( | |
| dataRange: range, | |
| datasetFilterState: datasetFilterState | |
| ) | |
| } | |
| @MapContentBuilder | |
| private func renderSpecialLayers(dataset: Dataset, entry: TimeEntry) -> some MapContent { | |
| switch dataset.type { | |
| case .eddys: | |
| if let contoursURL = entry.layers.contours, !contoursURL.isEmpty { | |
| if isSplitView { | |
| // Split view: use contour factory | |
| let selectedRange = datasetManager.datasetLayerManager.contourRange(for: .eddys) | |
| let contourState = ContourLayerState( | |
| url: contoursURL, | |
| color: datasetManager.datasetLayerManager.contourColor(for: .eddys), | |
| opacity: 1.0, | |
| minValue: selectedRange.min, | |
| maxValue: selectedRange.max, | |
| datasetType: .eddys, | |
| filter: nil, | |
| cacheService: appViewModel.cacheService | |
| ) | |
| ContourLayerFactory.makeLayer(for: .eddys, state: contourState) | |
| } else if let featuresViewModel = appViewModel.featureManager.featuresViewModel { | |
| // Regular view: use EddyLayer with features | |
| let selectedRange = datasetManager.datasetLayerManager.contourRange(for: .eddys) | |
| EddyLayer( | |
| cacheService: appViewModel.cacheService, | |
| sshURL: contoursURL, | |
| opacity: 1.0, | |
| minValue: selectedRange.min, | |
| maxValue: selectedRange.max, | |
| color: datasetManager.datasetLayerManager.contourColor(for: .eddys), | |
| featuresViewModel: featuresViewModel | |
| ) | |
| } | |
| } | |
| case .currents: | |
| if let dataURL = entry.layers.data, !dataURL.isEmpty { | |
| CurrentsLayer( | |
| cacheService: appViewModel.cacheService, | |
| dataURL: dataURL, | |
| opacity: 1.0 | |
| ) | |
| } | |
| default: | |
| EmptyMapContent() | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment