wandres.dev
DIBUJO Y GRÁFICOS · Canvas, Shape, Charts

Swift Charts: gráficas declarativas y honestas

La gramática de marcas de Swift Charts: tipos de mark y codificaciones visuales, escalas y dominios, personalización de ejes y leyendas, selección e interacción con el proxy, y la accesibilidad de los datos mediante audio graphs.

⏱ 18 min

Swift Charts no es una biblioteca de gráficos con plantillas: es una implementación de la gramática de gráficos, el mismo marco teórico que sostiene a ggplot2 y Vega. Su tesis es que una visualización se descompone en datos, marcas geométricas y codificaciones que asignan variables a canales visuales —posición, color, tamaño, símbolo—, y que todo lo demás, ejes y leyendas incluidos, se deduce. Trabajar dentro de esa gramática cambia el modo de pensar: dejas de preguntarte qué componente pinta barras y empiezas a preguntarte qué variable merece el canal perceptual más preciso, que es exactamente la pregunta que separa una gráfica correcta de una que engaña.

🎯 Al terminar esta lección sabrás
  • Construir gráficas componiendo marcas y codificaciones sobre datos conformes a Plottable.
  • Controlar escalas y dominios para que la representación no distorsione la magnitud.
  • Personalizar ejes y leyendas con la API de AxisMarks sin perder adaptabilidad.
  • Añadir selección e interacción y exponer los datos a tecnologías de asistencia.

La gramática de marcas

Un gráfico se declara como una colección de marcas, y cada marca asigna variables de tus datos a canales visuales mediante value:

struct Medicion: Identifiable {
    let id = UUID()
    let dia: Date
    let sensor: String
    let valor: Double
}

Chart(mediciones) { m in
    LineMark(
        x: .value("Dia", m.dia, unit: .day),
        y: .value("Temperatura", m.valor)
    )
    .foregroundStyle(by: .value("Sensor", m.sensor))
    .symbol(by: .value("Sensor", m.sensor))
    .interpolationMethod(.monotone)

    RuleMark(y: .value("Umbral", 30))
        .foregroundStyle(.red.opacity(0.5))
        .lineStyle(StrokeStyle(dash: [4, 4]))
        .annotation(position: .top, alignment: .leading) {
            Text("Umbral critico").font(.caption2)
        }
}

El catálogo cubre barras, líneas, puntos, áreas, reglas, rectángulos y sectores, y todas se pueden superponer en el mismo gráfico porque comparten el mismo sistema de coordenadas. Los modificadores de codificación son la parte sustantiva: foregroundStyle por variable produce series separadas y leyenda automática, position por variable agrupa o apila, y symbol añade una segunda codificación redundante que es lo que hace legible un gráfico multiserie en escala de grises o para quien no distingue rojo y verde.

📊

Marcas

Barras, lineas, puntos, areas, reglas, rectangulos y sectores conviven en el mismo sistema de coordenadas.

🎨

Codificaciones

foregroundStyle, position y symbol por variable generan series, agrupaciones y leyenda automatica.

🏷️

Anotaciones

annotation coloca vistas SwiftUI completas ancladas a una marca, con resolucion de desbordamiento.

La elección del canal no es estética. La precisión perceptual del ojo humano ordena los canales de forma estable: posición sobre una escala común es lo más preciso, después longitud, después ángulo y área, y al final color e intensidad. La variable que más importa merece el canal más preciso, y por eso una serie temporal se lee mejor como línea que como sectores, por mucho que estos últimos resulten vistosos.

💡
Plottable evita el stringly typed

Cualquier tipo propio puede conformar a Plottable para actuar como valor de un eje. Un enumerado de categorías conforme a Plottable te da orden estable en la escala, autocompletado y errores en compilación en lugar de cadenas sueltas repartidas por el código.

Escalas, ejes y leyendas

Por defecto Swift Charts infiere el dominio a partir de los datos, y esa inferencia es correcta para explorar pero peligrosa para comunicar: un eje que empieza en el mínimo observado exagera visualmente diferencias pequeñas. Fijar el dominio es una decisión editorial explícita.

Chart(ventas) { v in
    BarMark(x: .value("Mes", v.mes, unit: .month),
            y: .value("Ingresos", v.ingresos))
}
.chartYScale(domain: 0...50_000)
.chartXAxis {
    AxisMarks(values: .stride(by: .month)) { valor in
        AxisGridLine()
        AxisTick()
        AxisValueLabel(format: .dateTime.month(.narrow))
    }
}
.chartYAxis {
    AxisMarks(position: .leading) { valor in
        AxisGridLine()
        AxisValueLabel {
            if let n = valor.as(Double.self) {
                Text(n, format: .number.notation(.compactName))
            }
        }
    }
}
.chartLegend(position: .bottom, alignment: .leading, spacing: 8)

