← Files Vaisala Xweather API & MapsARCHIVED FILE
skills/mapsgl-apple/references/styles.md
14 KB · Oct 5, 2026 · 18:33 UTC
# MapsGL Apple SDK — paint properties, quality, color scales, filters, masks
A layer's paint object is namespaced by render type, and which namespaces exist is decided by the
layer descriptor. `layers.md` gives the descriptor for every built-in weather layer; the tables below
give the properties for each.
Two rules that apply everywhere:
1. **Data values are always metric.** Color-scale stops, draw ranges, and contour intervals are in
°C, m/s, mm, hPa — regardless of what the app displays. Converting for display is a legend/format
concern, not a paint concern.
2. **Styleable values are `StyleValue<T>`, an enum with two cases.** Write `.constant(x)` for a fixed
value and `.expression(…)` for a data-driven one. There is no bare-value shorthand.
```swift
color: .constant(.red)
color: .expression(Expression.get("COLOR"))
```
## Where paint lives
For a built-in weather layer, mutate the configuration struct before adding it:
```swift
var config = WeatherService.Temperatures(service: controller.service)
config.layer.paint.opacity = 0.5 // paint hangs off `layer`
config.layer.quality = .low // quality is on the descriptor, not on paint
try controller.addWeatherLayer(config: config)
```
For a custom layer, pass paint to the descriptor's initializer:
```swift
let layer = FillLayerDescriptor(
id: "alerts-fill",
source: "alerts",
paint: .init(
fill: .init(color: .expression(Expression.get("COLOR"))),
stroke: .init(color: .constant(.black))
)
)
```
`addWeatherLayer(for:)` accepts no overrides. A customized weather layer must go through
`addWeatherLayer(config:)`.
## Paint by render type
Every layer paint type has `opacity: Opacity`, and **that is the only place opacity lives**. `Opacity`
is `ExpressibleByFloatLiteral`, so `paint.opacity = 0.5` works; `.full`, `.half`, `.none`, `.opaque`,
`.transparent` are provided.
`paint.sample.opacity` does **not** compile — `SamplePaint` has no `opacity` member, even though the
web documentation's override example shows it. Verified against 1.6.1. The render-type namespaces
(`sample`, `particle`, `grid`, `contour`, `raster`) carry data and geometry properties; opacity is
always one level up. `FillPaint` and `StrokePaint` *do* have their own `opacity`, because those are
per-geometry alphas layered on top of the layer opacity.
### `sample` — `SampleFillLayerPaint`
`opacity`, `sample: SamplePaint`. The workhorse for encoded weather grids (68 of the built-in layers).
| `SamplePaint` property | Type | Notes |
|---|---|---|
| `colorScale` | `ColorScaleSpecification` | See "Color scales" below |
| `drawRange` | `(any InclusiveBoundedRange<Double>)?` | Clip which values render — `...2.22`, `10...30` |
| `quality` | `DataQuality` | Per-paint override of the descriptor's `quality` |
| `interpolation` | `InterpolationMode` | `.none`, `.bilinear`, `.biquadratic`, `.bicubic` |
| `smoothing` | `Double` | Post-sample smoothing |
| `offset` | `Double` | Value offset applied before colorizing |
| `channel` | `SampleChannel` | Which color band(s) hold the data — `.r`/`.red`, `.g`, `.b`, `.a`; an `OptionSet` |
| `expression` | `SampleExpression` | How to decode packed channels — `.number`, `.vector`, `.angle`, `.difference`, `.sum`, `.custom` |
| `meld` | `Bool` | Blend across time steps |
| `multiband` | `Bool` | Data spans multiple bands |
`channel`, `expression`, `meld`, and `multiband` describe the *encoding* and are already correct for
built-in layers. Only touch them when building a `SampleLayerDescriptor` over your own encoded source.
### `raster` — `RasterLayerPaint`
`opacity`, `raster: RasterPaint`. `RasterPaint` has no configurable properties — for raster imagery
(satellite, radar imagery) opacity is the lever.
### `particles` — `ParticleLayerPaint`
`opacity`, `sample: SamplePaint`, `particle: ParticlePaint`.
| `ParticlePaint` property | Type | Notes |
|---|---|---|
| `density` | `ParticleDensity?` | `.minimal`, `.low`, `.normal`, `.high`, `.extreme`, `.count` |
| `count` | `Int?` | Explicit particle count; used when `density` is `.count` |
| `kind` | `ParticleKind` | `.circle` or `.bar` |
| `size` | `CGSize` | |
| `speed` | `Double` | Multiplier on the data's own velocity |
| `trails` | `Bool` | |
| `trailsFade` | `Double` | 0–1; lower fades faster |
| `dropRate` | `Double` | How often particles respawn |
| `dropRateBump` | `Double` | Extra respawn pressure in fast flow |
`sample` still applies — it's what colorizes the particles from the underlying speed grid.
Density is the main performance lever for particle layers on device. `.extreme` looks great on a
recent iPhone and will cost frames on older hardware.
### `grid` — `GridLayerPaint`
`opacity`, `sample: SamplePaint`, `grid: GridPaint`, `fill: FillPaint?`, `stroke: StrokePaint?`,
`icon: IconPaint`, `symbol: SymbolPlacementPaint`. Used for wind barbs and arrow grids.
`GridPaint` has one property: `spacing: Double` — screen-space distance between sampled symbols.
Raise it to declutter, lower it for density.
`icon.image` names a registered image; register it with `controller.addImage(id:image:)` first.
### `contour` — `ContourLayerPaint`
`opacity`, `sample: SamplePaint`, `contour: ContourPaint`.
| `ContourPaint` property | Type | Notes |
|---|---|---|
| `interval` | `StyleValue<Double>` | Isoline spacing, in the data's metric units |
| `majorInterval` | `StyleValue<Double>` | Spacing of emphasized lines |
| `width` / `majorWidth` | `StyleValue<Double>` | Line widths |
| `scale` | `StyleValue<Double>` | |
| `offset` | `StyleValue<Double>` | |
The web docs describe `contour` as "coming soon" for the Apple SDK; the type and three built-in
contour layers (`.temperaturesContour`, `.windSpeedsContour`, `.pressureMeanSeaLevelContour`) do exist.
Treat it as supported but less exercised than `sample`.
### `fill` — `FillLayerPaint`
`opacity`, `fill: FillPaint`, `stroke: StrokePaint?`.
| `FillPaint` property | Type |
|---|---|
| `color` | `StyleValue<UIColor>` |
| `opacity` | `StyleValue<Double>` |
| `pattern` | `StyleValue<String>?` — a registered image id |
| `sortKey` | `StyleValue<String>?` |
### `line` — `LineLayerPaint`
`opacity`, `stroke: StrokePaint`.
| `StrokePaint` property | Type |
|---|---|
| `color` | `StyleValue<UIColor>` |
| `opacity` | `StyleValue<Double>` |
| `thickness` | `StyleValue<Double>` |
| `lineJoin` | `StyleValue<StrokePaint.LineJoin>` |
| `lineCap` | `StyleValue<StrokePaint.LineCap>` |
| `miterLimit` | `StyleValue<Double>` |
Note the name: it's `thickness`, not `width`.
### `circle` — `CircleLayerPaint`
`opacity`, `fill: FillPaint`, `stroke: StrokePaint`, `circle: CirclePaint`.
`CirclePaint`: `radius: StyleValue<Double>`, `sortKey: StyleValue<Double>?`.
Radius is the natural place for a data-driven expression — see `expressions.md` for the
magnitude-scaled earthquake example.
### `symbol` — `SymbolLayerPaint`
`opacity`, `fill: FillPaint?`, `stroke: StrokePaint?`, `icon: IconPaint`, `text: [TextPaint]`.
`text` is an **array**, so a symbol can carry several independently styled labels.
| `IconPaint` property | Type |
|---|---|
| `image` | `StyleValue<String>?` — registered image id |
| `atlas` | `IconAtlasPaint?` — sprite sheet |
| `source` | `IconSourcePaint` |
| `type` | `String?` |
| `size` | `StyleValue<Double>?` |
| `fixedSize` | `CGSize?` |
| `anchor` | `StyleValue<Anchor>` |
| `offset` | `StyleValue<AnchorOffset>` |
| `padding` | `StyleValue<Double>` |
| `rotation` | `StyleValue<Double>` |
| `allowOverlap` | `StyleValue<Bool>` |
| `placement` | `SymbolPlacementPaint` |
| `TextPaint` property | Type |
|---|---|
| `value` | `StyleValue<String>` — the label content; usually an expression |
| `size` | `StyleValue<Double>?` |
| `font` | `StyleValue<[String]>?` |
| `weight` | `StyleValue<String>?` |
| `color` / `outlineColor` | `StyleValue<UIColor>` / `StyleValue<UIColor>?` |
| `opacity` | `StyleValue<Double>` |
| `align` | `StyleValue<TextJustification>` |
| `transform` | `StyleValue<TextTransform>` |
| `anchor` / `offset` | `StyleValue<Anchor>` / `StyleValue<AnchorOffset>` |
| `padding` / `rotation` | `StyleValue<Double>` |
| `letterSpacing` / `lineHeight` / `maxWidth` | `StyleValue<Double>?` |
| `allowOverlap` | `StyleValue<Bool>` |
`SymbolPlacementPaint` adds `rotateWithMap: Bool` and `pitchWithMap: Bool` alongside the shared
`allowOverlap`/`anchor`/`offset`/`padding`/`rotation`.
### `heatmap` — `HeatmapLayerPaint`
`opacity`, `heatmap: HeatmapPaint`.
`HeatmapPaint`: `color: StyleValue<UIColor>`, `intensity: StyleValue<Double>`,
`radius: StyleValue<Double>`, `weight: StyleValue<Double>`.
`weight` is where a per-feature expression usually goes — weighting strikes by amperage, quakes by
magnitude.
## Data quality
`quality: DataQuality` on the layer descriptor (and overridable on `SamplePaint`) sets the
level-of-detail the data renders at. **The Apple SDK has four cases:**
| Case | Effect |
|---|---|
| `.low` | Less detail, more smoothing. Good for slowly varying fields like surface pressure |
| `.medium` | |
| `.high` | More detail. Useful for radar and satellite |
| `.exact` | Data tiles 1:1 with the map's zoom. Can significantly affect performance |
The web docs also list `minimal` and `normal`; those are JS-only and **do not exist in Swift** — using
them is a compile error. There is no explicit "default" case: leave `quality` unset to take the
descriptor's default.
Lower quality means fewer tile requests, less memory, and smoother frames on constrained devices —
recommend it for performance. It does **not** reduce session billing (see `sessions.md`).
## Color scales
`paint.sample.colorScale` is a `ColorScaleSpecification`, an enum:
```swift
case colorScale(ColorScaleOptions) // a gradient or stepped scale
case masks([ColorMaskOptions]) // channel masking
```
It also has a convenience initializer, so both of these compile:
```swift
config.layer.paint.sample.colorScale = ColorScaleSpecification(stops: stops)
config.layer.paint.sample.colorScale = .colorScale(options)
```
`ColorScaleOptions`:
| Property | Type | Notes |
|---|---|---|
| `stops` | `[ColorStop]` | `ColorStop(value, cgColor)` — value in metric units |
| `interval` | `Double` | Quantize into steps of this size. Omit/0 for a smooth gradient |
| `interpolate` | `Bool` | `false` gives hard bands between stops |
| `range` | `ClosedRange<Double>?` | Explicit data range; used by bar legends |
| `positions` | `[Double]?` | Normalized stop positions, as an alternative to values |
| `normalized` | `Bool` | Treat stop values as 0–1 rather than data units |
| `isGradient` | `Bool` | Derived |
`ColorStop(_ value: Double, _ color: CGColor)`, and `.fromString("#rrggbb")` builds the color:
```swift
var scale = ColorScaleOptions(stops: [
ColorStop(-17.78, .fromString("#464ab5")), // 0 °F
ColorStop(0.00, .fromString("#6bea99")), // 32 °F
ColorStop(15.56, .fromString("#fdff87")), // 60 °F
ColorStop(37.78, .fromString("#901436")), // 100 °F
])
```
Three presentations from the same stops:
```swift
// smooth gradient — leave interval and interpolate alone
config.layer.paint.sample.colorScale = .colorScale(scale)
// stepped every 5 °F
scale.interval = 2.775
config.layer.paint.sample.colorScale = .colorScale(scale)
// hard categorical bands
scale.interpolate = false
config.layer.paint.sample.colorScale = .colorScale(scale)
```
Comment your stops with the imperial equivalent when the app is US-facing. `-17.78` reads as noise;
`-17.78 // 0 °F` reads as a decision, and it's how the official examples are written.
**`drawRange` clips rather than colors.** To show only freezing temperatures, don't add a transparent
stop — set the range:
```swift
config.layer.paint.sample.drawRange = ...2.22 // ≤ 36 °F only; no lower bound
```
**Custom paint means custom legend for categorical layers.** Bar/color-scale legends re-derive
themselves from the layer's color scale, but point legends can't be inferred and will keep showing the
defaults. See `legends.md`.
## Filters
Vector layer descriptors (`fill`, `line`, `circle`, `symbol`, `heatmap`) have
`filter: Expression?` — features the expression rejects are not rendered at all:
```swift
var layer = CircleLayerDescriptor(id: "big-quakes", source: "earthquakes", paint: paint)
layer.filter = Expression.greaterThan(Expression.get("report.mag"), 4.0)
```
Encoded descriptors (`sample`, `particles`, `grid`, `contour`, `raster`) have no `filter` — use
`paint.sample.drawRange` to restrict them by value instead.
## Masks
Every layer descriptor has `maskConfiguration: LayerMaskConfiguration`, which clips rendering to a
region. Presets cover the common cases:
```swift
config.layer.maskConfiguration = .land // draw only over land
config.layer.maskConfiguration = .water // draw only over water
config.layer.maskConfiguration = .none
```
Spell the type out where there's no context to infer from:
```swift
_ = [LayerMaskConfiguration.none, .land, .water]
```
To clip against your own layers instead, build the configuration from layer references:
```swift
config.layer.maskConfiguration = LayerMaskConfiguration(
layers: [LayerMaskReference(layerId: "my-clip-layer")],
invert: false, // true renders everywhere EXCEPT the referenced geometry
mode: .all // .all = inside every reference; .any = inside at least one
)
```
`LayerMaskReference(layerId:)` names a layer already on the map, so add the clip layer first.
`Mode` has exactly two cases, `.all` and `.any`, and only matters with more than one reference.
`isEnabled` is **get-only** — a configuration is active because it has references, so clear
`maskConfiguration` back to `.none` to turn masking off rather than looking for a toggle.
Read an active mask layer back with `getMaskLayer(_:)`, keyed by `MaskLayerKind` (`.land` / `.water`),
and get the insertion point masks use with `beforeIdForMaskLayers()`. Neither is main-actor-isolated:
```swift
_ = controller.getMaskLayer(.land)
_ = controller.beforeIdForMaskLayers()
```
Sea-surface temperature masked to water, or a temperature field masked to land, is the usual reason to
reach for this.
SHA-256: d395a471fe2e2745a7c5874722898a1f277aeacbe89f57d49f9320db99cc66b1