Flighty:正しく行われたデータビジュアライゼーション

FlightyがApple Design Award 2023を受賞した理由:15のスマートステート、Live Activities、空港にインスパイアされたデータビジュアライゼーション。SwiftUI実装パターン付き。

5 分で読める 155 語
Flighty:正しく行われたデータビジュアライゼーション screenshot

Flighty:データビジュアライゼーションの正しい形

「Flightyは、当たり前すぎてつまらないと感じるほど完璧に機能してほしい」

Flightyは複雑なフライトデータを一目で分かる情報に変換します。Apple Design Award 2023のインタラクション部門を受賞し、数十年にわたる空港サイネージにインスパイアされた穏やかで明快なデザインで、高密度なデータを提示するお手本となっています。


Flightyが重要な理由

元Apple社員のRyan Jonesがフライト遅延でフラストレーションを感じたことをきっかけに設立されたFlightyは、iOSデータビジュアライゼーションのゴールドスタンダードとなっています。

主な実績: - Apple Design Award 2023(インタラクション部門) - 2023年 iPhone App of the Year ファイナリスト - 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

1便につき1行。必要な情報のみ。数十年にわたる最適化の結晶。

FLIGHTYの解釈
───────────────────────────────────────────────────────────────────
[同じ情報密度のフライトカード]
[カラーコード化されたステータス]
[時間に応じた詳細情報]

重要な洞察:「空港の掲示板は1フライトにつき1行です。これは良い指針になります—彼らは50年かけて何が重要かを見極めてきたのですから。」

旅行コンテキストの認識

Flightyは旅行がストレスフルであることを理解しています。デザインは認知負荷を軽減するものでなければならず、増やすものであってはなりません。

デザイン原則: 1. **最も重要な情報をファーストビューに** 2. 詳細の段階的開示 3. カラーコードによるステータス表示(緑 = 良好、黄 = 警告、赤 = 問題) 4. データは“整理され、包装され、色分けされている”ため、ユーザーが自分で解釈する必要がない


パターンライブラリ

1. 15のスマートステート

Flightyの代表的なイノベーション:必要な情報を、必要なタイミングで正確に表示する15のコンテキスト対応ステート。

ステートのタイムライン:

24時間前
┌─────────────────────────────────────────┐
│ UA 1234 → SFO                           │
│ 明日 午後2:30                            │
│ 確認番号: ABC123                         │
│ ターミナル2へ向かってください              │
└─────────────────────────────────────────┘

3時間前
┌─────────────────────────────────────────┐
│ UA 1234 → SFO                           │
│ ゲートB22 • 搭乗開始 午後2:00            │
│ 午後12:15までに空港へ出発                │
│ [タイムライン全体を見る →]               │
└─────────────────────────────────────────┘

ゲートにて
┌─────────────────────────────────────────┐
│ UA 1234 → SFO     [定刻]               │
│ ゲート B22 • 搭乗開始まで12分           │
│ 座席 14A • 窓側                         │
│ [機体到着済み ✓]                        │
└─────────────────────────────────────────┘

機内へ移動中
┌─────────────────────────────────────────┐
│ UA 1234 → SFO     [搭乗中]             │
│ お座席: 14A (窓側)                      │
│ ▓▓▓▓▓▓▓▓░░ 80% 搭乗完了                │
└─────────────────────────────────────────┘

飛行中(オフライン)
┌─────────────────────────────────────────┐
│  SFO ──────●───────────── JFK           │
│            │                            │
│     残り 2時間15分                      │
│     到着予定 現地時間 午後5:45頃        │
└─────────────────────────────────────────┘

到着
┌─────────────────────────────────────────┐
│ ✓ JFKに到着しました                      │
│ 到着ゲート: C15 → 乗り継ぎゲート: D22    │
│ 徒歩8分 • ターミナルマップ [→]           │
└─────────────────────────────────────────┘

実装コンセプト:

enum FlightState: CaseIterable {
    case farOut          // 24時間以上前
    case dayBefore       // 24時間前
    case headToAirport   // 約3時間前
    case atAirport       // ターミナルに到着
    case atGate          // ゲートエリア
    case boarding        // 搭乗開始
    case onBoard         // 着席済み
    case taxiing         // 滑走路へ移動中
    case inFlight        // 飛行中
    case descending      // 降下中
    case landed          // 着陸
    case atGateDest      // 到着ゲートに到着
    case connection      // 乗り継ぎ便
    case completed       // 旅程完了
    case delayed         // 遅延状態
}

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

        // コンテキスト要因
        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: "ゲート \(flight.gate) • 搭乗開始 \(flight.boardingTime.formatted())",
                secondary: "空港への出発推奨時刻: \(flight.recommendedDeparture.formatted())",
                action: "完全なタイムラインを表示"
            )
        case .inFlight:
            return StateContent(
                headline: flight.routeVisualization,
                primary: "残り \(flight.remainingTime.formatted())",
                secondary: "到着予定 ~\(flight.estimatedArrival.formatted())",
                action: nil
            )
        // ... その他の状態
        }
    }
}

