Flighty: 제대로 된 데이터 시각화

Flighty가 Apple Design Award 2023을 수상한 이유: 15개의 스마트 상태, Live Activities, 공항에서 영감받은 데이터 시각화. SwiftUI 구현 패턴 포함.

4 분 소요 760 단어
Flighty: 제대로 된 데이터 시각화 screenshot

Flighty: 올바른 데이터 시각화의 정석

"우리는 Flighty가 너무나 잘 작동해서 당연하게 느껴질 정도가 되길 원합니다."

Flighty는 복잡한 항공편 데이터를 한눈에 파악할 수 있는 정보로 변환합니다. 2023년 Apple Design Award 인터랙션 부문 수상작으로, 수십 년간 다듬어진 공항 안내판에서 영감을 받은 차분하고 명확한 디자인을 통해 밀도 높은 데이터를 제시하는 방법의 교과서입니다.


Flighty가 중요한 이유

전 Apple 직원 Ryan Jones가 불쾌한 항공편 지연을 겪은 후 설립한 Flighty는 iOS 데이터 시각화의 표준이 되었습니다.

주요 성과: - 2023년 Apple Design Award (인터랙션 부문) - 2023년 올해의 iPhone 앱 최종 후보 - Live Activities를 완전히 활용한 최초의 앱 - 15가지 상황 인식 스마트 상태 - 업계 최고의 지연 예측


핵심 요점

  1. 검증된 시스템에서 차용하라 - 공항 출발 안내판은 50년 이상의 최적화 역사가 있습니다. Flighty는 이 시각적 언어를 모바일에 맞게 적용합니다
  2. 상황 인식 상태가 인지 부하를 줄인다 - 15가지 스마트 상태로 사용자가 여행의 각 단계에서 정확히 필요한 정보만 볼 수 있습니다
  3. "당연하게 느껴지는 것"이 목표다 - 앱이 마찰 없이 그냥 작동한다면, 성공한 것입니다
  4. 데이터를 압축하고, 정리하고, 색상을 입혀라 - 원시 데이터가 아닌 인사이트를 제시하고, 상태를 색상으로 구분해 사용자가 해석할 필요가 없게 하세요
  5. 불안정한 연결에 대비한 오프라인 우선 - 비행 중에는 오프라인이 됩니다. 앱은 특히 비행 중에도 연결 없이 작동해야 합니다

핵심 디자인 철학

디자인 언어로서의 공항 안내판

Flighty의 디자인은 실제 세계의 비유에 기반합니다: 바로 공항 출발 안내판입니다.

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]

핵심 인사이트: "공항 안내판은 항공편당 한 줄입니다. 이것이 좋은 기준이 됩니다—그들은 무엇이 중요한지 50년간 연구해왔으니까요."

여행 상황 인식

Flighty는 여행이 스트레스를 준다는 것을 이해합니다. 디자인은 인지 부하를 줄여야지, 늘려서는 안 됩니다.

디자인 원칙: 1. 가장 중요한 정보는 스크롤 없이 보이는 영역에 2. 세부 정보는 점진적 공개 3. 색상으로 구분된 상태 (초록 = 양호, 노랑 = 주의, 빨강 = 문제) 4. 데이터를 "압축, 정리, 색상 처리"하여 사용자가 해석할 필요 없게


패턴 라이브러리

1. 15가지 스마트 상태

Flighty의 핵심 혁신: 필요한 것을 필요한 순간에 정확히 보여주는 15가지 상황 인식 상태.

상태 타임라인:

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 [→]          │
└─────────────────────────────────────────┘

구현 컨셉:

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 & Dynamic Island

Flighty는 iOS 16의 Live Activities를 완벽하게 활용한 최초의 앱으로, 이 기능의 표준을 정립했습니다.

Dynamic Island 상태:

COMPACT (최소화)
╭──────────────────────────────────╮
│ [^] B22  │  *  │  2:30 -> 1h 45m │
╰──────────────────────────────────╯
  게이트    상태      시간/소요시간

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

