Skip to content

Instantly share code, notes, and snippets.

@ryancoughlin
Created September 25, 2025 13:38
Show Gist options
  • Save ryancoughlin/1c7dc1cd5cdfa8ed6bd9e52179d39e35 to your computer and use it in GitHub Desktop.
Save ryancoughlin/1c7dc1cd5cdfa8ed6bd9e52179d39e35 to your computer and use it in GitHub Desktop.
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