AxisMarks recorre valores del eje y deja construir por separado la línea de rejilla, la marca y la etiqueta, con acceso al valor concreto para formatearlo. La regla de oro es preferir escalas y formatos declarados a cadenas precalculadas, porque el formateo de fechas y números depende del idioma, del calendario y del tamaño disponible, y todo eso lo resuelve el sistema si le dejas.

flowchart LR
A[Datos del dominio] --> B[Marcas geometricas]
B --> C[Codificacion en canales visuales]
C --> D[Escalas y dominios explicitos]
D --> E[Ejes y leyendas derivados]
E --> F[Interaccion y accesibilidad]
style C fill:#a6e3a1,color:#11111b
style D fill:#fab387,color:#11111b

Interacción y desplazamiento

La selección declarativa cubre el caso habitual sin tocar gestos: enlaza el valor seleccionado del eje y dibuja una marca de énfasis a partir de él.

@State private var diaSeleccionado: Date?

Chart(mediciones) { m in
    LineMark(x: .value("Dia", m.dia), y: .value("Valor", m.valor))

    if let d = diaSeleccionado, let punto = medicion(en: d) {
        RuleMark(x: .value("Seleccion", d))
            .foregroundStyle(.secondary)
            .annotation(position: .topTrailing, overflowResolution: .init(x: .fitToPlot)) {
                Text(punto.valor, format: .number.precision(.fractionLength(1)))
                    .padding(6)
                    .background(.regularMaterial, in: .rect(cornerRadius: 8))
            }
    }
}
.chartXSelection(value: $diaSeleccionado)
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: 3600 * 24 * 30)

Cuando necesitas control fino —un gesto propio, un cursor que sigue el dedo, una zona rectangular— la vía es chartOverlay, que entrega un proxy capaz de convertir coordenadas de pantalla a valores del dominio y viceversa. Ese proxy es la frontera entre el mundo de píxeles y el mundo de datos, y usarlo evita el error clásico de calcular posiciones a mano y que se descuadren al cambiar el tamaño o el dominio. Para series largas, la combinación de ejes desplazables con un dominio visible acotado mantiene el rendimiento porque solo se rasteriza la ventana visible.

Accesibilidad de los datos

Una gráfica es información, y si esa información solo existe como píxeles queda fuera del alcance de quien no ve la pantalla. Swift Charts está preparado para resolverlo con muy poco código: al anotar cada marca con etiqueta y valor accesibles, el sistema construye automáticamente una representación navegable y, en iOS, un audio graph que sonifica la serie mediante tonos cuya frecuencia sigue el valor.

BarMark(x: .value("Mes", v.mes), y: .value("Ingresos", v.ingresos))
    .accessibilityLabel(v.mes.formatted(.dateTime.month(.wide)))
    .accessibilityValue(v.ingresos.formatted(.currency(code: "EUR")))

Complétalo con dos medidas: una descripción global del gráfico como etiqueta de la vista contenedora, que resuma tendencia y rango en una frase, y una alternativa tabular alcanzable desde la interfaz, porque hay usuarios para los que una tabla ordenada es sencillamente más útil que cualquier visualización. Añade también soporte para tipografía dinámica en las etiquetas de ejes y comprueba que el gráfico sigue siendo legible con el tamaño de texto más grande.

Toda gráfica es un argumento, y el eje es su premisa oculta

Al escribir código de visualización es fácil creer que el trabajo consiste en representar los datos fielmente, como si la fidelidad fuese una propiedad automática de dibujar lo que hay. No lo es. Cada decisión que tomas —dónde empieza el eje, si el color codifica categoría o magnitud, si interpolas entre puntos que no fueron medidos, si apilas o agrupas— es una afirmación sobre qué comparación es la relevante. Un eje truncado convierte un ruido del dos por ciento en una montaña; una interpolación suave entre muestras dispersas inventa datos que nunca existieron; un gradiente continuo aplicado a categorías sugiere un orden inexistente. La gramática de marcas es valiosa precisamente porque hace explícitas esas decisiones en el código, donde se pueden revisar y discutir, en lugar de dejarlas escondidas en una plantilla. Escribir gráficas es, en el fondo, un ejercicio de honestidad intelectual con firma en tu repositorio.

⚔️ De los datos al argumento
  1. Dibuja una serie temporal con LineMark y añade una segunda serie codificada por color y por símbolo a la vez.
  2. Compara la misma gráfica con dominio inferido y con dominio fijado desde cero, y escribe qué comparación favorece cada una.
  3. Personaliza el eje horizontal con AxisMarks y un formato de fecha declarado, y comprueba el resultado cambiando el idioma del dispositivo.
  4. Añade selección con chartXSelection y una anotación que muestre el valor exacto del punto elegido.
  5. Anota las marcas con etiqueta y valor accesibles y escucha el audio graph con VoiceOver activado.