IN-FLIGHT (오프라인 지원)
╭──────────────────────────────────╮
│  SFO ●════════○──── JFK          │
│         2h 15m                   │
╰──────────────────────────────────╯

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에서 1.0
        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
            // Lock Screen view
            LockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // Expanded view
                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: {
                // Compact left side
                Text(context.attributes.gate)
                    .font(.caption)
            } compactTrailing: {
                // Compact right side
                Text(context.state.departureTime, style: .timer)
            } minimal: {
                // Minimal (when multiple activities)
                Image(systemName: "airplane")
            }
        }
    }
}

3. 비행 진행 상황 시각화

Flighty는 다양한 시각적 메타포를 통해 비행 진행 상황을 보여줍니다.

비행기 표시가 있는 경로 라인:

LINEAR PROGRESS (Primary)
───────────────────────────────────────────────────────────────────
SFO ════════════════●════════════════════════════════════ JFK
    [45분 전 출발]    ↑ 현재 위치        [2시간 15분 남음]

CIRCULAR PROGRESS (Compact)
        ╭───────╮
       /    ●    \
      │     |     │    ← 진행 호가 시계 방향으로 채워짐
      │     |     │
       \    ↓    /     2시간 15분 남음
        ╰───────╯

HEAT MAP (Lifetime Stats)
[다양한 두께의 경로 라인이 있는 지도]
굵은 라인 = 자주 이용한 경로
얇은 라인 = 한 번 이용한 경로

SwiftUI 구현:

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

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                // Route line
                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)

                // Progress line
                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)

                // 출발지 마커
                Circle()
                    .fill(Color.secondary)
                    .frame(width: 8, height: 8)
                    .position(x: 40, y: geometry.size.height / 2)

                // 현재 위치의 비행기 아이콘
                Image(systemName: "airplane")
                    .foregroundStyle(Color.accentColor)
                    .position(
                        x: 40 + (geometry.size.width - 80) * progress,
                        y: geometry.size.height / 2
                    )

                // 도착지 마커
                Circle()
                    .fill(Color.secondary)
                    .frame(width: 8, height: 8)
                    .position(x: geometry.size.width - 40, y: geometry.size.height / 2)

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

4. 환승 어시스턴트

환승 항공편이 있는 경우 착륙 시 Flighty는 두 게이트와 사용자의 실시간 위치를 함께 보여줍니다.

CONNECTION VIEW
┌─────────────────────────────────────────┐
│ 환승 시간: 45분                          │
│                                         │
│ ┌─────────────────────────────────────┐ │
│ │                                     │ │
│ │   [터미널 지도]                       │ │
│ │                                     │ │
│ │   [*] 현재 위치 -> -> -> -> [>] D22   │ │
│ │   C15            도보 8분             │ │
│ │                                     │ │
│ └─────────────────────────────────────┘ │
│                                         │
│ [!] 환승 시간이 촉박합니다 - 이동을 시작하세요 │
└─────────────────────────────────────────┘

구현:

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

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

    var walkingTime: TimeInterval {
        // 게이트 간 거리와 일반적인 보행 속도를 기반으로 계산
        TerminalMap.walkingTime(from: arrival.arrivalGate, to: departure.gate)
    }

    var isTight: Bool {
        connectionTime < walkingTime + 15 * 60  // 15분 미만의 여유 시간
    }

    var body: some View {
        VStack(spacing: 16) {
            // 헤더
            HStack {
                Text("Connection")
                    .font(.headline)
                Spacer()
                Text(connectionTime.formatted())
                    .font(.title2.bold())
                    .foregroundStyle(isTight ? .orange : .primary)
            }

            // 경로가 표시된 지도
            TerminalMapView(
                from: arrival.arrivalGate,
                to: departure.gate,
                userLocation: userLocation
            )
            .frame(height: 200)
            .clipShape(RoundedRectangle(cornerRadius: 12))

            // 게이트 정보
            HStack {
                GateLabel(gate: arrival.arrivalGate, label: "Arrival")
                Spacer()
                Text("\(Int(walkingTime / 60)) min walk")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Spacer()
                GateLabel(gate: departure.gate, label: "Departure")
            }

            // 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. 패스포트 및 평생 통계

Flighty는 여행 기록을 아름답고 공유 가능한 시각화로 변환합니다.

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]                 │
└─────────────────────────────────────────┘

비주얼 디자인 시스템

컬러 팔레트

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
}

