Flighty : Visualisation des données, bien faite

Pourquoi Flighty a remporté l'Apple Design Award 2023 : 15 états intelligents, Live Activities et visualisation de données inspirée de l'aéroport. Avec motifs d'implémentation SwiftUI.

6 min de lecture 1097 mots
Flighty : Visualisation des données, bien faite screenshot

Flighty : La visualisation de données bien faite

« Nous voulons que Flighty fonctionne si bien que cela semble presque évidemment banal. »

Flighty transforme des données de vol complexes en informations consultables d'un coup d'œil. Lauréat de l'Apple Design Award 2023 pour l'Interaction, c'est une masterclass dans la présentation de données denses à travers un design calme et clair, inspiré de décennies de signalétique aéroportuaire.


Pourquoi Flighty est important

Fondé par Ryan Jones, ancien employé d'Apple, après un retard de vol frustrant, Flighty est devenu la référence absolue pour la visualisation de données sur iOS.

Réalisations clés : - Apple Design Award 2023 (Interaction) - Finaliste de l'App iPhone de l'Année 2023 - Première application à exploiter pleinement les Live Activities - 15 états intelligents contextuels - Prédictions de retards leaders du secteur


Points clés à retenir

  1. S'inspirer de systèmes éprouvés - Les tableaux de départ d'aéroport ont plus de 50 ans d'optimisation ; Flighty adapte ce langage visuel pour le mobile
  2. Les états contextuels réduisent la charge cognitive - 15 états intelligents garantissent que les utilisateurs voient exactement ce dont ils ont besoin à chaque phase du voyage
  3. « Évidemment banal » est l'objectif - Si l'application fonctionne simplement sans friction, vous avez réussi
  4. Condensez, enveloppez et colorez vos données - Présentez des insights, pas des données brutes ; codez les statuts par couleur pour éviter aux utilisateurs d'interpréter
  5. Offline-first pour la connectivité peu fiable - Les vols passent hors ligne ; l'application doit fonctionner sans connexion, particulièrement en vol

Philosophie de design fondamentale

La signalétique aéroportuaire comme langage de design

Le design de Flighty repose sur une analogie du monde réel : les tableaux de départ d'aéroport.

AIRPORT DEPARTURE BOARD (50+ years of refinement)
───────────────────────────────────────────────────────────────────
FLIGHT   DESTINATION      GATE    TIME    STATUS
UA 1234  San Francisco    B22     14:30   ON TIME
DL 567   New York JFK     C15     15:00   DELAYED
AA 890   Chicago O'Hare   A08     15:45   BOARDING

One line per flight. Essential info only. Decades of optimization.

FLIGHTY'S TRANSLATION
───────────────────────────────────────────────────────────────────
[Flight card with same information density]
[Color-coded status]
[Time-appropriate details]

Insight clé : « Ces tableaux d'aéroport ont une ligne par vol, et c'est un bon fil conducteur — ils ont eu 50 ans pour déterminer ce qui est important. »

Conscience du contexte de voyage

Flighty comprend que voyager est stressant. Le design doit réduire la charge cognitive, pas l'augmenter.

Principes de design : 1. L'information la plus importante au-dessus de la ligne de flottaison 2. Divulgation progressive pour les détails 3. Statut codé par couleur (vert = bon, jaune = attention, rouge = problème) 4. Les données sont « condensées, enveloppées et colorées » pour éviter aux utilisateurs d'interpréter


Bibliothèque de patterns

1. Les 15 états intelligents

L'innovation signature de Flighty : 15 états contextuels qui montrent exactement ce dont vous avez besoin, exactement quand vous en avez besoin.

Chronologie des états :

24 HOURS BEFORE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO                           │
│ Tomorrow at 2:30 PM                     │
│ Confirmation: ABC123                    │
│ Head to Terminal 2                      │
└─────────────────────────────────────────┘

3 HOURS BEFORE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO                           │
│ Gate B22 • Boards 2:00 PM               │
│ Leave for airport by 12:15 PM           │
│ [View full timeline →]                  │
└─────────────────────────────────────────┘

AT GATE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO     [ON TIME]            │
│ Gate B22 • Boarding in 12 min           │
│ Seat 14A • Window                       │
│ [Your plane is here ✓]                  │
└─────────────────────────────────────────┘

WALKING TO PLANE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO     [BOARDING]           │
│ Your seat: 14A (Window)                 │
│ ▓▓▓▓▓▓▓▓░░ 80% boarded                 │
└─────────────────────────────────────────┘

IN FLIGHT (offline)
┌─────────────────────────────────────────┐
│  SFO ──────●───────────── JFK           │
│            │                            │
│     2h 15m remaining                    │
│     Arrive ~5:45 PM local               │
└─────────────────────────────────────────┘

LANDED
┌─────────────────────────────────────────┐
│ ✓ Arrived at JFK                        │
│ Your gate: C15 → Connection gate: D22   │
│ 8 min walk • Terminal map [→]          │
└─────────────────────────────────────────┘

Concept d'implémentation :

enum FlightState: CaseIterable {
    case farOut          // > 24 hours
    case dayBefore       // 24 hours
    case headToAirport   // ~3 hours
    case atAirport       // At terminal
    case atGate          // Gate area
    case boarding        // Boarding started
    case onBoard         // Seated
    case taxiing         // Moving to runway
    case inFlight        // Airborne
    case descending      // Approaching
    case landed          // Touched down
    case atGateDest      // Arrived at gate
    case connection      // Connecting flight
    case completed       // Trip done
    case delayed         // Any delay state
}

struct SmartStateEngine {
    func currentState(for flight: Flight, context: TravelContext) -> FlightState {
        let now = Date()
        let departure = flight.scheduledDeparture

        // Contextual factors
        let isAtAirport = context.currentLocation.isNear(flight.departureAirport)
        let isBoarding = flight.boardingStatus == .active
        let isAirborne = flight.status == .inFlight

        switch true {
        case isAirborne:
            return flight.altitude > 10000 ? .inFlight : .taxiing
        case isBoarding && isAtAirport:
            return .boarding
        case isAtAirport && departure.timeIntervalSinceNow < 3600:
            return .atGate
        case isAtAirport:
            return .atAirport
        case departure.timeIntervalSinceNow < 3 * 3600:
            return .headToAirport
        case departure.timeIntervalSinceNow < 24 * 3600:
            return .dayBefore
        default:
            return .farOut
        }
    }

    func displayContent(for state: FlightState, flight: Flight) -> StateContent {
        switch state {
        case .headToAirport:
            return StateContent(
                headline: "\(flight.number)\(flight.destination.code)",
                primary: "Gate \(flight.gate) • Boards \(flight.boardingTime.formatted())",
                secondary: "Leave for airport by \(flight.recommendedDeparture.formatted())",
                action: "View full timeline"
            )
        case .inFlight:
            return StateContent(
                headline: flight.routeVisualization,
                primary: "\(flight.remainingTime.formatted()) remaining",
                secondary: "Arrive ~\(flight.estimatedArrival.formatted())",
                action: nil
            )
        // ... other states
        }
    }
}

2. Live Activities et Dynamic Island

Flighty a été la première application à exploiter pleinement les Live Activities d'iOS 16, établissant ainsi la référence pour cette fonctionnalité.

États du Dynamic Island :

COMPACT (Minimal)
╭──────────────────────────────────╮
│ [^] B22  │  *  │  2:30 -> 1h 45m │
╰──────────────────────────────────╯
  Gate      Status    Time/Duration

EXPANDED (Long press)
╭────────────────────────────────────────╮
│  UA 1234 to San Francisco              │
│  ───────────────────────────────────   │
│  Gate B22        Boards 2:00 PM        │
│  Seat 14A        On Time ✓             │
│                                        │
│  [Open Flighty]     [Share ETA]        │
╰────────────────────────────────────────╯

IN-FLIGHT (Offline capable)
╭──────────────────────────────────╮
│  SFO ●════════○──── JFK          │
│         2h 15m                   │
╰──────────────────────────────────╯

Pattern d'implémentation SwiftUI :

import ActivityKit
import WidgetKit

struct FlightActivityAttributes: ActivityAttributes {
    public typealias ContentState = FlightStatus

    let flightNumber: String
    let origin: Airport
    let destination: Airport
    let gate: String
    let seat: String

    struct FlightStatus: Codable, Hashable {
        let departureTime: Date
        let arrivalTime: Date
        let status: Status
        let boardingStatus: BoardingStatus
        let progress: Double  // 0.0 to 1.0 for in-flight
        let phase: FlightPhase

        enum Status: String, Codable { case onTime, delayed, cancelled }
        enum BoardingStatus: String, Codable { case notStarted, boarding, closed }
        enum FlightPhase: String, Codable { case preBoarding, boarding, taxiing, inFlight, landed }
    }
}

struct FlightLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: FlightActivityAttributes.self) { context in
            // Vue écran de verrouillage
            LockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // Vue étendue
                DynamicIslandExpandedRegion(.leading) {
                    VStack(alignment: .leading) {
                        Text(context.attributes.flightNumber)
                            .font(.headline)
                        Text("to \(context.attributes.destination.city)")
                            .font(.caption)
                    }
                }
                DynamicIslandExpandedRegion(.trailing) {
                    VStack(alignment: .trailing) {
                        Text("Gate \(context.attributes.gate)")
                        Text(context.state.status.displayText)
                            .foregroundStyle(context.state.status.color)
                    }
                }
                DynamicIslandExpandedRegion(.bottom) {
                    if context.state.phase == .inFlight {
                        FlightProgressBar(progress: context.state.progress)
                    } else {
                        Text("Boards \(context.state.boardingTime.formatted())")
                    }
                }
            } compactLeading: {
                // Côté gauche compact
                Text(context.attributes.gate)
                    .font(.caption)
            } compactTrailing: {
                // Côté droit compact
                Text(context.state.departureTime, style: .timer)
            } minimal: {
                // Minimal (quand plusieurs activités)
                Image(systemName: "airplane")
            }
        }
    }
}

3. Visualisation de la progression du vol

Flighty affiche la progression du vol à travers plusieurs métaphores visuelles.

Ligne de route avec indicateur d'avion :

PROGRESSION LINÉAIRE (Principale)
───────────────────────────────────────────────────────────────────
SFO ════════════════●════════════════════════════════════ JFK
    [Décollé il y a 45min]    ↑ Vous êtes ici     [2h 15min restantes]

PROGRESSION CIRCULAIRE (Compacte)
        ╭───────╮
       /    ●    \
      │     |     │    ← L'arc de progression se remplit dans le sens horaire
      │     |     │
       \    ↓    /     2h 15min restantes
        ╰───────╯

CARTE THERMIQUE (Statistiques globales)
[Carte avec des lignes de route d'épaisseurs variées]
Ligne épaisse = itinéraire fréquemment emprunté
Ligne fine = emprunté une seule fois

Implémentation SwiftUI :

struct FlightProgressView: View {
    let progress: Double  // 0.0 à 1.0
    let origin: Airport
    let destination: Airport
    let remainingTime: TimeInterval

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                // Ligne de route
                Path { path in
                    let startX: CGFloat = 40
                    let endX = geometry.size.width - 40
                    let y = geometry.size.height / 2

                    path.move(to: CGPoint(x: startX, y: y))
                    path.addLine(to: CGPoint(x: endX, y: y))
                }
                .stroke(Color.secondary.opacity(0.3), lineWidth: 3)

                // Ligne de progression
                Path { path in
                    let startX: CGFloat = 40
                    let endX = geometry.size.width - 40
                    let progressX = startX + (endX - startX) * progress
                    let y = geometry.size.height / 2

                    path.move(to: CGPoint(x: startX, y: y))
                    path.addLine(to: CGPoint(x: progressX, y: y))
                }
                .stroke(Color.accentColor, lineWidth: 3)

                // Marqueur d'origine
                Circle()
                    .fill(Color.secondary)
                    .frame(width: 8, height: 8)
                    .position(x: 40, y: geometry.size.height / 2)

                // Icône d'avion à la position actuelle
                Image(systemName: "airplane")
                    .foregroundStyle(Color.accentColor)
                    .position(
                        x: 40 + (geometry.size.width - 80) * progress,
                        y: geometry.size.height / 2
                    )

                // Marqueur de destination
                Circle()
                    .fill(Color.secondary)
                    .frame(width: 8, height: 8)
                    .position(x: geometry.size.width - 40, y: geometry.size.height / 2)

                // Libellés
                HStack {
                    Text(origin.code)
                        .font(.caption.bold())
                    Spacer()
                    Text(destination.code)
                        .font(.caption.bold())
                }
                .padding(.horizontal, 20)
            }
        }
        .frame(height: 60)
    }
}

4. Assistant de correspondance

Lors de l'atterrissage avec un vol en correspondance, Flighty affiche les deux portes avec votre position en temps réel.

VUE CORRESPONDANCE
┌─────────────────────────────────────────┐
│ Correspondance : 45 minutes             │
│                                         │
│ ┌─────────────────────────────────────┐ │
│ │                                     │ │
│ │   [Plan du terminal]                │ │
│ │                                     │ │
│ │   [*] Vous -> -> -> -> -> [>] D22   │ │
│ │   C15            8 min à pied       │ │
│ │                                     │ │
│ └─────────────────────────────────────┘ │
│                                         │
│ [!] Correspondance serrée - partez      │
└─────────────────────────────────────────┘

Implémentation :

struct ConnectionAssistantView: View {
    let arrival: Flight
    let departure: Flight
    let userLocation: CLLocationCoordinate2D?

    var connectionTime: TimeInterval {
        departure.scheduledDeparture.timeIntervalSince(arrival.estimatedArrival)
    }

    var walkingTime: TimeInterval {
        // Calcul basé sur la distance entre les portes et la vitesse de marche typique
        TerminalMap.walkingTime(from: arrival.arrivalGate, to: departure.gate)
    }

    var isTight: Bool {
        connectionTime < walkingTime + 15 * 60  // Moins de 15 min de marge
    }

    var body: some View {
        VStack(spacing: 16) {
            // En-tête
            HStack {
                Text("Correspondance")
                    .font(.headline)
                Spacer()
                Text(connectionTime.formatted())
                    .font(.title2.bold())
                    .foregroundStyle(isTight ? .orange : .primary)
            }

            // Carte avec itinéraire
            TerminalMapView(
                from: arrival.arrivalGate,
                to: departure.gate,
                userLocation: userLocation
            )
            .frame(height: 200)
            .clipShape(RoundedRectangle(cornerRadius: 12))

            // Informations des portes
            HStack {
                GateLabel(gate: arrival.arrivalGate, label: "Arrivée")
                Spacer()
                Text("\(Int(walkingTime / 60)) min à pied")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Spacer()
                GateLabel(gate: departure.gate, label: "Départ")
            }

            // Warning if tight
            if isTight {
                HStack {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .foregroundStyle(.orange)
                    Text("Tight connection - start moving")
                        .font(.callout)
                }
                .padding()
                .background(Color.orange.opacity(0.1))
                .clipShape(RoundedRectangle(cornerRadius: 8))
            }
        }
        .padding()
    }
}

5. Passeport et statistiques cumulées

Flighty transforme l'historique de voyage en visualisations élégantes et partageables.

PASSPORT PAGE
┌─────────────────────────────────────────┐
│           [^] FLIGHTY PASSPORT          │
│                                         │
│  [Custom artwork with travel themes]    │
│                                         │
│  ───────────────────────────────────    │
│  2024                                   │
│                                         │
│  47 flights                             │
│  89,234 miles                           │
│  23 airports                            │
│  12 countries                           │
│                                         │
│  Most visited: SFO (23 times)           │
│  Longest flight: SFO → SIN (8,447 mi)  │
│  ───────────────────────────────────    │
│                                         │
│  [Share] [View Details]                 │
└─────────────────────────────────────────┘

Système de design visuel

Palette de couleurs

extension Color {
    // Status colors (airport-inspired)
    static let flightOnTime = Color(hex: "#10B981")    // Green
    static let flightDelayed = Color(hex: "#F59E0B")   // Amber
    static let flightCancelled = Color(hex: "#EF4444") // Red
    static let flightBoarding = Color(hex: "#3B82F6")  // Blue

    // UI colors
    static let flightPrimary = Color(hex: "#1F2937")
    static let flightSecondary = Color(hex: "#6B7280")
    static let flightBackground = Color(hex: "#F9FAFB")
    static let flightCard = Color(hex: "#FFFFFF")

    // Accent
    static let flightAccent = Color(hex: "#6366F1")    // Indigo
}

Typographie

struct FlightyTypography {
    // Flight numbers and gates (need to stand out)
    static let flightNumber = Font.system(size: 20, weight: .bold, design: .monospaced)
    static let gate = Font.system(size: 24, weight: .bold, design: .rounded)

    // Time displays
    static let time = Font.system(size: 18, weight: .medium, design: .monospaced)
    static let countdown = Font.system(size: 32, weight: .bold, design: .monospaced)

    // Status and labels
    static let status = Font.system(size: 14, weight: .semibold)
    static let label = Font.system(size: 12, weight: .medium)
    static let body = Font.system(size: 16, weight: .regular)
}

Philosophie d'animation

Mouvement intentionnel

Les animations de Flighty servent une fonction, pas une décoration.

// Status change animation
struct StatusBadge: View {
    let status: FlightStatus

    var body: some View {
        Text(status.displayText)
            .font(.caption.bold())
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
            .background(status.color)
            .foregroundStyle(.white)
            .clipShape(Capsule())
            .contentTransition(.numericText())  // Smooth number changes
            .animation(.spring(response: 0.3), value: status)
    }
}

// Progress bar animation
struct AnimatedProgress: View {
    let progress: Double

    var body: some View {
        GeometryReader { geo in
            Rectangle()
                .fill(Color.accentColor)
                .frame(width: geo.size.width * progress)
                .animation(.linear(duration: 60), value: progress)  // Slow, continuous
        }
    }
}

Enseignements pour notre travail

1. Les analogies du monde réel guident le design

La signalétique aéroportuaire bénéficie de plus de 50 ans d'optimisation. Inspirez-vous des systèmes éprouvés.

2. Les états contextuels réduisent la charge cognitive

15 états intelligents signifient que les utilisateurs ne voient jamais d'informations non pertinentes.

3. L'évidence ennuyeuse est l'objectif

Si l'application « fonctionne tout simplement », vous avez réussi. La friction est un échec.

4. Condensez, enveloppez, colorez vos données

N'affichez pas les données brutes. Présentez des insights. Utilisez des codes couleur pour les statuts.

5. Priorité au hors-ligne pour le mobile

Les vols passent hors ligne. L'application doit fonctionner sans connexion.


Questions fréquemment posées

Comment fonctionnent les 15 états intelligents de Flighty ?

Flighty surveille le statut de votre vol, votre position et l'heure pour afficher automatiquement les informations les plus pertinentes. Les états vont de « loin » (24+ heures avant) jusqu'à « direction l'aéroport », « à la porte », « embarquement », « en vol » et « atterri ». Chaque état fait apparaître différents détails : les numéros de confirmation en amont, les informations de porte quand c'est pertinent, le temps de vol restant pendant le trajet, et les indications de correspondance après l'atterrissage.

Qu'est-ce qui rend l'implémentation des Live Activities de Flighty si spéciale ?

Flighty a été la première application à exploiter pleinement les Live Activities et la Dynamic Island d'iOS 16. L'implémentation comprend des vues compactes affichant la porte et le compte à rebours, des vues étendues avec tous les détails du vol, et une visualisation de la progression en vol fonctionnant hors ligne. Le design traite les Live Activities comme une interface principale, pas comme une réflexion après coup.

Comment Flighty gère-t-il les scénarios hors ligne ?

Flighty télécharge les données de vol et les stocke localement avant le départ. La progression en vol continue en se basant sur les horaires prévus et les performances connues de l'avion. L'application pré-calcule les estimations d'arrivée, les temps de correspondance et les informations de porte. Quand la connectivité revient, les données se synchronisent et se mettent à jour avec les éventuels changements. Cette approche priorité hors-ligne est essentielle puisque les vols passent des heures sans internet.

Pourquoi Flighty utilise-t-il la signalétique aéroportuaire comme inspiration de design ?

Les tableaux d'affichage des départs dans les aéroports ont été perfectionnés pendant plus de 50 ans pour communiquer rapidement les informations de vol aux voyageurs stressés dans des environnements animés. Ils utilisent une ligne par vol, des statuts codés par couleur, et uniquement les informations essentielles. Flighty adapte ce langage visuel éprouvé au mobile, utilisant les mêmes principes de densité, clarté et indication de statut que les aéroports ont optimisés pendant des décennies.

Comment l'assistant de correspondance calcule-t-il les correspondances serrées ?

L'assistant de correspondance compare votre heure d'arrivée avec le prochain départ, puis calcule le temps de marche en fonction de la distance entre les portes au sein du terminal. Il prend en compte les vitesses de marche typiques et les configurations des terminaux. Si la marge tombe en dessous de 15 minutes, l'application signale une correspondance serrée et affiche une carte en direct avec votre position actuelle, la porte de destination et l'itinéraire à pied.