2. Live Activities & Dynamic Island

FlightyはiOS 16のLive Activitiesを完全に活用した最初のアプリであり、この機能の標準を確立しました。

Dynamic Islandの状態:

COMPACT(最小表示)
╭──────────────────────────────────╮
│ [^] B22  │  *  │  2:30 -> 1h 45m │
╰──────────────────────────────────╯
  ゲート    ステータス    時間/所要時間

展開表示(長押し)
╭────────────────────────────────────────╮
│  UA 1234 サンフランシスコ行き          │
│  ───────────────────────────────────   │
│  ゲート B22      搭乗 2:00 PM          │
│  座席 14A        定刻通り ✓            │
│                                        │
│  [Flightyを開く]    [到着予定を共有]   │
╰────────────────────────────────────────╯

飛行中(オフライン対応)
╭──────────────────────────────────╮
│  SFO ●════════○──── JFK          │
│         2時間15分                │
╰──────────────────────────────────╯

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
            // ロック画面ビュー
            LockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // 展開ビュー
                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: {
                // コンパクト左側
                Text(context.attributes.gate)
                    .font(.caption)
            } compactTrailing: {
                // コンパクト右側
                Text(context.state.departureTime, style: .timer)
            } minimal: {
                // ミニマル(複数のアクティビティがある場合)
                Image(systemName: "airplane")
            }
        }
    }
}

3. フライト進行状況の可視化

Flightyは複数の視覚的表現を通じてフライトの進行状況を表示します。

機体インジケーター付きルートライン:

リニア進捗(プライマリ)
───────────────────────────────────────────────────────────────────
SFO ════════════════●════════════════════════════════════ JFK
    [45分前に出発]    ↑ 現在地        [残り2時間15分]

円形プログレス(コンパクト)
        ╭───────╮
       /    ●    \
      │     |     │    ← 進捗アークが時計回りに塗りつぶされる
      │     |     │
       \    ↓    /     残り2時間15分
        ╰───────╯

HEAT MAP(生涯統計)
[さまざまな太さのルート線が描かれた地図]
太い線 = 頻繁に利用したルート
細い線 = 一度だけ利用したルート

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 {
                // ルートライン
                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)

                // プログレスライン
                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("接続")
                    .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: "到着")
                Spacer()
                Text("\(Int(walkingTime / 60))分 徒歩")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Spacer()
                GateLabel(gate: departure.gate, label: "出発")
            }

            // 時間がタイトな場合の警告
            if isTight {
                HStack {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .foregroundStyle(.orange)
                    Text("乗り継ぎ時間が短いです - すぐに移動を開始してください")
                        .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 {
    // ステータスカラー(空港インスパイア)
    static let flightOnTime = Color(hex: "#10B981")    // グリーン
    static let flightDelayed = Color(hex: "#F59E0B")   // アンバー
    static let flightCancelled = Color(hex: "#EF4444") // レッド
    static let flightBoarding = Color(hex: "#3B82F6")  // ブルー

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

    // アクセント
    static let flightAccent = Color(hex: "#6366F1")    // インディゴ
}

タイポグラフィ

struct FlightyTypography {
    // フライト番号とゲート(目立つ必要がある)
    static let flightNumber = Font.system(size: 20, weight: .bold, design: .monospaced)
    static let gate = Font.system(size: 24, weight: .bold, design: .rounded)

    // 時刻表示
    static let time = Font.system(size: 18, weight: .medium, design: .monospaced)
    static let countdown = Font.system(size: 32, weight: .bold, design: .monospaced)

    // ステータスとラベル
    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のアニメーションは装飾ではなく、機能を果たします。

// ステータス変更アニメーション
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())  // 数値の滑らかな変化
            .animation(.spring(response: 0.3), value: status)
    }
}

// プログレスバーのアニメーション
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)  // ゆっくりと連続的に
        }
    }
}

私たちの仕事への教訓

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年以上かけて洗練されてきました。1フライトにつき1行、色分けされたステータス、必要な情報のみを使用しています。Flightyはこの実証済みのビジュアル言語をモバイル向けに適応させ、空港が何十年もかけて最適化してきた密度、明瞭さ、ステータス表示の同じ原則を使用しています。

乗り継ぎアシスタントはタイトな乗り継ぎをどのように計算しますか?

乗り継ぎアシスタントは、到着時刻と次の出発時刻を比較し、ターミナル内のゲート間距離に基づいて歩行時間を計算します。 一般的な歩行速度とターミナルのレイアウトを考慮しています。バッファが15分を下回ると、アプリはタイトな乗り継ぎとしてフラグを立て、現在地、目的のゲート、徒歩ルートを示すライブマップを表示します。