타이포그래피

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)
}

애니메이션 철학

목적이 있는 모션

Flighty의 애니메이션은 장식이 아닌 기능을 위해 존재합니다.

// 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
        }
    }
}

우리 작업을 위한 교훈

1. 실제 세계의 비유가 디자인을 이끈다

공항 안내 표지판은 50년 이상의 최적화 과정을 거쳤습니다. 검증된 시스템에서 차용하세요.

2. 맥락 인식 상태가 인지 부하를 줄인다

15개의 스마트 상태 덕분에 사용자는 불필요한 정보를 보지 않습니다.

3. 지루할 정도로 명확한 것이 목표다

앱이 “그냥 작동하면” 성공한 것입니다. 마찰은 실패입니다.

4. 데이터를 압축하고, 감싸고, 색상으로 구분하라

원시 데이터를 그대로 보여주지 마세요. 인사이트를 제시하세요. 상태를 색상으로 구분하세요.

5. 모바일은 오프라인 우선으로

비행 중에는 오프라인이 됩니다. 앱은 연결 없이도 작동해야 합니다.


자주 묻는 질문

Flighty의 15가지 스마트 상태는 어떻게 작동하나요?

Flighty는 항공편 상태, 위치, 시간을 모니터링하여 가장 관련성 높은 정보를 자동으로 표시합니다. 상태는 “아직 멀었음”(24시간 이상 전)부터 “공항으로 이동,” “게이트 도착,” “탑승 중,” “비행 중,” “착륙”까지 다양합니다. 각 상태는 서로 다른 세부 정보를 표시합니다: 초기에는 예약 확인 번호, 관련 시점에는 게이트 정보, 비행 중에는 남은 비행 시간, 착륙 후에는 환승 안내를 제공합니다.

Flighty의 Live Activities 구현이 특별한 이유는 무엇인가요?

Flighty는 iOS 16의 Live Activities와 Dynamic Island를 완전히 활용한 최초의 앱입니다. 구현에는 게이트와 카운트다운을 보여주는 컴팩트 뷰, 전체 항공편 세부 정보가 있는 확장 뷰, 연결 없이도 작동하는 오프라인 지원 비행 진행 시각화가 포함됩니다. 이 디자인은 Live Activities를 부가 기능이 아닌 주요 인터페이스로 취급합니다.

Flighty는 오프라인 상황을 어떻게 처리하나요?

Flighty는 출발 전에 항공편 데이터를 다운로드하고 로컬에 저장합니다. 비행 중 진행 상황은 예정된 시간과 알려진 항공기 성능을 기반으로 계속됩니다. 앱은 도착 예상 시간, 환승 시간, 게이트 정보를 미리 계산합니다. 연결이 복구되면 데이터가 동기화되고 변경 사항이 업데이트됩니다. 항공편이 인터넷 없이 몇 시간을 보내기 때문에 이러한 오프라인 우선 접근 방식은 필수적입니다.

Flighty가 공항 안내 표지판을 디자인 영감으로 사용하는 이유는 무엇인가요?

공항 출발 안내판은 50년 이상 다듬어져 바쁜 환경에서 스트레스받는 여행객에게 항공편 정보를 빠르게 전달합니다. 항공편당 한 줄, 색상으로 구분된 상태, 필수 정보만 사용합니다. Flighty는 이 검증된 시각 언어를 모바일에 맞게 적용하여, 공항이 수십 년간 최적화해 온 동일한 밀도, 명확성, 상태 표시 원칙을 사용합니다.

환승 어시스턴트는 촉박한 환승을 어떻게 계산하나요?

환승 어시스턴트는 도착 시간과 다음 출발 시간을 비교한 다음, 터미널 내 게이트 거리를 기반으로 도보 시간을 계산합니다. 일반적인 걷는 속도와 터미널 레이아웃을 고려합니다. 여유 시간이 15분 미만으로 떨어지면 앱이 촉박한 환승으로 표시하고 현재 위치, 목적지 게이트, 도보 경로가 포함된 실시간 지도를 보여줍니다.