Created
October 17, 2025 15:36
-
-
Save ryancoughlin/56bec17afff84f71ab641013ff301fc7 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 | |
| /// Renders the primary dataset visualization (data layer, visual layer, contours, special layers) | |
| struct DatasetLayers: MapContent { | |
| let dataset: Dataset | |
| let entry: TimeEntry | |
| let region: RegionMetadata | |
| let renderingState: MapRenderingState | |
| let isOnline: Bool | |
| let eddyFeatures: [OceanFeature] | |
| let cacheService: CacheService | |
| var body: some MapContent { | |
| let regionId = region.id | |
| let coordinates = MapRegion.getImageCoordinates(for: region) | |
| // 1. Data layer (for crosshair reading) | |
| createDataLayer(entry: entry, regionId: regionId) | |
| // 2. Visual layer (COG > Image) | |
| createVisualLayer(dataset: dataset, entry: entry, regionId: regionId, coordinates: coordinates) | |
| // 3. Contour layer (if supported) | |
| if dataset.type != .eddys && renderingState.contoursEnabled && dataset.hasContours && hasContourData(entry) { | |
| createContourLayer(dataset: dataset, entry: entry, regionId: regionId) | |
| } | |
| // 4. Special layers (eddys, currents) | |
| createSpecialLayers(dataset: dataset, entry: entry, regionId: regionId) | |
| } | |
| // MARK: - Helper Methods | |
| 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) | |
| } | |
| // MARK: - Data Layer | |
| @MapContentBuilder | |
| private func createDataLayer(entry: TimeEntry, regionId: String) -> some MapContent { | |
| let sourceId = "data-source-\(regionId)" | |
| let layerId = "data-layer" | |
| if isOnline { | |
| // Online: Prefer PMTiles | |
| if let pmtilesData = entry.layers.pmtiles_data, !pmtilesData.source.isEmpty { | |
| VectorSource(id: sourceId) | |
| .tiles([AppConstants.pmtilesTileURL(from: pmtilesData)]) | |
| .maxzoom(8) | |
| .prefetchZoomDelta(6) | |
| CircleLayer(id: layerId, source: sourceId) | |
| .sourceLayer(pmtilesData.layerName) | |
| .circleRadius(4) | |
| .circleOpacity(0) | |
| .circleColor(StyleColor(.systemRed)) | |
| .slot(.middle) | |
| } else if let dataURL = entry.layers.data, !dataURL.isEmpty { | |
| // Fallback to GeoJSON | |
| let resolvedURL = cacheService.resolveURL(dataURL) | |
| if let urlObject = URL(string: resolvedURL) { | |
| GeoJSONSource(id: sourceId) | |
| .data(.url(urlObject)) | |
| CircleLayer(id: layerId, source: sourceId) | |
| .circleRadius(1) | |
| .circleOpacity(0) | |
| .circleColor(StyleColor(.systemRed)) | |
| .slot(.middle) | |
| } | |
| } | |
| } else { | |
| // Offline: Use cached GeoJSON only (PMTiles require network) | |
| if let dataURL = entry.layers.data, !dataURL.isEmpty { | |
| let resolvedURL = cacheService.resolveURL(dataURL) | |
| if let urlObject = URL(string: resolvedURL) { | |
| GeoJSONSource(id: sourceId) | |
| .data(.url(urlObject)) | |
| CircleLayer(id: layerId, source: sourceId) | |
| .circleRadius(1) | |
| .circleOpacity(0) | |
| .slot(.middle) | |
| } | |
| } | |
| } | |
| } | |
| // MARK: - Visual Layer | |
| @MapContentBuilder | |
| private func createVisualLayer(dataset: Dataset, entry: TimeEntry, regionId: String, coordinates: [[Double]]) -> some MapContent { | |
| let sourceId = "visual-source-\(dataset.id)-\(regionId)-\(entry.id)" | |
| let layerId = "visual-layer-\(dataset.id)-\(regionId)-\(entry.id)" | |
| let renderDecision = computeRenderDecision(dataset: dataset, entry: entry) | |
| switch renderDecision { | |
| case .useImage: | |
| createImageLayer(sourceId: sourceId, layerId: layerId, entry: entry, coordinates: coordinates) | |
| case .useCOG: | |
| createCOGLayer(sourceId: sourceId, layerId: layerId) | |
| case .noVisualLayer: | |
| EmptyMapContent() | |
| } | |
| } | |
| private enum RenderDecision { | |
| case useImage | |
| case useCOG | |
| case noVisualLayer | |
| } | |
| private var datasetsExcludedFromCOG: Set<DatasetType> { | |
| [.chlorophyll, .eddys] | |
| } | |
| private func computeRenderDecision(dataset: Dataset, entry: TimeEntry) -> RenderDecision { | |
| if !isOnline { | |
| return entry.layers.image != nil && !entry.layers.image!.isEmpty ? .useImage : .noVisualLayer | |
| } | |
| if let cogURL = entry.layers.cog, !cogURL.isEmpty, !datasetsExcludedFromCOG.contains(dataset.type) { | |
| return .useCOG | |
| } else if let imageURL = entry.layers.image, !imageURL.isEmpty { | |
| return .useImage | |
| } | |
| return .noVisualLayer | |
| } | |
| @MapContentBuilder | |
| private func createImageLayer(sourceId: String, layerId: String, entry: TimeEntry, coordinates: [[Double]]) -> some MapContent { | |
| if let imageURL = entry.layers.image, !imageURL.isEmpty { | |
| let resolvedURL = cacheService.resolveURL(imageURL) | |
| ImageSource(id: sourceId) | |
| .url(resolvedURL) | |
| .coordinates(coordinates) | |
| RasterLayer(id: layerId, source: sourceId) | |
| .rasterOpacity(1.0) | |
| .slot(.middle) | |
| } | |
| } | |
| @MapContentBuilder | |
| private func createCOGLayer(sourceId: String, layerId: String) -> some MapContent { | |
| if let tileURL = renderingState.cogTileURL { | |
| RasterSource(id: sourceId) | |
| .tiles([tileURL]) | |
| .minzoom(0) | |
| .maxzoom(16) | |
| .prefetchZoomDelta(6) | |
| RasterLayer(id: layerId, source: sourceId) | |
| .rasterOpacity(1.0) | |
| .rasterFadeDuration(0.3) | |
| .rasterOpacityTransition(StyleTransition(duration: 0.3, delay: 0)) | |
| .slot(.middle) | |
| } | |
| } | |
| // MARK: - Contour Layer | |
| @MapContentBuilder | |
| private func createContourLayer(dataset: Dataset, entry: TimeEntry, regionId: String) -> some MapContent { | |
| if let range = renderingState.contourRange, | |
| let color = renderingState.contourColor, | |
| let opacity = renderingState.contourOpacity { | |
| // Compute IDs explicitly at call site | |
| let sourceId = "\(dataset.type.rawValue)-contour-source-\(regionId)" | |
| let layerId = "\(dataset.type.rawValue)-contour-layer-\(regionId)-\(entry.id)" | |
| let contourState = ContourLayerState( | |
| color: color, | |
| opacity: opacity, | |
| valueRange: range.min...range.max, | |
| datasetType: dataset.type, | |
| resolvedURL: cacheService.resolveURL(entry.layers.contours ?? ""), | |
| sourceLayer: entry.layers.pmtiles_contours?.layerName, | |
| sourceId: sourceId, | |
| layerId: layerId | |
| ) | |
| // Create source | |
| if isOnline { | |
| // Online: Prefer PMTiles | |
| if let pmtilesContours = entry.layers.pmtiles_contours, !pmtilesContours.source.isEmpty { | |
| VectorSource(id: contourState.sourceId) | |
| .tiles([AppConstants.pmtilesTileURL(from: pmtilesContours)]) | |
| .maxzoom(8) | |
| .volatile(false) | |
| .prefetchZoomDelta(6) | |
| } else if let contoursURL = entry.layers.contours, !contoursURL.isEmpty { | |
| // Fallback to GeoJSON | |
| let resolvedURL = cacheService.resolveURL(contoursURL) | |
| if let urlObject = URL(string: resolvedURL) { | |
| GeoJSONSource(id: contourState.sourceId) | |
| .data(.url(urlObject)) | |
| } | |
| } | |
| } else { | |
| // Offline: Use cached GeoJSON only (PMTiles require network) | |
| if let contoursURL = entry.layers.contours, !contoursURL.isEmpty { | |
| let resolvedURL = cacheService.resolveURL(contoursURL) | |
| if let urlObject = URL(string: resolvedURL) { | |
| GeoJSONSource(id: contourState.sourceId) | |
| .data(.url(urlObject)) | |
| } | |
| } | |
| } | |
| // Render appropriate contour layer | |
| switch dataset.type { | |
| case .sst: | |
| SSTContourLayer(state: contourState) | |
| case .salinity: | |
| SalinityContourLayer(state: contourState) | |
| case .mld: | |
| MLDContourLayer(state: contourState) | |
| default: | |
| EmptyMapContent() | |
| } | |
| } | |
| } | |
| // MARK: - Special Layers | |
| @MapContentBuilder | |
| private func createSpecialLayers(dataset: Dataset, entry: TimeEntry, regionId: String) -> some MapContent { | |
| switch dataset.type { | |
| case .eddys: | |
| if let contoursURL = entry.layers.contours, !contoursURL.isEmpty, | |
| let range = renderingState.contourRange, | |
| let color = renderingState.contourColor { | |
| // Compute IDs explicitly at call site | |
| let sourceId = "eddys-contour-source-\(regionId)" | |
| let layerId = "eddys-contour-layer-\(regionId)-\(entry.id)" | |
| let contourState = ContourLayerState( | |
| color: color, | |
| opacity: 1.0, | |
| valueRange: range.min...range.max, | |
| datasetType: .eddys, | |
| resolvedURL: cacheService.resolveURL(contoursURL), | |
| sourceLayer: entry.layers.pmtiles_contours?.layerName, | |
| sourceId: sourceId, | |
| layerId: layerId | |
| ) | |
| // Create source | |
| if isOnline { | |
| // Online: Prefer PMTiles | |
| if let pmtilesContours = entry.layers.pmtiles_contours, !pmtilesContours.source.isEmpty { | |
| VectorSource(id: contourState.sourceId) | |
| .tiles([AppConstants.pmtilesTileURL(from: pmtilesContours)]) | |
| .maxzoom(8) | |
| .volatile(false) | |
| .prefetchZoomDelta(6) | |
| } else if !contoursURL.isEmpty { | |
| // Fallback to GeoJSON | |
| let resolvedURL = cacheService.resolveURL(contoursURL) | |
| if let urlObject = URL(string: resolvedURL) { | |
| GeoJSONSource(id: contourState.sourceId) | |
| .data(.url(urlObject)) | |
| } | |
| } | |
| } else { | |
| // Offline: Use cached GeoJSON only (PMTiles require network) | |
| if !contoursURL.isEmpty { | |
| let resolvedURL = cacheService.resolveURL(contoursURL) | |
| if let urlObject = URL(string: resolvedURL) { | |
| GeoJSONSource(id: contourState.sourceId) | |
| .data(.url(urlObject)) | |
| } | |
| } | |
| } | |
| EddyContourLayer(state: contourState) | |
| // Render eddy features | |
| EddyLayer( | |
| cacheService: cacheService, | |
| sshURL: contoursURL, | |
| opacity: 1.0, | |
| minValue: range.min, | |
| maxValue: range.max, | |
| color: color, | |
| entryId: entry.id, | |
| datasetId: nil, | |
| regionId: regionId, | |
| pmtilesData: entry.layers.pmtiles_contours, | |
| features: eddyFeatures | |
| ) | |
| } | |
| case .currents: | |
| if isOnline { | |
| // Online: Prefer PMTiles | |
| if let pmtilesData = entry.layers.pmtiles_data, !pmtilesData.url.isEmpty, !pmtilesData.source.isEmpty { | |
| CurrentsLayer( | |
| cacheService: cacheService, | |
| pmtilesData: pmtilesData, | |
| dataURL: nil, | |
| opacity: 1.0, | |
| entryId: entry.id, | |
| datasetId: nil | |
| ) | |
| } else if let dataURL = entry.layers.data, !dataURL.isEmpty { | |
| // Fallback to GeoJSON | |
| CurrentsLayer( | |
| cacheService: cacheService, | |
| pmtilesData: nil, | |
| dataURL: dataURL, | |
| opacity: 1.0, | |
| entryId: entry.id, | |
| datasetId: nil | |
| ) | |
| } | |
| } else { | |
| // Offline: Use cached GeoJSON only (PMTiles require network) | |
| if let dataURL = entry.layers.data, !dataURL.isEmpty { | |
| CurrentsLayer( | |
| cacheService: cacheService, | |
| pmtilesData: nil, | |
| dataURL: dataURL, | |
| opacity: 1.0, | |
| entryId: entry.id, | |
| datasetId: nil | |
| ) | |
| } | |
| } | |
| default: | |
| EmptyMapContent() | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment