돌아가기 시계열 어노테이터 스키마
## 용어 정의

| 용어 | 의미 | 예시 |
|---|---|---|
| **트랙 (track)** | 하나의 차트에 함께 표시되는 채널들의 그룹. 서로 다른 센서의 채널을 자유롭게 조합할 수 있다. | GPS 고도 + 기압 고도 + EKF 고도를 하나의 차트에 표시 |
| **채널 (channel)** | 하나의 값 배열을 가진 개별 데이터 시리즈. timestamps와 동일한 길이의 숫자 배열이다. | `battery_status__voltage_v` |
| **채널 메타 (channelMeta)** | 채널의 표시 정보 (이름, 단위, 색상, 차트 타입). | `{ name: "전압", unit: "V", color: "#ab47bc", chartType: "line" }` |

:::tip[PX4 ULog와의 관계]
ULog의 **토픽(topic)** 은 여러 필드를 가진 메시지 타입이고, dm_schema의 **채널(channel)** 은 토픽의 개별 필드에 대응합니다.
하나의 토픽이 여러 인스턴스를 가질 수 있으며(예: `sensor_accel[0]`, `sensor_accel[1]`), 각 인스턴스의 필드가 개별 채널이 됩니다.
트랙은 PX4에 없는 개념으로, 어떤 토픽의 어떤 필드든 자유롭게 조합하여 하나의 차트로 묶을 수 있습니다.

| PX4 ULog | dm_schema | 관계 |
|---|---|---|
| topic (예: `battery_status`) | — | 직접 대응 없음 (채널의 출처) |
| topic.field (예: `battery_status.voltage_v`) | channel | 1:1 대응 |
| — | track | PX4에 없는 개념 (사용자 정의 그룹) |
:::

---

## 프로젝트 카테고리

| `project.category` |
| --- |
| `time_series_annotation` |

## 지원 도구

| `project.annotation_types[]` | 설명 |
| --- | --- |
| `time_range` | 시간 범위 어노테이션 |

---

## 라벨링 결과 구조

라벨링 결과는 [공통 어노테이터 스키마](/annotator-schema/공통/)의 `annotatorData` 최상위 구조를 그대로 따릅니다.

다른 어노테이터와 다른 점은 **AssetId(에셋 키)** 입니다. 이미지는 `image_1`, 오디오는 `audio_1` 같은 논리적 키를 쓰지만,
시계열은 `task.data_unit.files`에서 **`is_primary: true`인 파일의 키를 그대로** 사용합니다 (예: `data_1`).

| 최상위 키 | 시계열에서의 사용 |
| --- | --- |
| `extra` | 트랙 순서·숨김 상태 저장 (아래 [시계열 전용 extra](#시계열-전용-extra) 참고) |
| `annotations` | `time_range` 어노테이션의 메타 정보 |
| `annotationsData` | 각 어노테이션의 시간 구간 (`section`) |
| `relations` | **사용하지 않음** (항상 빈 배열) |
| `annotationGroups` | 조건부 사용 — 아래 설명 참고 |
| `assignmentId` | 작업 식별자 (숫자) |

**`relations`** — 시계열 어노테이터에는 관계(relation) 도구도, 관계 패널도 없습니다. 최상위 키는 공통 스키마 호환을 위해 유지되지만 항상 빈 배열입니다.

**`annotationGroups`** — 시계열 어노테이터도 공통 어노테이션 그룹 패널을 사용합니다.
프로젝트 분류 스키마에 `annotationGroup`이 정의된 경우에만 그룹 추가 탭이 노출되며, 이때 생성된 그룹은 공통 스키마의 `AnnotationGroupItem` 형태로 저장됩니다.
정의되지 않았다면 빈 배열로 유지됩니다.

각 키는 작업 로드 시 `{ "<AssetId>": [] }` 형태로 초기화되므로, 어노테이션이 하나도 없어도 키 자체는 존재합니다.

### annotations 필드 구조

| 필드 | 타입 | 설명 |
| --- | --- | --- |
| `id` | `string` | 어노테이션 고유 ID |
| `tool` | `"time_range"` | 시계열은 항상 `time_range` |
| `isLocked` | `boolean` | 편집 잠금 여부 (생성 시 `false`) |
| `isVisible` | `boolean` | 화면 표시 여부 (생성 시 `true`) |
| `isValid` | `boolean` | 유효성 여부 (생성 시 `false`, 분류 입력 후 검증 결과로 갱신) |
| `isDrawCompleted` | `boolean` | 그리기 완료 여부. 시계열은 구간 생성 즉시 완료되므로 **항상 `true`** |
| `classification` | `Classification \| null` | 분류 정보 (관리자 정의 스키마를 flatten한 key-value) |
| `label` | `object[]` | `classification`에서 파생된 표시용 라벨 (아래 참고) |

:::note[label 필드에 대하여]
`label`은 어노테이션이 추가·갱신될 때 `classification`으로부터 자동 생성되며, 저장 데이터에 항상 포함됩니다.
**문자열 배열이 아니라 객체 배열**이고, 각 요소는 다음 형태입니다.

```ts
{
  code: string                          // 분류 속성 코드 (예: "class")
  text: string                          // 현재 로케일 기준 표시 문자열
  color: string                         // 분류에 지정된 색상 (없으면 "")
  display: { ko: string; en: string }
  widget: string
  isRepresentative?: boolean            // representativeCodes에 포함되는지
  customFields?: unknown
}
```

배열 구성 규칙은 다음과 같습니다.

- **첫 번째 요소는 두 번째 요소의 복제본이며 `text`만 `""`입니다.** 색상 칩만 렌더링하기 위한 항목으로, 분류가 하나라도 있으면 항상 삽입됩니다. 따라서 배열 길이는 항상 `채워진 분류 속성 수 + 1`입니다.
- 두 번째 요소부터는 분류 스키마의 정의 순서(클래스 → 하위 속성)를 따릅니다.
- 값이 비어 있는 속성은 배열에 포함되지 않습니다.
- `text` / `display`는 로케일에 종속됩니다 (한국어 로케일이면 `text`는 `display.ko`와 같음).
- `color`는 선택형 위젯(`single_choice` / `multi_choice`)에서만 스키마에 정의된 색상이 들어가고, 자유 입력 위젯은 `""`입니다.
- `customFields`는 선택형 위젯에서 **항상 존재**하며, 프로젝트가 커스텀 필드를 정의하지 않았으면 `null`입니다.
- `isRepresentative`는 프로젝트 분류에 `representativeCodes`가 정의된 경우에만 붙습니다.
:::

### annotationData 구조 (time_range)

```json
{
  "id": "Yx8pQ2mLk3",
  "section": {
    "start": 1613693139891,
    "end": 1613693157891
  },
  "excludedTracks": ["visual-odometry-latency"]
}
```

| 필드 | 타입 | 필수 | 설명 |
| --- | --- | --- | --- |
| `id` | `string` | O | `annotations[].id`와 1:1 매칭 |
| `section.start` | `number` | O | 구간 시작 시각 |
| `section.end` | `number` | O | 구간 종료 시각 |
| `excludedTracks` | `string[]` | X | 이 구간을 적용하지 않을 트랙 ID 목록 (미지정 시 전체 트랙 적용) |

:::caution[section의 단위는 timestamps를 따릅니다]
오디오 어노테이터의 `section`은 항상 **초(second)** 이지만, 시계열의 `section.start` / `section.end`는
차트 X축 값 — 즉 **`timestamps`의 원시 단위** — 를 반올림한 정수입니다. 초 단위로 정규화되지 않습니다.

| `timeAxis.origin` | `section` 단위 |
| --- | --- |
| `absolute` / `epoch` | epoch_ms |
| `relative` (`unit: "us"`) | 부팅 후 마이크로초 |
| `index` | 샘플 인덱스 |

따라서 결과 JSON만으로는 단위를 알 수 없으며, **원본 데이터 유닛의 `timeAxis` 설정과 함께 해석**해야 합니다.
:::

#### excludedTracks

시간 구간 어노테이션은 기본적으로 **모든 트랙에 걸쳐** 표시됩니다.
특정 트랙에서만 "이 구간은 해당 없음"으로 처리하려면 해당 트랙 ID를 `excludedTracks`에 넣습니다.

- 빈 배열이거나 필드가 없으면 전체 트랙에 적용됩니다.
- 모든 트랙을 제외할 수는 없습니다 (최소 1개 트랙은 포함).
- 현재 트랙 목록에 없는 ID는 편집 시 무시됩니다.

트랙 ID는 데이터 유닛 구성 방식에 따라 다릅니다.

| 데이터 유닛 구성 | 트랙 ID |
| --- | --- |
| 통합 JSON | `tracks[].id` 값을 그대로 사용 (예: `altitude-estimate`) |
| CSV + 메타 JSON | `view.subplots` 순서대로 **자동 생성** — `track-0`, `track-1`, … |

:::caution[CSV 방식의 트랙 ID는 순서에 종속됩니다]
CSV 방식에서는 `subplots[].name`이 아니라 **배열 인덱스**로 ID가 만들어집니다.
메타 JSON의 `subplots` 순서를 바꾸면 기존 `excludedTracks`가 엉뚱한 트랙을 가리키게 됩니다.
:::

### 시계열 전용 extra

`extra[AssetId]`에는 작업자의 트랙 레이아웃 상태가 저장되어 세션 간 유지됩니다.

| 키 | 타입 | 설명 |
| --- | --- | --- |
| `trackOrder` | `string[]` | 작업자가 재정렬한 트랙 ID 순서 |
| `hiddenTracks` | `string[]` | 숨김 처리한 트랙 ID 목록 |

### 샘플

PX4 비행 로그(통합 JSON, `origin: "absolute"` → epoch_ms)를 라벨링한 결과 예시입니다.

`label`은 분류 스키마에서 파생되는 값이므로, 아래 샘플은 **클래스 6종에 `representativeCodes: ["class"]`가
정의되고 `vibration_anomaly`에만 하위 속성(`severity` / `axis`)이 붙은 프로젝트**를 가정한 결과입니다
([label 필드 설명](#annotations-필드-구조) 참고).

```json
{
  "assignmentId": 12345,
  "extra": {
    "data_1": {
      "trackOrder": [
        "altitude-estimate",
        "actuator-controls",
        "motor-outputs",
        "roll-angle",
        "roll-angular-rate",
        "pitch-angle",
        "pitch-angular-rate",
        "yaw-angle",
        "yaw-angular-rate",
        "raw-acceleration",
        "vibration-metrics",
        "local-position-x",
        "local-position-y",
        "local-position-z",
        "velocity",
        "visual-odometry-latency"
      ],
      "hiddenTracks": ["visual-odometry-latency"]
    }
  },
  "annotations": {
    "data_1": [
      {
        "id": "Yx8pQ2mLk3",
        "tool": "time_range",
        "isLocked": false,
        "isVisible": true,
        "isValid": true,
        "isDrawCompleted": true,
        "classification": {
          "class": "takeoff"
        },
        "label": [
          { "code": "class", "text": "", "color": "#00B894", "display": { "ko": "이륙구간", "en": "takeoff" }, "widget": "single_choice", "isRepresentative": true, "customFields": null },
          { "code": "class", "text": "이륙구간", "color": "#00B894", "display": { "ko": "이륙구간", "en": "takeoff" }, "widget": "single_choice", "isRepresentative": true, "customFields": null }
        ]
      },
      {
        "id": "Bn4vR7tWq1",
        "tool": "time_range",
        "isLocked": false,
        "isVisible": true,
        "isValid": true,
        "isDrawCompleted": true,
        "classification": {
          "class": "hover"
        },
        "label": [
          { "code": "class", "text": "", "color": "#8B5CF6", "display": { "ko": "호버링구간", "en": "hover" }, "widget": "single_choice", "isRepresentative": true, "customFields": null },
          { "code": "class", "text": "호버링구간", "color": "#8B5CF6", "display": { "ko": "호버링구간", "en": "hover" }, "widget": "single_choice", "isRepresentative": true, "customFields": null }
        ]
      },
      {
        "id": "Hs9cE2yZd6",
        "tool": "time_range",
        "isLocked": false,
        "isVisible": true,
        "isValid": true,
        "isDrawCompleted": true,
        "classification": {
          "class": "cruise"
        },
        "label": [
          { "code": "class", "text": "", "color": "#3B82F6", "display": { "ko": "순항구간", "en": "cruise" }, "widget": "single_choice", "isRepresentative": true, "customFields": null },
          { "code": "class", "text": "순항구간", "color": "#3B82F6", "display": { "ko": "순항구간", "en": "cruise" }, "widget": "single_choice", "isRepresentative": true, "customFields": null }
        ]
      },
      {
        "id": "Kt3mA8bXf5",
        "tool": "time_range",
        "isLocked": true,
        "isVisible": true,
        "isValid": true,
        "isDrawCompleted": true,
        "classification": {
          "class": "vibration_anomaly",
          "severity": "high",
          "axis": "z"
        },
        "label": [
          { "code": "class", "text": "", "color": "#FF6B6B", "display": { "ko": "진동 이상구간", "en": "vibration_anomaly" }, "widget": "single_choice", "isRepresentative": true, "customFields": null },
          { "code": "class", "text": "진동 이상구간", "color": "#FF6B6B", "display": { "ko": "진동 이상구간", "en": "vibration_anomaly" }, "widget": "single_choice", "isRepresentative": true, "customFields": null },
          { "code": "severity", "text": "높음", "color": "#E53935", "display": { "ko": "높음", "en": "high" }, "widget": "single_choice", "isRepresentative": false, "customFields": null },
          { "code": "axis", "text": "Z축", "color": "#78909C", "display": { "ko": "Z축", "en": "z" }, "widget": "single_choice", "isRepresentative": false, "customFields": null }
        ]
      },
      {
        "id": "Pw6dU1nGj0",
        "tool": "time_range",
        "isLocked": false,
        "isVisible": true,
        "isValid": true,
        "isDrawCompleted": true,
        "classification": {
          "class": "landing"
        },
        "label": [
          { "code": "class", "text": "", "color": "#F59E0B", "display": { "ko": "착륙구간", "en": "landing" }, "widget": "single_choice", "isRepresentative": true, "customFields": null },
          { "code": "class", "text": "착륙구간", "color": "#F59E0B", "display": { "ko": "착륙구간", "en": "landing" }, "widget": "single_choice", "isRepresentative": true, "customFields": null }
        ]
      }
    ]
  },
  "annotationsData": {
    "data_1": [
      {
        "id": "Yx8pQ2mLk3",
        "section": {
          "start": 1613693139891,
          "end": 1613693157891
        }
      },
      {
        "id": "Bn4vR7tWq1",
        "section": {
          "start": 1613693157891,
          "end": 1613693189891
        }
      },
      {
        "id": "Hs9cE2yZd6",
        "section": {
          "start": 1613693189891,
          "end": 1613693352891
        }
      },
      {
        "id": "Kt3mA8bXf5",
        "section": {
          "start": 1613693271491,
          "end": 1613693285891
        },
        "excludedTracks": [
          "local-position-x",
          "local-position-y",
          "local-position-z",
          "yaw-angle"
        ]
      },
      {
        "id": "Pw6dU1nGj0",
        "section": {
          "start": 1613693402891,
          "end": 1613693426291
        }
      }
    ]
  },
  "relations": {
    "data_1": []
  },
  "annotationGroups": {
    "data_1": []
  }
}
```

위 샘플에서 확인할 점:

- `data_1`은 `task.data_unit.files`의 `is_primary` 파일 키입니다. CSV 방식이라도 실데이터 파일 키(`data_1`)를 쓰며, 메타 JSON 키(`data_meta_1`)는 쓰지 않습니다. 에셋 키는 파일 키를 그대로 따르므로, 파일 키가 `data_1`이 아닌 프로젝트에서는 그 키가 들어갑니다.
- `section` 값은 epoch_ms입니다. 같은 데이터를 CSV + `timeAxis.origin: "relative"` / `unit: "us"`로 로드했다면 `191000000` 같은 마이크로초 값이 저장됩니다.
- 트랙 ID와 시간 범위는 PX4 예제 데이터의 실제 값이고, 클래스 코드(`takeoff` / `hover` / `cruise` / `turn` / `landing` / `vibration_anomaly`)도 해당 프로젝트의 분류 스키마와 동일합니다.
- `vibration_anomaly` 구간은 위치 관련 트랙 4개를 `excludedTracks`로 제외했고, `isLocked: true`로 편집이 잠겨 있습니다.
- `classification`의 `severity` / `axis`는 **하위 속성(attributes)까지 정의한 프로젝트를 가정한 확장 예시**입니다. 하위 속성은 특정 클래스가 선택됐을 때만 노출되도록 정의되므로, 위처럼 `vibration_anomaly`에만 붙는 형태가 됩니다. 클래스만 정의한 프로젝트라면 `classification`은 `{ "class": "..." }` 하나뿐입니다.
- `label`의 **첫 번째 요소는 `text`만 비운 중복 항목**입니다. 클래스만 있는 어노테이션은 2개, 하위 속성 2개가 붙은 `vibration_anomaly`는 4개가 됩니다. 배열을 순회해 텍스트를 이어 붙일 때는 첫 요소를 건너뛰어야 합니다.
- `label`은 `classification`에서 파생된 값이며, 작업을 다시 열 때 분류 스키마 기준으로 **재계산되어 덮어써집니다**. 저장된 값을 신뢰해 읽는 소비자는 없으므로, 외부에서 결과를 해석할 때는 `label`이 아니라 `classification`을 기준으로 삼아야 합니다.

---

:::warning[초안]
⬇️ ⬇️ 아래 **데이터 유닛** 섹션부터 문서 끝까지는 작성 중인 내용으로, 확정되지 않았습니다.
:::

---

## 데이터 유닛

시계열 데이터 유닛은 동일한 시간 범위를 공유하는 센서 채널들의 집합입니다.
`task.data_unit.files`에 실제 파일들이 담기며, 다음 두 가지 구성을 지원합니다.

| 구성 | files 구성 | 실데이터 | 뷰 정의 |
| --- | --- | --- | --- |
| **통합 JSON** | `data_N` (`.json`, `is_primary`) | JSON 내 `channels` | 같은 JSON의 `tracks` / `channelMeta` |
| **CSV + 메타 JSON** | `data_N` (`.csv`, `is_primary`) + `data_meta_N` (`.json`) | CSV 파일 | 메타 JSON의 `view.subplots` |

`is_primary`인 파일이 실데이터이며, 로더는 그 URL의 **확장자**로 통합 JSON(`.json`) / CSV(`.csv`) 경로를 판별합니다. (`file_type` 필드는 사용하지 않습니다.)

### task.data_unit.files

각 파일 항목은 다음과 같은 형태입니다. `meta`에는 파일 크기 등 **스토리지 메타데이터**가 담기며, 뷰 정의는 이 안에 들어가지 않습니다.

```jsonc
{
  "data_1": {                        // 실데이터 (is_primary)
    "url": "https://cdn.example.com/....csv?X-Amz-...", // CSV 또는 JSON
    "is_primary": true,
    "file_type": "data",             // 로더는 미사용 (URL 확장자로 판별)
    "meta": {                        // 스토리지 메타 (뷰 정의 아님)
      "file_size": 33694099,
      "original_file_key": "tenants/1/files/.../....csv"
    }
  },
  "data_meta_1": {                   // CSV 방식일 때만: 뷰 정의 메타 JSON
    "url": "https://cdn.example.com/....json?X-Amz-...",
    "is_primary": false,
    "file_type": "data",
    "meta": { "file_size": 3701, "original_file_key": "tenants/1/files/.../....json" }
  }
}
```

> 통합 JSON 방식에서는 `data_meta_N` 없이 `data_N`(`.json`) 하나만 존재합니다.

### 방식 1: 통합 JSON

ULG(ULog) 형식을 차용합니다. `data_N`(`.json`) 파일 하나에 데이터와 뷰 정의가 모두 담깁니다.

`timestamps` 및 `meta.startTime` / `endTime`은 기본적으로 **Unix Epoch 기준 밀리초(epoch_ms)** 를 사용합니다.
`timeAxis.origin`으로 timestamps의 기준(`epoch`/`absolute`/`relative`/`index`)을 명시하면 별도 사전 변환 없이 그대로 렌더링됩니다.
`origin: "relative"`인 데이터(예: 부팅 후 마이크로초)는 `timeAxis.unit`으로 원시 단위를 지정합니다.
화면 표시 형식은 `timeAxis.format`으로 지정합니다.

```jsonc
{
  "meta": {
    "startTime": 1613693139891, // epoch_ms
    "endTime": 1613693426291,   // epoch_ms
    "duration": 286.4,          // 초(s)
    "sampleRate": 10,           // Hz
    "nSamples": 2865,
    "timeAxis": {
      "origin": "absolute", // → TimeAxisOrigin
      "format": "HH:mm"     // → TimeAxisFormat
    }
  },
  "timestamps": [1613693139891, 1613693139991, "..."], // epoch_ms 배열
  "tracks": [
    {
      "id": "altitude-estimate", // 임의의 문자열 (kebab-case 권장)
      "name": "트랙 이름",
      "chartType": "line", // → ChartType
      "channels": ["채널ID_1", "채널ID_2"],
      "yRange": { "min": -30, "max": 30 },           // Y축 클리핑 (선택)
      "thresholds": [                                  // 배경색 구간 (선택)
        { "max": 4.905, "color": "rgba(34,197,94,0.15)", "label": "Good" },
        { "min": 4.905, "max": 9.81, "color": "rgba(253,203,110,0.15)", "label": "Warning" }
      ]
    }
  ],
  "channels": {
    "채널ID_1": [0.1, 0.2, "..."], // timestamps와 길이 동일
    "채널ID_2": [1.0, 1.1, "..."]
  },
  "channelMeta": {
    "채널ID_1": {
      "name": "채널 표시 이름",
      "unit": "m/s²",
      "color": "#cc6600",
      "chartType": "line" // → ChartType
    }
  }
}
```

### 방식 2: CSV + 메타 JSON

`data_N`이 CSV(실측 데이터), `data_meta_N`이 뷰/시간축 정의를 담은 메타 JSON인 구성입니다.
컬럼이 많은 CSV에서 **어떤 컬럼을 어떤 트랙으로 그릴지**는 메타 JSON의 `view.subplots`가 결정합니다.

- **x축(timestamps)**: CSV의 `timestamp` 컬럼 값 (컬럼이 없으면 행 인덱스)
- **채널**: `view.subplots[].columns`에 명시된 컬럼만 로드 (라인 차트)
- **트랙**: `subplot` 하나당 트랙 하나

메타 JSON 예시:

```jsonc
{
  "format_version": "2.0",
  "total_rows": 48213,
  "filename": "AIPilot_SIM_FLT01.csv",
  "classes": ["takeoff", "hover", "landing"], // 어노테이션 클래스 (선택)
  "view": {
    "timeAxis": {         // 시간축 (선택, 미지정 시 absolute/epoch_ms)
      "origin": "relative",
      "unit": "us"        // PX4 timestamp = 부팅 후 마이크로초
    },
    "subplots": [
      { "name": "고도/자세", "columns": ["altitude", "roll", "pitch"] },
      { "name": "가속도",    "columns": ["accel_x", "accel_y", "accel_z"] }
    ]
  }
}
```

| 필드 | 타입 | 필수 | 설명 |
| --- | --- | --- | --- |
| `view.subplots` | `{ name?, columns[] }[]` | O | 트랙별로 로드/표시할 CSV 컬럼 (`name` 생략 시 `Track N`) |
| `view.timeAxis` | `{ origin, unit?, format? }` | X | 시간축 기준/단위 (미지정 시 `absolute`/epoch_ms) |
| `format_version` 또는 `total_rows` | `string` / `number` | O | 메타 포맷 식별 필드 (둘 중 하나 이상 필요) |
| `classes` | `string[]` | X | 어노테이션 클래스 목록 |
| `filename` / `csv_hash` | `string` | X | 원본 CSV 식별용 |

## 타입 상세

### ChartType

| 값 | 설명 |
| --- | --- |
| `line` | 라인 차트 |
| `scatter` | 산점도 |
| `area` | 영역 차트 |
| `stacked_area` | 누적 영역 차트 |
| `step` | 계단 차트 |
| `bar` | 막대 차트 |
| `band` | 범위(min/max) 밴드 차트 |
| `box_plot` | 박스 플롯(통계 범위 요약) |
| `waterfall` | 워터폴(증감) 차트 |

### TimeAxisOrigin

`timeAxis.origin`은 timestamps 값의 기준을 지정하며, ECharts 축 타입을 결정합니다. 지정하지 않으면 `absolute`(epoch_ms)로 간주합니다.

각 origin은 별도 사전 변환 없이 그대로 렌더링됩니다. 사용자가 표시 기준만 바꾸고 싶을 때는 `displayOrigin`을 사용합니다(데이터 origin은 유지, 포맷터에만 영향).

| 값 | 설명 |
| --- | --- |
| `absolute` | timestamps가 epoch_ms. 벽시계 시각(`format`)으로 표시 (**기본값**) |
| `epoch` | timestamps가 epoch_ms. 원시 숫자 그대로 표시 |
| `relative` | 시작(0) 기준 경과 시간. 단위는 `unit`으로 지정 (예: PX4 부팅 후 마이크로초 → `unit: "us"`) |
| `index` | timestamps를 샘플 인덱스 기준으로 표시 |

### TimeAxisUnit

`origin: "relative"`일 때 timestamps의 원시 단위를 지정합니다. 지정하지 않으면 `ms`이며, 표시 시 자동으로 밀리초로 환산됩니다.

| 값 | 단위 |
| --- | --- |
| `us` | 마이크로초 (PX4 로그 등) |
| `ms` | 밀리초 (기본값) |
| `s` | 초 |
| `min` | 분 |
| `h` | 시 |

### ChannelThreshold

트랙의 Y축에 표시되는 배경색 구간입니다. `tracks[].thresholds` 배열의 각 요소입니다.

| 필드 | 타입 | 필수 | 설명 |
| --- | --- | --- | --- |
| `color` | `string` | O | 배경색 (rgba 권장) |
| `label` | `string` | X | 구간 라벨 |
| `min` | `number` | X | Y축 최소값 (미지정 시 -∞) |
| `max` | `number` | X | Y축 최대값 (미지정 시 +∞) |

### YRange

트랙의 Y축 표시 범위를 고정합니다. `tracks[].yRange` 객체입니다.

| 필드 | 타입 | 필수 | 설명 |
| --- | --- | --- | --- |
| `min` | `number` | O | Y축 하한 |
| `max` | `number` | O | Y축 상한 |

### TimeAxisFormat

화면에 표시할 시간 형식을 지정합니다. [Day.js 포맷](https://day.js.org/docs/en/display/format) 문자열을 사용합니다.

| 예시 | 출력 |
| --- | --- |
| `HH:mm:ss` | 14:05:32 |
| `HH:mm` | 14:05 |
| `s.SSS` | 5.320 |
| `MM-DD HH:mm` | 02-18 14:05 |