Compare commits

..

No commits in common. "main" and "old" have entirely different histories.

53 changed files with 1559 additions and 7341 deletions

View file

@ -1,309 +0,0 @@
# Component Usage Documentation
This document provides a guide on how to use the components available in `src/lib/components`.
## Table of Contents
- [AffectedAreaItem](#affectedareaitem)
- [Card](#card)
- [GempaBumiAlert](#gempabumialert)
- [HexGrid](#hexgrid)
- [HexShape](#hexshape)
- [Jam](#jam)
- [MentalToxicityLevel](#mentaltoxicitylevel)
- [Modal](#modal)
- [RibCageLayout](#ribcagelayout)
- [StripeBar](#stripebar)
- [TsunamiAlert](#tsunamialert)
- [TitikGempa (Mapbox Utility)](#titikgempa-mapbox-utility)
- [TitikTsunami (Mapbox Utility)](#titiktsunami-mapbox-utility)
---
## AffectedAreaItem
Displays a countdown and status for an affected area (e.g., tsunami arrival).
**Props:**
- `kota`: `AffectedArea` object (requires `name`, `timeArrival`, `distance`, `hit`).
- `onClick?`: `(kota: AffectedArea) => void` (optional callback).
### Example:
```svelte
<script>
import AffectedAreaItem from "$lib/components/AffectedAreaItem.svelte";
const area = {
name: "Pangandaran",
timeArrival: new Date(Date.now() + 600000), // 10 minutes from now
distance: "120km",
hit: false
};
</script>
<AffectedAreaItem kota={area} />
```
---
## Card
A stylized "EWS" card component with header, content, and footer sections.
**Props:**
- `children`: `Snippet` (main content).
- `title?`: `Snippet` (header content).
- `footer?`: `Snippet` (footer content).
- `className?`: `string` (additional CSS classes).
- `onToggle?`: `() => void` (callback when header is clicked).
### Example:
```svelte
<Card>
{#snippet title()}
<h3>System Status</h3>
{/snippet}
<p>All sensors operating within normal parameters.</p>
{#snippet footer()}
<small>Last updated: 5 minutes ago</small>
{/snippet}
</Card>
```
---
## GempaBumiAlert
A full-screen overlay alert for earthquake detections.
**Props:**
- `magnitudo?`: `number` (default: 0).
- `kedalaman?`: `string` (default: "").
- `show?`: `boolean` (default: false).
- `closeInSecond?`: `number` (auto-close timer in seconds).
### Example:
```svelte
<GempaBumiAlert
show={true}
magnitudo={7.5}
kedalaman="10 Km"
closeInSecond={10}
/>
```
---
## HexGrid
A responsive honeycomb/hexagonal grid layout.
**Props:**
- `children`: `Snippet` (hex components).
- `className?`: `string`.
- `variant?`: `"pointy" | "flat"` (default: `"pointy"`).
- `hexWidth?`: `number`.
- `hexHeight?`: `number`.
- `gap?`: `number` (default: 4).
### Example:
```svelte
<HexGrid variant="pointy" hexWidth={100} hexHeight={115}>
{#each Array(10) as _, i}
<HexShape color="orange">
<p>{i + 1}</p>
</HexShape>
{/each}
</HexGrid>
```
---
## HexShape
A single hexagonal shape container.
**Props:**
- `children?`: `Snippet`.
- `className?`: `string`.
- `color?`: `string` (e.g., `"red"`, `"orange"`).
- `flatTop?`: `boolean` (default: true).
- `clipContent?`: `boolean` (default: false).
- `paddingContent?`: `number` (default: 10).
### Example:
```svelte
<HexShape color="red" clipContent={true}>
<div class="bg-black text-white p-2">
CRITICAL ALERT
</div>
</HexShape>
```
---
## Jam
A real-time clock component.
**Props:**
- `timeZone?`: `string` (default: `"local"`).
### Example:
```svelte
<div class="text-2xl font-bold">
<Jam timeZone="Asia/Jakarta" />
</div>
```
---
## MentalToxicityLevel
A complex status visualization for network channels (NERV-style).
**Props:**
- `title?`: `string` (default: `"MENTAL TOXICITY LEVEL"`).
- `headerInfo?`: `{ label: string; value: string }[]`.
- `networks?`: `NetworkData[]`.
- `className?`: `string`.
### Example:
```svelte
<MentalToxicityLevel
title="SIGNAL PURITY"
networks={[
{ id: "1", name: "IA-GE", active_channel: 10, inactive_channel: 2, total_channel: 12 },
{ id: "2", name: "GE-II", active_channel: 5, inactive_channel: 5, total_channel: 10 }
]}
/>
```
---
## Modal
A stylized modal overlay for settings or information.
**Props:**
- `show`: `boolean` (bindable).
- `title?`: `string`.
- `variant?`: `"medium" | "large"` (default: `"medium"`).
- `contentClass?`: `string`.
- `children`: `Snippet`.
### Example:
```svelte
<script>
let showModal = $state(false);
</script>
<button onclick={() => showModal = true}>Open Modal</button>
<Modal bind:show={showModal} title="SYSTEM SETTINGS">
<p>Configure emergency protocols here.</p>
</Modal>
```
---
## RibCageLayout
A "ribcage" spine layout for displaying lists of items.
**Props:**
- `items`: `any[]`.
- `nodeContent`: `Snippet<[item, context]>`.
- `connectorContent?`: `Snippet<[item, context]>`.
- `getHref?`: `(item: any) => string`.
### Example:
```svelte
<RibCageLayout items={stations}>
{#snippet nodeContent(item, ctx)}
<div class="p-2 border border-primary">
{item.name} ({ctx.side})
</div>
{/snippet}
{#snippet connectorContent(item, ctx)}
<span>{item.distance}km</span>
{/snippet}
</RibCageLayout>
```
---
## StripeBar
An animated striped background/bar component.
**Props:**
- `children?`: `Snippet`.
- `className?`: `string`.
- `color?`: `string`.
- `orientation?`: `string` (e.g., `"vertical"`).
- `loop?`: `boolean` (default: false).
- `reverse?`: `boolean` (default: false).
- `duration?`: `number` (default: 10).
- `size?`: `string` (default: `"30px"`).
### Example:
```svelte
<StripeBar
loop={true}
orientation="vertical"
size="50px"
color="bg-red-500"
duration={5}
/>
```
---
## TsunamiAlert
A full-screen overlay alert for tsunami warnings.
**Props:**
- `infoTsunami`: `InfoTsunami` object.
- `closeInSecond?`: `number` (default: 0).
### Example:
```svelte
<TsunamiAlert
infoTsunami={{
level: "AWAS",
message: "Potensi tsunami besar terdeteksi di Pantai Selatan Jawa.",
lng: 108.5,
lat: -8.5
}}
closeInSecond={30}
/>
```
---
## TitikGempa (Mapbox Utility)
Class to manage earthquake markers and waves on a Mapbox map.
### Example:
```typescript
import { TitikGempa } from "$lib/components/TitikGempa";
const earthquake = new TitikGempa("quake-1", infoGempa, {
map: mapObject,
showMarker: true,
showPopup: true,
pWaveSpeed: 8.0,
sWaveSpeed: 4.0,
showPopUpInSecond: 1,
closePopUpInSecond: 5
});
```
---
## TitikTsunami (Mapbox Utility)
Class to manage tsunami markers on a Mapbox map.
### Example:
```typescript
import { TitikTsunami } from "$lib/components/TitikTsunami";
const tsunami = new TitikTsunami("tsunami-1", infoTsunami, {
map: mapObject,
showMarker: true,
showPopup: true,
description: "Coastal Warning Issued"
});
```

26
LICENSE
View file

@ -1,26 +0,0 @@
MIT License (Modified)
Copyright (c) 2026 Bagus Indrayana
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
1. If the Software (including any part of the code or data) is modified by the
user or deployer, the requirement to include the above copyright notice
and this permission notice is waived.
2. If the Software is used or deployed in its original form without any
modifications to the code or data, the above copyright notice and this
permission notice MUST be included in all copies, and credit must be given
to the original author or original repository (https://github.com/bagusindrayana/ews-concept-new).
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -85,7 +85,3 @@ This application relies on two distinct external data sources for real-time oper
- **Purpose**: A WebSocket server that broadcasts structured alert data regarding recent earthquakes, parameters (magnitude, depth, location), and potential tsunami warnings.
- **Usage**: Triggers the UI popups, updates the recent earthquake list, and displays alert banners.
- **Environment Variable**: `PUBLIC_SOCKET_DATA_URL` (default: `ws://localhost:8081`)
## Support Me!
[![Support me on Sociabuzz](https://img.shields.io/badge/Support%20Me-Sociabuzz-orange?style=for-the-badge&logo=buymeacoffee&logoColor=white)](https://sociabuzz.com/bagusindrayana/tribe)

View file

@ -1,75 +0,0 @@
# Earthquake Data Source Documentation (SOURCE_DATA)
This document explains how to manage and modify earthquake data sources in the EWS Concept application. It covers manual data fetching functions and the dynamic configuration system using JSON.
## 1. Manual Fetching Functions
The `EarthquakeDataService` provides several methods to fetch and normalize data manually. These specialized functions handle specific endpoints with hardcoded transformation logic.
### Available Functions
- **`fetchTitikGempa(existingIds?)`**: Fetches historical/all earthquake points from `gempaQL.json`.
- **`fetchGempaDirasakan()`**: Fetches the latest "felt" earthquake from `datagempa.json`.
- **`fetchGempaKecil()`**: Fetches the latest "small" earthquake from `lastQL.json`.
- **`fetchGempaLive(existingIds?)`**: Fetches the last 30 earthquake events via XML from `live30event.xml`.
- **`fetchTsunamiEvents()`**: Fetches tsunami alert data from `last30tsunamievent.xml`.
### Example Usage
```typescript
import { EarthquakeDataService } from '$lib/services/earthquakeDataService';
const service = new EarthquakeDataService();
// Manual fetch example
const dirasakan = await service.fetchGempaDirasakan();
console.log(dirasakan.info.place); // Normalized location name
```
---
## 2. Dynamic Configuration via JSON (`source-data.json`)
The application also supports a dynamic data mapping system controlled by `src/lib/config/source-data.json`. This allows adding new sources without changing the service code.
### Configuration Structure (`DataMappingConfig`)
| Field | Type | Description |
| :--- | :--- | :--- |
| `id` | `string` | Unique identifier for the source. |
| `category` | `string` | Data category: `"all"`, `"feel"`, or `"small"`. |
| `source_url` | `string` | The API endpoint URL (JSON or XML). |
| `type` | `string` | Data type: `"json"` or `"xml"`. |
| `single_data` | `boolean` | `true` if it's a single object, `false` if it's a list. |
| `attribute` | `string` | Path to the data root (e.g., `"features"` for GeoJSON). |
| `data_mapping` | `object` | Mapping from raw API fields to `InfoGempa` properties. |
### Data Transformation Functions
Within `data_mapping`, you can use the `func` array to apply transformations:
- `split`: Splits a string. Parameters: `{ "separator": ",", "index": 0 }`.
- `replace`: Replaces a substring. Parameters: `{ "from": "WIB", "to": "" }`.
- `toFloat` / `toInt`: Numeric conversion.
- `fromISO` / `fromFormat`: Parses strings into DateTime objects.
- `utcSqlToJakarta`: Specifically converts UTC SQL timestamps to Jakarta time.
- `formatReadable`: Formats DateTime to `yyyy-MM-dd HH:mm:ss`.
- `template`: Constructs strings using variables. Parameters: `{ "format": "${6} - ${0}" }`.
- `toMmi`: Calculates MMI value based on time.
---
## 3. Unified Initialization
The `initializeAllEarthquakes` method combines both systems. It fetches data from all sources defined in the config (or custom configs provided) and merges/deduplicates them.
```typescript
const result = await service.initializeAllEarthquakes();
// result.infoList contains merged and sorted data from multiple sources
```
### Why use `initializeAllEarthquakes`?
1. **Deduplication**: Automatically handles overlapping data from different sources using `id`.
2. **Standardization**: Applies the same normalization rules across JSON and XML sources.
3. **Efficiency**: Fetches all sources concurrently.

View file

@ -1,9 +1,8 @@
{
"name": "ews-concept-new",
"private": true,
"version": "0.0.2",
"version": "0.0.1",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@ -13,7 +12,6 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@iconify/svelte": "^5.2.1",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-cloudflare": "^7.2.8",
"@sveltejs/kit": "^2.50.2",
@ -23,26 +21,23 @@
"@types/mapbox-gl": "^3.1.0",
"@types/ws": "^8.18.1",
"@ubermanu/sveltekit-websocket": "^0.3.3",
"devalue": ">=5.6.4",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"typescript": "^5.9.3",
"undici": ">=7.24.0",
"vite": "^7.3.1"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.5.4",
"@tailwindcss/vite": "^4.2.1",
"@turf/turf": "^6.5",
"fast-xml-parser": ">=5.5.7",
"html-to-image": "^1.11.13",
"fast-xml-parser": "^4.3.6",
"luxon": "^3.4.4",
"mapbox-gl": "^3.2.0",
"mapbox-gl-animated-popup": "^0.4.0",
"seisplotjs": "^3.2.1",
"socket.io-client": "^4.8.1",
"socket.io-client": "^4.7.5",
"svelte-highlight": "^7.9.0",
"tailwindcss": "^4.2.1",
"ws": "^8.19.0"
}
}
}

83
pnpm-lock.yaml generated
View file

@ -18,11 +18,8 @@ importers:
specifier: ^6.5
version: 6.5.0
fast-xml-parser:
specifier: '>=5.5.7'
version: 5.5.7
html-to-image:
specifier: ^1.11.13
version: 1.11.13
specifier: ^4.3.6
version: 4.5.4
luxon:
specifier: ^3.4.4
version: 3.7.2
@ -36,7 +33,7 @@ importers:
specifier: ^3.2.1
version: 3.2.1
socket.io-client:
specifier: ^4.8.1
specifier: ^4.7.5
version: 4.8.3
svelte-highlight:
specifier: ^7.9.0
@ -48,9 +45,6 @@ importers:
specifier: ^8.19.0
version: 8.19.0
devDependencies:
'@iconify/svelte':
specifier: ^5.2.1
version: 5.2.1(svelte@5.53.10)
'@sveltejs/adapter-auto':
specifier: ^7.0.0
version: 7.0.1(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.10)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.10)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)))
@ -78,9 +72,6 @@ importers:
'@ubermanu/sveltekit-websocket':
specifier: ^0.3.3
version: 0.3.3(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.10)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.10)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)))(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1))
devalue:
specifier: '>=5.6.4'
version: 5.6.4
svelte:
specifier: ^5.51.0
version: 5.53.10
@ -90,9 +81,6 @@ importers:
typescript:
specifier: ^5.9.3
version: 5.9.3
undici:
specifier: '>=7.24.0'
version: 7.24.4
vite:
specifier: ^7.3.1
version: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)
@ -308,14 +296,6 @@ packages:
cpu: [x64]
os: [win32]
'@iconify/svelte@5.2.1':
resolution: {integrity: sha512-zHmsIPmnIhGd5gc95bNN5FL+GifwMZv7M2rlZEpa7IXYGFJm/XGHdWf6PWQa6OBoC+R69WyiPO9NAj5wjfjbow==}
peerDependencies:
svelte: '>5.0.0'
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
@ -1410,8 +1390,8 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
devalue@5.6.4:
resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==}
devalue@5.6.3:
resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
@ -1463,11 +1443,8 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fast-xml-builder@1.1.4:
resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==}
fast-xml-parser@5.5.7:
resolution: {integrity: sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==}
fast-xml-parser@4.5.4:
resolution: {integrity: sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==}
hasBin: true
fdir@6.5.0:
@ -1544,9 +1521,6 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@ -1760,10 +1734,6 @@ packages:
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
path-expression-matcher@1.1.3:
resolution: {integrity: sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==}
engines: {node: '>=14.0.0'}
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@ -1930,8 +1900,8 @@ packages:
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
strnum@2.2.0:
resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==}
strnum@1.1.2:
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
supercluster@8.0.1:
resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==}
@ -2008,10 +1978,6 @@ packages:
resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==}
engines: {node: '>=20.18.1'}
undici@7.24.4:
resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
@ -2254,13 +2220,6 @@ snapshots:
'@esbuild/win32-x64@0.27.3':
optional: true
'@iconify/svelte@5.2.1(svelte@5.53.10)':
dependencies:
'@iconify/types': 2.0.0
svelte: 5.53.10
'@iconify/types@2.0.0': {}
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
@ -2573,7 +2532,7 @@ snapshots:
'@types/cookie': 0.6.0
acorn: 8.16.0
cookie: 0.6.0
devalue: 5.6.4
devalue: 5.6.3
esm-env: 1.2.2
kleur: 4.1.5
magic-string: 0.30.21
@ -3694,7 +3653,7 @@ snapshots:
detect-libc@2.1.2: {}
devalue@5.6.4: {}
devalue@5.6.3: {}
dunder-proto@1.0.1:
dependencies:
@ -3772,15 +3731,9 @@ snapshots:
estree-walker@2.0.2: {}
fast-xml-builder@1.1.4:
fast-xml-parser@4.5.4:
dependencies:
path-expression-matcher: 1.1.3
fast-xml-parser@5.5.7:
dependencies:
fast-xml-builder: 1.1.4
path-expression-matcher: 1.1.3
strnum: 2.2.0
strnum: 1.1.2
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
@ -3858,8 +3811,6 @@ snapshots:
highlight.js@11.11.1: {}
html-to-image@1.11.13: {}
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@ -4059,8 +4010,6 @@ snapshots:
pako@1.0.11: {}
path-expression-matcher@1.1.3: {}
path-parse@1.0.7: {}
path-to-regexp@6.3.0: {}
@ -4305,7 +4254,7 @@ snapshots:
dependencies:
safe-buffer: 5.1.2
strnum@2.2.0: {}
strnum@1.1.2: {}
supercluster@8.0.1:
dependencies:
@ -4342,7 +4291,7 @@ snapshots:
aria-query: 5.3.1
axobject-query: 4.1.0
clsx: 2.1.1
devalue: 5.6.4
devalue: 5.6.3
esm-env: 1.2.2
esrap: 2.2.3
is-reference: 3.0.3
@ -4385,8 +4334,6 @@ snapshots:
undici@7.18.2: {}
undici@7.24.4: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3

View file

@ -5,10 +5,9 @@
@import "./lib/styles/animations.css";
@import "./lib/styles/components.css";
@import "./lib/styles/variants.css";
/* @import "./lib/styles/status.css";
@import "./lib/styles/hex-grid.css"; */
@import "./lib/styles/status.css";
@import "./lib/styles/hex-grid.css";
/* refactor code */
/* @import "./lib/styles/stripe-bar.css";
@import "./lib/styles/hex-shape.css";
@import "./lib/styles/components/RibLayout.css"; */
@import "./lib/styles/stripe-bar.css";
@import "./lib/styles/hex-shape.css";

View file

@ -1,5 +1,4 @@
<script lang="ts">
import "../styles/components/Card.css";
import type { Snippet } from "svelte";
interface Props {
@ -15,10 +14,10 @@
let open = $state(false);
</script>
<div class="ews-card {className}" class:open>
<div class="ews-card bordered-red {className}" class:open>
{#if title}
<div
class="ews-card-header"
class="ews-card-header bordered-red-bottom"
onclick={() => {
open = !open;
onToggle?.();
@ -27,11 +26,11 @@
{@render title()}
</div>
{/if}
<div class="ews-card-content">
<div class="ews-card-content p-1 lg:p-2 custom-scrollbar">
{@render children()}
</div>
{#if footer}
<div class="ews-card-footer">
<div class="ews-card-footer bordered-red-top">
{@render footer()}
</div>
{/if}

View file

@ -1,7 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import StripeBar from "./StripeBar.svelte";
import InfiniteScroll from "./InfiniteScroll.svelte";
interface GempaBumiAlertProps {
magnitudo?: number;
@ -54,7 +52,7 @@
class="warning-black opacity-0 blink animation-fast animation-delay-2"
></div>
<div class="flex flex-col font-bold text-center text-black">
<span class="text-xl">WARNING</span>
<span class="text-xl">PERINGATAN</span>
<span class="text-xs">Gempa Bumi Terdeteksi</span>
</div>
<div
@ -105,53 +103,16 @@
></div>
</div>
</div>
<!-- <div class="absolute top-0">
<StripeBar loop={true} reverse={true} duration={20}></StripeBar>
</div>
<div class="absolute bottom-0">
<StripeBar loop={true} duration={20}></StripeBar>
</div> -->
<div class="flex w-full absolute top-0 left-0 right-0" style="z-index: 2;">
<StripeBar className="my-2 " size="100px"></StripeBar>
<StripeBar className="my-2 -scale-x-100 " size="100px"></StripeBar>
<div
class="absolute top-0 left-0 bottom-0 right-0 flex items-center justify-center text-center"
>
<div class="p-1 bg-black rounded-lg w-full">
<div class="bordered-red bg-black p-2 w-full text-primary">
<InfiniteScroll speed={100} gap={48}>
{#snippet children()}
<div class="flex flex-col text-center px-4">
<b class="text-3xl" style="line-height: 0.8;">EARTHQUAKE</b>
</div>
{/snippet}
</InfiniteScroll>
</div>
</div>
<div class="stripe top-0">
<div class="stripe-wrapper">
<div class="stripe-bar loop-stripe-reverse"></div>
<div class="stripe-bar loop-stripe-reverse"></div>
</div>
</div>
<div
class="flex w-full absolute bottom-0 left-0 right-0"
style="z-index: 2;"
>
<StripeBar className="my-2 " size="100px"></StripeBar>
<StripeBar className="my-2 -scale-x-100 " size="100px"></StripeBar>
<div
class="absolute top-0 left-0 bottom-0 right-0 flex items-center justify-center text-center"
>
<div class="p-1 bg-black rounded-lg w-full">
<div class="bordered-red bg-black p-2 w-full text-primary">
<InfiniteScroll speed={100} gap={48} direction="right">
{#snippet children()}
<div class="flex flex-col text-center px-4">
<b class="text-3xl" style="line-height: 0.8;">EARTHQUAKE</b>
</div>
{/snippet}
</InfiniteScroll>
</div>
</div>
<div class="stripe bottom-0">
<div class="stripe-wrapper">
<div class="stripe-bar loop-stripe"></div>
<div class="stripe-bar loop-stripe"></div>
</div>
</div>
</div>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import "../styles/components/HexGrid.css";
import type { Snippet } from "svelte";
let {
@ -8,7 +7,7 @@
variant = "pointy",
hexWidth,
hexHeight,
gap = 4,
gap = 4
}: {
children: Snippet;
className?: string;
@ -39,12 +38,10 @@
if (!isFlat) {
// Pointy (Variant 1)
const rowOffsetTop = gap + -20;
const rowOffsetTop = -14;
const itemFullWidth = w + gap;
let maxCols = Math.floor(
(containerWidth + gap) / itemFullWidth,
);
let maxCols = Math.floor((containerWidth + gap) / itemFullWidth);
if (maxCols < 1) maxCols = 1;
let isOffset = false;
@ -81,18 +78,21 @@
let totalHeight = 0;
if (currentCol > 0) {
totalHeight = currentRow * (h + rowOffsetTop) + h;
totalHeight =
currentRow * (h + rowOffsetTop) + h;
} else {
totalHeight = (currentRow - 1) * (h + rowOffsetTop) + h;
totalHeight =
(currentRow - 1) * (h + rowOffsetTop) +
h;
}
node.style.height = `${totalHeight}px`;
} else {
// Flat (Variant 2)
const colAdvanceX = w * 0.75 + gap;
const rowAdvanceY = h + gap;
let maxCols =
Math.floor((containerWidth - w) / colAdvanceX) + 1;
let maxCols = Math.floor((containerWidth - w) / colAdvanceX) + 1;
if (containerWidth < w) maxCols = 1;
let currentCol = 0;
@ -149,6 +149,6 @@
}
</script>
<div class="ews-hex-honeycomb {className}" use:honeycombLayout>
<div class="hex-honeycomb {className}" use:honeycombLayout>
{@render children()}
</div>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import "../styles/components/HexShape.css";
import type { Snippet } from "svelte";
interface Props {
@ -22,14 +21,11 @@
</script>
<div
class="ews-hex-shape {flatTop ? 'flat-top' : ''} {color} {clipContent
class="hex-shape {flatTop ? 'flat-top' : ''} {color} {clipContent
? 'clip-content'
: ''} {className}"
: ''} flex flex-col justify-center items-center {className}"
>
<div
class="inner-content"
style={`--ews-hex-padding: ${paddingContent}px;`}
>
<div class="inner-content" style={`--hex-padding: ${paddingContent}px;`}>
{#if children}
{@render children()}
{/if}

View file

@ -1,138 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
interface Props {
/** Gap antar item (px) */
gap?: number;
/** Kecepatan scroll: pixel per detik */
speed?: number;
/** Arah scroll: 'left' = normal, 'right' = reverse */
direction?: "left" | "right";
/** Pause saat hover */
pauseOnHover?: boolean;
/** Class tambahan untuk wrapper luar */
className?: string;
children?: import("svelte").Snippet;
}
let {
gap = 24,
speed = 80,
direction = "left",
pauseOnHover = false,
className = "",
children,
}: Props = $props();
let containerEl: HTMLDivElement;
let trackEl: HTMLDivElement;
let animFrame: number;
let offset = 0;
let cloneCount = $state(1);
let singleWidth = 0;
let lastTime: number | null = null;
let paused = false;
/**
* Hitung berapa kali child perlu di-clone agar track selalu lebih lebar dari container,
* termasuk gap antar item.
*/
function calcClones() {
if (!containerEl || !trackEl) return;
const containerW = containerEl.getBoundingClientRect().width;
// Ambil lebar satu "set" original items (slot pertama)
const firstSet = trackEl.querySelector<HTMLElement>(".scroll-set");
if (!firstSet) return;
singleWidth = firstSet.getBoundingClientRect().width + gap;
// Kloning minimal agar total lebar > 2x container (agar seamless loop)
const needed = Math.ceil((containerW * 2) / singleWidth) + 1;
cloneCount = Math.max(needed, 2);
}
function tick(ts: number) {
if (!paused) {
if (lastTime !== null) {
const dt = (ts - lastTime) / 1000;
offset += speed * dt;
if (singleWidth > 0 && offset >= singleWidth) {
offset -= singleWidth;
}
}
lastTime = ts;
} else {
lastTime = ts; // reset agar tidak jump saat resume
}
if (trackEl) {
const sign = direction === "right" ? 1 : -1;
trackEl.style.transform = `translateX(${sign * offset}px)`;
}
animFrame = requestAnimationFrame(tick);
}
onMount(() => {
// Hitung clone setelah render awal
calcClones();
// Tunggu $state update (clones dirender), lalu hitung ulang
requestAnimationFrame(() => {
calcClones();
animFrame = requestAnimationFrame(tick);
});
const ro = new ResizeObserver(() => {
calcClones();
offset = 0;
lastTime = null;
});
ro.observe(containerEl);
return () => {
cancelAnimationFrame(animFrame);
ro.disconnect();
};
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="infinite-scroll-container {className}"
bind:this={containerEl}
onmouseenter={() => { if (pauseOnHover) paused = true; }}
onmouseleave={() => { if (pauseOnHover) paused = false; }}
>
<div
class="infinite-scroll-track"
bind:this={trackEl}
style="gap: {gap}px;"
>
<!--
Render (1 + cloneCount) sets: index 0 adalah original,
sisanya adalah clone agar container selalu penuh.
-->
{#each { length: 1 + cloneCount } as _, i}
<div class="scroll-set" aria-hidden={i > 0 ? "true" : undefined}>
{@render children?.()}
</div>
{/each}
</div>
</div>
<style>
.infinite-scroll-container {
overflow: hidden;
width: 100%;
position: relative;
}
.infinite-scroll-track {
display: flex;
flex-wrap: nowrap;
will-change: transform;
}
.scroll-set {
display: flex;
flex-shrink: 0;
align-items: center;
}
</style>

View file

@ -1,483 +0,0 @@
<script lang="ts">
interface NetworkData {
id: string;
name: string;
active_channel: number;
inactive_channel: number;
total_channel: number;
}
interface Props {
title?: string;
headerInfo?: { label: string; value: string }[];
networks?: NetworkData[];
className?: string;
}
let {
title = "MENTAL TOXICITY LEVEL",
headerInfo = [
{ label: "ELAPSED TIME", value: "120 min." },
{ label: "L.C.L. PURITY", value: "99.9999989%" },
],
networks = [
{
id: "network-1",
name: "NET 1",
active_channel: 2,
inactive_channel: 14,
total_channel: 16,
},
{
id: "network-2",
name: "NET 2",
active_channel: 22,
inactive_channel: 7,
total_channel: 29,
},
{
id: "network-3",
name: "NET 3",
active_channel: 12,
inactive_channel: 5,
total_channel: 17,
},
],
className = "",
}: Props = $props();
// Total bars per row — always fills 100%
const totalBars = 50;
// Get inactive percentage
function getInactivePercent(network: NetworkData): number {
if (network.total_channel === 0) return 0;
return (network.inactive_channel / network.total_channel) * 100;
}
// Color: continuous gradient from light blue/cyan to purple
// based on bar position across the full 100% width
function getBarColor(barIndex: number, network: NetworkData): string {
const inactivePct = getInactivePercent(network);
const fillBarsCount = Math.round((inactivePct / 100) * totalBars);
// If the current bar index is greater than the percentage it should fill, hide it
if (barIndex >= fillBarsCount) {
return "transparent";
}
const t = barIndex / (totalBars - 1);
// Hue: 190 (cyan/light blue) → 280 (purple)
const hue = 190 + t * 90;
// Saturation: 100% → 85%
const sat = 100 - t * 15;
// Lightness: 55% → 45%
const light = 55 - t * 10;
return `hsl(${hue}, ${sat}%, ${light}%)`;
}
function getSubjectIndex(idx: number): string {
return idx.toString().padStart(2, "0");
}
// Caution position: where inactive ratio begins to be notable (e.g. >10%)
// We show the caution marker at the percentage point where inactive begins
function getInactiveStartPosition(network: NetworkData): number {
if (network.total_channel === 0) return 100;
return (network.active_channel / network.total_channel) * 100;
}
</script>
<div class="ews-mtl-container w-full {className}">
<!-- <div class="ews-mtl-header">
<span class="ews-mtl-title">{title}</span>
<div class="ews-mtl-info">
{#each headerInfo as info}
<div class="ews-mtl-info-row">
<span class="ews-mtl-info-label">{info.label}</span>
<span class="ews-mtl-info-separator">:</span>
<span class="ews-mtl-info-value">{info.value}</span>
</div>
{/each}
</div>
</div> -->
<!-- Scale: 0% to 100% -->
<div class="ews-mtl-scale">
<div class="ews-mtl-scale-label-area"></div>
<div class="ews-mtl-scale-bar">
<div class="ews-mtl-scale-marks">
<span class="ews-mtl-scale-mark" style="left: 0%">0%</span>
<span class="ews-mtl-scale-mark" style="left: 25%">25%</span>
<span class="ews-mtl-scale-mark" style="left: 50%">50%</span>
<span class="ews-mtl-scale-mark" style="left: 75%">75%</span>
<span class="ews-mtl-scale-mark" style="left: 100%">100%</span>
</div>
<!-- <div class="ews-mtl-zone-marks">
<div class="ews-mtl-zone-caution">
<span class="ews-mtl-zone-bracket">&#x007B;</span>
<span class="ews-mtl-zone-text">CAUTION</span>
<span class="ews-mtl-zone-bracket">&#x007D;</span>
</div>
<div class="ews-mtl-zone-danger">
<span class="ews-mtl-zone-bracket">&#x007B;</span>
<span class="ews-mtl-zone-text">DANGER</span>
</div>
</div> -->
</div>
</div>
<!-- Network Rows -->
{#each networks as network, idx}
{@const inactivePct = getInactivePercent(network)}
{@const activeStart = getInactiveStartPosition(network)}
<div class="flex flex-row-reverse gap-6">
<div>
<span class="ews-mtl-marker-bracket">|</span>
<span class="ews-mtl-marker-label">DANGER</span>
<span class="ews-mtl-marker-bracket">|</span>
</div>
<div>
<span class="ews-mtl-marker-bracket">|</span>
<span class="ews-mtl-marker-label">CAUTION</span>
<span class="ews-mtl-marker-bracket">|</span>
</div>
</div>
<div class="ews-mtl-subject-row">
<div class="ews-mtl-subject-info">
<span class="ews-mtl-subject-label">NETWORK</span>
<span class="ews-mtl-subject-id"
>{network.name.toUpperCase()}</span
>
<!-- <span class="ews-mtl-subject-id">{getSubjectIndex(idx)}</span> -->
<span class="ews-mtl-subject-name"
>{network.name.toUpperCase()}</span
>
</div>
<div class="ews-mtl-bar-area">
<div class="ews-mtl-bars">
{#each { length: totalBars } as _, i}
<div
class="ews-mtl-bar"
style="background-color: {getBarColor(i, network)};"
></div>
{/each}
</div>
<!-- CAUTION marker at where inactive channels begin -->
<!-- {#if network.inactive_channel > 0}
<div class="ews-mtl-row-marker" style="left: {activeStart}%">
<span class="ews-mtl-marker-bracket">|</span>
<span class="ews-mtl-marker-label">CAUTION</span>
<span class="ews-mtl-marker-bracket">|</span>
</div>
{/if} -->
<!-- <div class="ews-mtl-row-marker" style="left: 70%">
<span class="ews-mtl-marker-bracket">|</span>
<span class="ews-mtl-marker-label">CAUTION</span>
<span class="ews-mtl-marker-bracket">|</span>
</div> -->
<!-- Danger marker at the end -->
<!-- <div class="ews-mtl-row-marker danger" style="left: 100%">
<span class="ews-mtl-marker-bracket">|</span>
<span class="ews-mtl-marker-label">DANGER</span>
<span class="ews-mtl-marker-bracket">|</span>
</div> -->
<!-- Inactive percentage and channel stats -->
<!-- <div class="ews-mtl-channel-stats">
<span class="ews-mtl-inactive-pct"
>{Math.round(inactivePct)}%</span
>
<span class="ews-mtl-stat-detail"
>({network.inactive_channel}/{network.total_channel}
inactive)</span
>
</div> -->
</div>
</div>
{/each}
</div>
<style>
.ews-mtl-container {
position: relative;
padding: 24px 28px;
overflow: hidden;
}
/* Header */
.ews-mtl-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 18px;
flex-wrap: wrap;
gap: 8px;
}
.ews-mtl-title {
font-size: 1.4em;
font-weight: bold;
color: var(--red);
/* text-shadow:
0 0 8px rgba(255, 68, 34, 0.6),
0 0 20px rgba(255, 68, 34, 0.3); */
letter-spacing: 2px;
}
.ews-mtl-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.ews-mtl-info-row {
display: flex;
gap: 6px;
font-size: 0.8em;
letter-spacing: 1px;
}
.ews-mtl-info-label {
color: var(--orange);
/* text-shadow: 0 0 6px rgba(255, 102, 51, 0.5); */
}
.ews-mtl-info-separator {
color: var(--orange);
}
.ews-mtl-info-value {
color: var(--orange);
/* text-shadow: 0 0 6px rgba(255, 102, 51, 0.5); */
}
/* Scale */
.ews-mtl-scale {
display: flex;
margin-bottom: 10px;
}
.ews-mtl-scale-label-area {
width: 100px;
flex-shrink: 0;
}
.ews-mtl-scale-bar {
flex: 1;
position: relative;
min-height: 12px;
}
.ews-mtl-scale-marks {
position: relative;
height: 16px;
}
.ews-mtl-scale-mark {
position: absolute;
font-size: 0.7em;
color: var(--orange);
/* text-shadow: 0 0 4px rgba(255, 102, 51, 0.4); */
transform: translateX(-50%);
letter-spacing: 1px;
white-space: nowrap;
}
.ews-mtl-scale-mark:first-child {
transform: translateX(0);
}
.ews-mtl-scale-mark:last-child {
transform: translateX(-100%);
}
.ews-mtl-zone-marks {
position: relative;
height: 18px;
display: flex;
gap: 20px;
padding-left: 55%;
}
.ews-mtl-zone-caution {
display: flex;
align-items: center;
font-size: 0.6em;
color: var(--orange);
/* text-shadow: 0 0 6px rgba(255, 136, 0, 0.5); */
letter-spacing: 1px;
white-space: nowrap;
}
.ews-mtl-zone-danger {
display: flex;
align-items: center;
font-size: 0.6em;
color: var(--red);
/* text-shadow: 0 0 6px rgba(255, 51, 51, 0.5); */
letter-spacing: 1px;
white-space: nowrap;
}
.ews-mtl-zone-bracket {
font-size: 1.2em;
}
.ews-mtl-zone-text {
margin: 0 3px;
}
/* Subject / Network Rows */
.ews-mtl-subject-row {
display: flex;
align-items: center;
margin-bottom: 10px;
position: relative;
}
.ews-mtl-subject-info {
width: 100px;
flex-shrink: 0;
display: flex;
flex-direction: column;
line-height: 1.15;
}
.ews-mtl-subject-label {
font-size: 1em;
color: var(--orange);
/* text-shadow: 0 0 4px rgba(255, 68, 34, 0.5); */
letter-spacing: 2px;
}
.ews-mtl-subject-id {
font-size: 2.4em;
font-weight: bold;
color: var(--orange);
/* text-shadow:
0 0 10px rgba(255, 68, 34, 0.7),
0 0 25px rgba(255, 68, 34, 0.3); */
letter-spacing: 3px;
line-height: 1;
}
.ews-mtl-subject-name {
font-size: 1em;
color: var(--orange);
/* text-shadow: 0 0 4px rgba(255, 68, 34, 0.4); */
letter-spacing: 1.5px;
}
/* Bar Area */
.ews-mtl-bar-area {
flex: 1;
position: relative;
height: 68px;
}
.ews-mtl-bars {
display: flex;
gap: 3px;
height: 100%;
align-items: stretch;
}
.ews-mtl-bar {
flex: 1;
min-width: 5px;
border-radius: 1px;
box-shadow: 0 0 3px rgba(0, 200, 220, 0.15);
}
/* Row zone markers */
.ews-mtl-row-marker {
position: absolute;
top: -2px;
display: flex;
align-items: center;
font-size: 0.55em;
color: var(--orange);
/* text-shadow: 0 0 5px rgba(255, 136, 0, 0.5); */
transform: translateX(-50%);
letter-spacing: 1px;
white-space: nowrap;
pointer-events: none;
}
.ews-mtl-row-marker.danger {
color: var(--red);
/* text-shadow: 0 0 5px rgba(255, 51, 51, 0.5); */
}
.ews-mtl-marker-bracket {
font-size: 1.1em;
}
.ews-mtl-marker-label {
margin: 0 2px;
}
/* Channel stats */
.ews-mtl-channel-stats {
position: absolute;
bottom: 2px;
right: 4px;
font-size: 0.7em;
letter-spacing: 1px;
pointer-events: none;
display: flex;
align-items: baseline;
gap: 6px;
}
.ews-mtl-inactive-pct {
color: #cc44ff;
text-shadow: 0 0 6px rgba(204, 68, 255, 0.5);
font-weight: bold;
font-size: 1.1em;
}
.ews-mtl-stat-detail {
color: #888;
font-size: 0.85em;
}
/* Responsive */
@media (max-width: 600px) {
.ews-mtl-container {
padding: 14px 16px;
}
.ews-mtl-title {
font-size: 0.95em;
}
.ews-mtl-subject-info {
width: 70px;
}
.ews-mtl-subject-id {
font-size: 1.6em;
}
.ews-mtl-scale-label-area {
width: 70px;
}
.ews-mtl-bar-area {
height: 54px;
}
.ews-mtl-bars {
gap: 2px;
}
.ews-mtl-bar {
min-width: 3px;
}
}
</style>

View file

@ -1,57 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import StripeBar from "./StripeBar.svelte";
let {
show = $bindable(false),
title = "",
variant = "medium",
contentClass = "",
children,
}: {
show: boolean;
title?: string;
variant?: "medium" | "large";
contentClass?: string;
children: Snippet;
} = $props();
function close() {
show = false;
}
</script>
{#if show}
<div class="settings-modal-overlay" onclick={close} role="presentation">
<div
class="settings-modal ews-card ews-card-red {variant === 'large'
? '!w-11/12 !max-w-4xl'
: ''}"
onclick={(e) => e.stopPropagation()}
role="presentation"
>
<div class="ews-card-header bordered-red-bottom overflow-hidden">
<StripeBar></StripeBar>
<div
class="absolute top-0 bottom-0 left-0 right-0 flex justify-between items-center px-3"
>
<p class="p-1 bg-black font-bold text-sm ews-title text-3xl">
{title}
</p>
<button
class="bg-black px-2 py-1 cursor-pointer"
style="color:#e60003"
onclick={close}>X</button
>
</div>
</div>
<div
class="ews-card-content {variant === 'large'
? 'p-4'
: 'p-1 lg:p-2 p-4'} {contentClass}"
>
{@render children()}
</div>
</div>
</div>
{/if}

View file

@ -1,134 +0,0 @@
<script lang="ts">
interface Props {
min: number;
max: number;
step: number;
low: number;
high: number;
}
let { min, max, step, low = $bindable(), high = $bindable() }: Props = $props();
let sliderElement: HTMLDivElement;
function handlePointerDown(e: PointerEvent, type: 'low' | 'high') {
const moveHandler = (moveEvent: PointerEvent) => {
const rect = sliderElement.getBoundingClientRect();
let percentage = (moveEvent.clientX - rect.left) / rect.width;
percentage = Math.max(0, Math.min(1, percentage));
let newValue = min + percentage * (max - min);
newValue = Math.round(newValue / step) * step;
if (type === 'low') {
low = Math.min(newValue, high - step);
} else {
high = Math.max(newValue, low + step);
}
};
const upHandler = () => {
window.removeEventListener('pointermove', moveHandler);
window.removeEventListener('pointerup', upHandler);
};
window.addEventListener('pointermove', moveHandler);
window.addEventListener('pointerup', upHandler);
// Immediate move to click position
moveHandler(e);
}
let lowPercent = $derived(((low - min) / (max - min)) * 100);
let highPercent = $derived(((high - min) / (max - min)) * 100);
</script>
<div class="range-slider-container">
<div bind:this={sliderElement} class="range-slider-track">
<div
class="range-slider-fill"
style="left: {lowPercent}%; right: {100 - highPercent}%"
></div>
<div
class="range-slider-handle"
style="left: {lowPercent}%"
onpointerdown={(e) => handlePointerDown(e, 'low')}
role="slider"
aria-valuenow={low}
tabindex="0"
>
<div class="handle-glow"></div>
</div>
<div
class="range-slider-handle"
style="left: {highPercent}%"
onpointerdown={(e) => handlePointerDown(e, 'high')}
role="slider"
aria-valuenow={high}
tabindex="0"
>
<div class="handle-glow"></div>
</div>
</div>
</div>
<style>
.range-slider-container {
width: 100%;
height: 24px;
display: flex;
align-items: center;
padding: 0 10px;
touch-action: none;
}
.range-slider-track {
position: relative;
width: 100%;
height: 4px;
background: rgba(255, 165, 0, 0.1);
border: 1px solid rgba(255, 165, 0, 0.2);
border-radius: 2px;
}
.range-slider-fill {
position: absolute;
height: 100%;
background: var(--orange);
box-shadow: 0 0 10px var(--orange);
}
.range-slider-handle {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
background: black;
border: 2px solid var(--orange);
transform: translate(-50%, -50%) rotate(45deg);
cursor: pointer;
z-index: 2;
transition: transform 0.1s ease;
}
.range-slider-handle:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.2);
}
.handle-glow {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--orange);
opacity: 0.3;
filter: blur(4px);
}
.range-slider-handle:active {
background: var(--orange);
}
</style>

View file

@ -1,162 +0,0 @@
<script lang="ts">
import "../styles/components/RibLayout.css";
import { onMount, onDestroy } from "svelte";
import type { Snippet } from "svelte";
let {
items = [],
nodeContent,
connectorContent,
getHref,
maxBranches,
}: {
items: any[];
maxBranches?: number;
nodeContent: Snippet<
[
any,
{
side: "left" | "right";
branchIndex: number;
index: number;
delay: number;
},
]
>;
connectorContent?: Snippet<
[
any,
{
side: "left" | "right";
branchIndex: number;
index: number;
delay: number;
},
]
>;
getHref?: (item: any) => string;
} = $props();
let branchCount = $state(5);
let windowWidth = $state(0);
function getBranchCount(width: number): number {
let count = 5;
if (width < 768) count = 1;
else if (width < 1024) count = 2;
else if (width < 1300) count = 4;
if (maxBranches !== undefined) {
return Math.min(count, maxBranches);
}
return count;
}
function handleResize() {
windowWidth = typeof window !== "undefined" ? window.innerWidth : 0;
branchCount = getBranchCount(windowWidth);
}
let chunkedItems = $derived.by(() => {
if (items.length === 0) return [];
const count = Math.max(1, branchCount);
const result = [];
const itemsPerBranch = Math.ceil(items.length / count);
for (let i = 0; i < items.length; i += itemsPerBranch) {
result.push(items.slice(i, i + itemsPerBranch));
}
return result;
});
onMount(() => {
handleResize();
window.addEventListener("resize", handleResize);
});
onDestroy(() => {
if (typeof window !== "undefined") {
window.removeEventListener("resize", handleResize);
}
});
</script>
<div class="ews-rib-layout">
{#each chunkedItems as branchItems, branchIndex}
<div class="ews-rib-layout__branch">
<!-- Central Spine -->
<div
class="ews-rib-layout__spine line-central"
style="animation-delay: {branchIndex * 200}ms;"
></div>
<!-- Iterate in pairs essentially by grouping them two by two -->
<div class="ews-rib-layout__grid">
{#each branchItems as item, index}
{@const side = index % 2 === 0 ? "left" : "right"}
{@const delay = (branchIndex + 1) * (index + 1) * 10}
<svelte:element
this={getHref ? "a" : "div"}
href={getHref?.(item)}
class="ews-rib-layout__node {side === 'left'
? 'ews-rib-layout__node--left node'
: 'ews-rib-layout__node--right node-flip'}"
>
{#if side === "left"}
<div class="ews-rib-layout__node-content parent-node">
{@render nodeContent(item, { side, branchIndex, index, delay })}
</div>
<div
class="ews-rib-layout__connector-wrapper ews-rib-layout__connector-wrapper--left line"
>
<div
class="ews-rib-layout__connector-line line-node"
style="animation-delay: {delay}ms;"
></div>
{#if connectorContent}
<div
class="ews-rib-layout__connector-text ews-rib-layout__connector-text--left fade-in animation-delay-5"
>
{@render connectorContent(item, {
side,
branchIndex,
index,
delay,
})}
</div>
{/if}
</div>
{:else}
<div
class="ews-rib-layout__connector-wrapper ews-rib-layout__connector-wrapper--right"
>
<div
class="ews-rib-layout__connector-line line-node"
style="animation-delay: {delay}ms;"
></div>
{#if connectorContent}
<div
class="ews-rib-layout__connector-text ews-rib-layout__connector-text--right fade-in animation-delay-5"
>
{@render connectorContent(item, {
side,
branchIndex,
index,
delay,
})}
</div>
{/if}
</div>
<div
class="ews-rib-layout__node-content ews-rib-layout__node-content--right parent-node flip"
>
{@render nodeContent(item, { side, branchIndex, index, delay })}
</div>
{/if}
</svelte:element>
{/each}
</div>
</div>
{/each}
</div>

View file

@ -1,71 +0,0 @@
<script lang="ts">
import { serialStore } from "$lib/stores/serialStore";
import { fade } from "svelte/transition";
import StripeBar from "./StripeBar.svelte";
let status = $derived($serialStore.status);
let error = $derived($serialStore.error);
const statusProps = {
connected: {
color: "green",
label: "LINKED",
loop: true,
reverse: false,
},
disconnected: {
color: "",
label: "LINK ESP32",
loop: false,
reverse: false,
},
unsupported: {
color: "red",
label: "NO SERIAL",
loop: false,
reverse: false,
},
connecting: {
color: "blue",
label: "LINKING...",
loop: true,
reverse: true,
},
};
async function handleConnect() {
if (status === "connected") {
await serialStore.disconnect();
} else {
await serialStore.connect();
}
}
</script>
<div class="flex flex-col items-start no-snapshot">
<button
onclick={handleConnect}
disabled={status === "unsupported" || status === "connecting"}
class="ews-btn ews-btn-primary w-full"
>
{statusProps[status].label}
</button>
{#if status === "connected"}
<button
onclick={() => serialStore.testConnection()}
class="ews-btn ews-btn-danger w-full"
>
TEST BEEP / HEARTBEAT
</button>
{/if}
{#if error}
<div
transition:fade
class="bg-red-950/80 border border-red-500/50 text-red-400 px-3 py-1 text-[10px] font-mono uppercase"
>
ERROR: {error}
</div>
{/if}
</div>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import "../styles/components/StripeBar.css";
import type { Snippet } from "svelte";
interface Props {
@ -10,7 +9,6 @@
loop?: boolean;
reverse?: boolean;
duration?: number;
size?: string;
}
let {
@ -21,24 +19,20 @@
loop = false,
reverse = false,
duration = 10,
size = "30px",
}: Props = $props();
</script>
<div style="overflow: hidden;" class={className}>
<div
class="ews-stripe-wrapper {orientation}"
style="{orientation == 'vertical' ? 'width' : 'height'}: {size};"
>
<div class="overflow-hidden {className}">
<div class="stripe-wrapper {orientation}">
<div
class="ews-stripe-bar {color} {orientation} {loop
class="stripe-bar {color} {orientation} {loop
? 'loop-stripe'
: ''}{orientation ? '-' + orientation : ''} {reverse
? 'reverse'
: ''} anim-duration-{duration}"
></div>
<div
class="ews-stripe-bar {color} {orientation} {loop
class="stripe-bar {color} {orientation} {loop
? 'loop-stripe'
: ''}{orientation ? '-' + orientation : ''} {reverse
? 'reverse'

View file

@ -1,718 +0,0 @@
<script module lang="ts">
export type ThreadTone = "normal" | "danger" | "muted";
</script>
<script lang="ts">
// import { fade } from "svelte/transition";
type ThreadVariant = "spine" | "threaded";
interface ThreadItem {
id: string;
label: string;
level?: number;
tone?: ThreadTone;
collapsed?: boolean;
children?: ThreadItem[];
}
interface Props {
items?: ThreadItem[];
variant?: ThreadVariant;
className?: string;
nodeWidth?: number;
rowHeight?: number;
indent?: number;
tone?: "neutral" | "primary" | "danger";
gap?: number;
expandable?: boolean;
animated?: boolean;
collapseAll?: boolean;
onToggle?: (id: string, collapsed: boolean) => void;
}
const defaultItemsByVariant: Record<ThreadVariant, ThreadItem[]> = {
spine: [
{
id: "spine-1",
label: "Primary Alert Message",
level: 1,
tone: "normal",
},
{
id: "spine-2",
label: "Operator Verification Note",
level: 2,
tone: "muted",
},
{
id: "spine-3",
label: "Follow-up Action Detail",
level: 3,
tone: "muted",
},
],
threaded: [
{
id: "thread-1",
label: "Primary Alert Message",
level: 1,
tone: "normal",
children: [
{
id: "thread-1-1",
label: "Operator Verification Note",
level: 2,
tone: "muted",
},
{
id: "thread-1-2",
label: "Follow-up Action Detail",
level: 3,
tone: "muted",
},
],
},
{
id: "thread-2",
label: "Secondary Discussion",
level: 1,
tone: "muted",
children: [
{
id: "thread-2-1",
label: "Nested Reply A",
level: 2,
tone: "normal",
},
{
id: "thread-2-2",
label: "Nested Reply B",
level: 2,
tone: "muted",
children: [
{
id: "thread-2-2-1",
label: "Deep Nested Reply",
level: 3,
tone: "muted",
},
],
},
],
},
{
id: "thread-3",
label: "Final Comment",
level: 1,
tone: "muted",
},
],
};
let {
items = [],
variant = "spine",
className = "",
nodeWidth = 280,
rowHeight = 42,
indent = 21,
tone = "primary",
gap = 16,
expandable = true,
animated = true,
collapseAll = false,
onToggle,
}: Props = $props();
let toggledIds = $state(new Set<string>());
let currentToggleId: string | null = $state(null);
function isNodeCollapsed(id: string): boolean {
if (!expandable) {
return false;
}
const defaultCollapsed = !collapseAll;
const toggled = toggledIds.has(id);
return defaultCollapsed ? !toggled : toggled;
}
function toggleCollapse(id: string) {
currentToggleId = id;
const newToggled = new Set(toggledIds);
if (newToggled.has(id)) {
newToggled.delete(id);
} else {
newToggled.add(id);
}
toggledIds = newToggled;
onToggle?.(id, isNodeCollapsed(id));
}
function flattenItems(
itemList: ThreadItem[],
parentCollapsed = false,
): (ThreadItem & { parentCollapsed?: boolean })[] {
const result: (ThreadItem & { parentCollapsed?: boolean })[] = [];
for (const item of itemList) {
const isCollapsed = isNodeCollapsed(item.id) || parentCollapsed;
result.push({ ...item, parentCollapsed });
if (item.children && item.children.length > 0 && !isCollapsed) {
result.push(...flattenItems(item.children, isCollapsed));
}
}
return result;
}
function hasChildren(item: ThreadItem): boolean {
return !!(item.children && item.children.length > 0);
}
function getChildCount(item: ThreadItem): number {
if (!item.children || item.children.length === 0) return 0;
let count = item.children.length;
for (const child of item.children) {
count += getChildCount(child);
}
return count;
}
const rowStep = $derived(rowHeight + gap);
const rootX = $derived(indent);
const resolvedItems = $derived.by(() =>
items.length > 0 ? items : defaultItemsByVariant[variant],
);
const normalizedItems = $derived.by(() => {
const items = resolvedItems.map((item, index) => ({
id: item.id || `threaded-item-${index + 1}`,
label: item.label || `Item ${index + 1}`,
level: Math.max(1, Math.floor(item.level ?? 1)),
tone: item.tone ?? ("muted" as ThreadTone),
collapsed: item.collapsed ?? false,
children: item.children,
}));
return flattenItems(items);
});
const maxLevel = $derived.by(() =>
normalizedItems.reduce((acc, item) => Math.max(acc, item.level ?? 1), 1),
);
const canvasHeight = $derived.by(() => {
if (normalizedItems.length === 0) return rowHeight;
return normalizedItems.length * rowStep - gap;
});
const canvasWidth = $derived.by(() => getNodeX(maxLevel) + nodeWidth + 8);
function getDepthX(depth: number) {
if (depth <= 0) return rootX;
return rootX + depth * indent;
}
function getNodeX(level: number) {
return getDepthX(level);
}
function getRowCenter(index: number) {
return index * rowStep + rowHeight / 2;
}
function getHorizontalStartX(level: number) {
if (variant === "spine") return rootX;
return getDepthX(level - 1);
}
function getThreadedVerticalX(currentLevel: number, nextLevel: number) {
if (nextLevel > currentLevel) return getDepthX(currentLevel);
if (nextLevel === currentLevel) return getDepthX(currentLevel - 1);
return getDepthX(nextLevel - 1);
}
function getParentIndex(index: number) {
const currentLevel = normalizedItems[index].level ?? 0;
for (let i = index - 1; i >= 0; i--) {
if ((normalizedItems[i].level ?? 0) < currentLevel) {
return i;
}
}
return -1;
}
function getChildIndex(parentId: string, childId: string): number {
const parentIndex = normalizedItems.findIndex((i) => i.id === parentId);
if (parentIndex === -1) return -1;
const childIndex = normalizedItems[parentIndex].children?.findIndex(
(i) => i.id === childId,
);
if (childIndex === -1) return -1;
return childIndex ?? -1;
}
function animateLine(
node: SVGLineElement,
params = { duration: 350, delay: 0 },
) {
const length = Math.hypot(
(node.x2?.baseVal?.value ?? 0) - (node.x1?.baseVal?.value ?? 0),
(node.y2?.baseVal?.value ?? 0) - (node.y1?.baseVal?.value ?? 0),
);
node.style.strokeDasharray = String(length);
node.style.strokeDashoffset = String(length);
node.style.opacity = "0";
const anim = node.animate(
[
{ strokeDashoffset: length, opacity: 0 },
{ strokeDashoffset: 0, opacity: 1 },
],
{
duration: params.duration,
delay: params.delay,
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
fill: "forwards",
},
);
return {
destroy() {
anim.cancel();
},
};
}
function animatePath(
node: SVGPathElement,
params = { duration: 350, delay: 0 },
) {
if (!animated) {
return;
}
const targetD = node.getAttribute("data-target-d");
if (targetD) {
const timeOut = setTimeout(() => {
node.setAttribute("d", targetD);
}, params.delay);
return {
destroy() {
clearTimeout(timeOut);
},
};
}
const length = node.getTotalLength();
node.style.strokeDasharray = String(length);
node.style.strokeDashoffset = String(length);
node.style.opacity = "0";
const anim = node.animate(
[
{ strokeDashoffset: length, opacity: 0 },
{ strokeDashoffset: 0, opacity: 1 },
],
{
duration: params.duration,
delay: params.delay,
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
fill: "forwards",
},
);
const timeOut = setTimeout(() => {
node.classList.add("smooth-line");
node.removeAttribute("style");
}, params.delay + params.duration);
return {
destroy() {
anim.cancel();
clearTimeout(timeOut);
},
};
}
</script>
<div class="ews-threaded-comments {tone} {className}">
<div class="threaded-scroll">
<div
class="threaded-canvas"
style:height={`${canvasHeight}px`}
style:width={`100%`}
>
<svg
class="threaded-connectors"
height="100%"
width="100%"
viewBox={`0 0 100% ${canvasHeight}`}
role="presentation"
aria-hidden="true"
>
{#if normalizedItems.length > 1 && variant === "spine"}
<path
class="connector-line vertical-main smooth-line"
d={`M 2,${rowHeight / 2} L 2,${(normalizedItems.length - 1) * rowStep + rowHeight / 2}`}
/>
{#each normalizedItems as item, index (`${item.id}-horizontal`)}
{@const toggleIndex = normalizedItems.findIndex(
(i) => i.id === currentToggleId,
)}
{@const parentIndex = getParentIndex(index)}
{@const childIndex =
getParentIndex(index) > -1
? getChildIndex(normalizedItems[parentIndex].id, item.id)
: 0}
{@const delay =
50 * Math.max(parentIndex + childIndex - toggleIndex, 1)}
<path
class="connector-line horizontal-1 {!animated
? 'smooth-line'
: ''}"
d={`M 2 ${getRowCenter(index)} H ${getNodeX(item.level ?? 1) + 2}`}
data-level={item.level}
fill="none"
use:animatePath={{
duration: 200,
delay: delay,
}}
/>
{/each}
{/if}
{#if variant === "threaded"}
<path
class="connector-line vertical-main smooth-line"
d={`M 2,${rowHeight / 2} L 2,${(normalizedItems.length - 1) * rowStep + rowHeight / 2}`}
/>
{#each normalizedItems as item, index (`${item.id}-vertical`)}
{#if index > 0 && getParentIndex(index) >= 0}
{@const toggleIndex = normalizedItems.findIndex(
(i) => i.id === currentToggleId,
)}
{@const parentIndex = getParentIndex(index)}
{@const childIndex =
getParentIndex(index) > -1
? getChildIndex(normalizedItems[parentIndex].id, item.id)
: 0}
{@const delay =
50 * Math.max(parentIndex + childIndex - toggleIndex, 1)}
<path
d="M {((item.level ?? 1) - 1) * rootX + 2}, {getRowCenter(
index,
) -
rowStep * (index - getParentIndex(index))} L {((item.level ??
1) -
1) *
rootX +
2}, {getRowCenter(index + 1) - rowStep}"
fill="none"
class="connector-line vertical-2 {!animated
? 'smooth-line'
: ''}"
use:animatePath={{
duration: 200,
delay: delay,
}}
/>
{/if}
{/each}
{#each normalizedItems as item, index (`${item.id}-horizontal`)}
{@const toggleIndex = normalizedItems.findIndex(
(i) => i.id === currentToggleId,
)}
{@const parentIndex = getParentIndex(index)}
{@const childIndex =
getParentIndex(index) > -1
? getChildIndex(normalizedItems[parentIndex].id, item.id)
: 0}
{@const delay =
100 * Math.max(parentIndex + childIndex - toggleIndex, 1)}
<path
class="connector-line horizontal-2 {!animated
? 'smooth-line'
: ''}"
d={`M ${((item.level ?? 1) - 1) * rootX + 2} ${getRowCenter(index)} H ${getNodeX(item.level ?? 1) + 2}`}
data-level={item.level}
fill="none"
use:animatePath={{
duration: 200,
delay: delay,
}}
/>
{/each}
{/if}
</svg>
{#each normalizedItems as item, index (item.id)}
{@const isCollapsed = isNodeCollapsed(item.id)}
{@const hasKids = hasChildren(item)}
{@const childCount = getChildCount(item)}
<div
class="threaded-node-wrap"
style:top={`${index * rowStep}px`}
style:left={`${getNodeX(item.level ?? 1)}px`}
style:height={`${rowHeight}px`}
style:right={"0"}
class:collapsed={isCollapsed}
class:has-children={hasKids && expandable}
>
<div
class="threaded-node {item.tone}"
class:is-collapsed={isCollapsed}
>
{#if expandable && hasKids}
<button
class="expand-toggle"
onclick={() => toggleCollapse(item.id)}
aria-label={isCollapsed ? "Expand" : "Collapse"}
>
<span class="toggle-icon" class:collapsed={isCollapsed}>
<svg viewBox="0 0 16 16" fill="currentColor">
<path
d="M4.5 5.5l3.5 3.5 3.5-3.5"
stroke="currentColor"
stroke-width="1.5"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
{#if isCollapsed}
<span class="child-count">{childCount}</span>
{/if}
</button>
{/if}
<span class="node-label">{item.label}</span>
</div>
</div>
{/each}
</div>
</div>
</div>
<style>
.ews-threaded-comments {
--line-color: rgba(var(--glow-rgb), 0.85);
--line-glow: rgba(var(--glow-rgb), 0.5);
width: 100%;
}
.ews-threaded-comments.neutral {
--line-color: rgba(226, 231, 236, 0.92);
--line-glow: rgba(255, 255, 255, 0.3);
}
.ews-threaded-comments.primary {
--line-color: rgba(var(--glow-rgb), 0.88);
--line-glow: rgba(var(--glow-rgb), 0.45);
}
.ews-threaded-comments.danger {
--line-color: rgba(var(--danger-glow-rgb), 0.9);
--line-glow: rgba(var(--danger-glow-rgb), 0.45);
}
.threaded-scroll {
width: 100%;
/* overflow-x: auto;
overflow-y: hidden; */
padding-bottom: 4px;
}
.threaded-canvas {
position: relative;
min-width: 100%;
transition:
height 0.25s ease,
width 0.25s ease;
}
.threaded-connectors {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.connector-line {
stroke: var(--line-color);
stroke-width: 2;
filter: drop-shadow(0 0 2px var(--line-glow));
/* transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); */
opacity: 0;
animation: fadeIn 0.5s ease forwards;
/* stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: draw 1s linear forwards; */
}
.connector-line.smooth-line {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes draw {
to {
stroke-dashoffset: 0; /* Animate the offset to zero to reveal the path */
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.threaded-node-wrap {
position: absolute;
display: flex;
align-items: center;
transition:
opacity 0.25s ease,
transform 0.25s ease,
top 0.25s ease,
left 0.25s ease;
}
.threaded-node {
width: 100%;
height: 100%;
display: flex;
align-items: center;
padding: 0 14px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: linear-gradient(
90deg,
rgba(36, 36, 36, 0.98),
rgba(62, 62, 62, 0.98)
);
border: 1px solid rgba(255, 255, 255, 0.16);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.65);
color: #f4f4f4;
}
.threaded-node.normal {
color: var(--text-color);
border-color: rgba(var(--glow-rgb), 0.55);
box-shadow:
inset 0 0 0 1px rgba(var(--glow-rgb), 0.18),
0 0 8px rgba(var(--glow-rgb), 0.12);
background: linear-gradient(
90deg,
rgba(var(--glow-rgb), 0.1),
rgba(var(--glow-rgb), 0.1)
);
}
.threaded-node.danger {
color: var(--danger-text-color);
border-color: rgba(var(--danger-glow-rgb), 0.7);
box-shadow:
inset 0 0 0 1px rgba(var(--danger-glow-rgb), 0.2),
0 0 8px rgba(var(--danger-glow-rgb), 0.14);
background: linear-gradient(
90deg,
rgba(var(--danger-glow-rgb), 0.1),
rgba(var(--danger-glow-rgb), 0.1)
);
}
.threaded-node.muted {
border-color: rgba(255, 255, 255, 0.2);
}
.expand-toggle {
display: flex;
align-items: center;
gap: 4px;
padding: 2px;
margin-right: 8px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
cursor: pointer;
color: inherit;
transition: all 0.2s ease;
flex-shrink: 0;
}
.expand-toggle:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.25);
}
.toggle-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
.toggle-icon svg {
width: 12px;
height: 12px;
}
.toggle-icon.collapsed {
transform: rotate(-90deg);
}
.child-count {
font-size: 0.6rem;
font-weight: 600;
padding: 0 4px;
background: rgba(var(--glow-rgb), 0.3);
border-radius: 8px;
letter-spacing: 0;
}
.node-label {
overflow: hidden;
text-overflow: ellipsis;
}
/* .threaded-node-wrap.collapsed {
opacity: 0.5;
} */
.threaded-node.is-collapsed {
font-style: italic;
}
.ews-threaded-comments:not(.primary) .expand-toggle {
background: rgba(255, 255, 255, 0.05);
}
.ews-threaded-comments.neutral .child-count {
background: rgba(226, 231, 236, 0.2);
}
.ews-threaded-comments.danger .child-count {
background: rgba(var(--danger-glow-rgb), 0.3);
}
@media (max-width: 768px) {
.threaded-node {
font-size: 0.65rem;
letter-spacing: 0.05em;
}
}
</style>

View file

@ -1,7 +1,6 @@
import mapboxgl from "mapbox-gl";
import AnimatedPopup from 'mapbox-gl-animated-popup';
import type { InfoGempa } from '$lib/types';
import { createGempaPopupHTML } from "$lib/utils/mapUtils";
type TitikGempaSetting = {
map: mapboxgl.Map;
@ -153,15 +152,30 @@ export class TitikGempa {
renderPopup() {
const placeholder = document.createElement('div');
placeholder.innerHTML = createGempaPopupHTML({
id: this.id,
mag: this.mag,
depth: this.depth,
time: new Date(this.infoGempa.time!).toLocaleString(),
lat: this.infoGempa.lat,
lng: this.infoGempa.lng,
place: this.infoGempa.place ?? "-",
});
placeholder.innerHTML = `
<div class="ews-card bordered-red min-h-48 min-w-48 whitespace-pre-wrap">
<div class="ews-card-header bordered-red-bottom">
<div class="overflow-hidden">
<div class="stripe-wrapper"><div class="stripe-bar loop-stripe-reverse anim-duration-20"></div><div class="stripe-bar loop-stripe-reverse anim-duration-20"></div></div>
<div class="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center">
<p class="p-1 bg-black font-bold text-xs text-glow">GEMPA BUMI</p>
</div>
</div>
</div>
<div class="ews-card-content p-1 lg:p-2 custom-scrollbar">
${this.mag ? `<table class="w-full">
<tbody>
<tr><td class="flex">Magnitudo</td><td class="text-right break-words pl-2">${Number(this.mag).toFixed(1)}</td></tr>
<tr><td class="flex">Kedalaman</td><td class="text-right break-words pl-2">${this.depth}</td></tr>
<tr><td class="flex">Waktu</td><td class="text-right break-words pl-2">${new Date(this.infoGempa.time!).toLocaleString()}</td></tr>
<tr><td class="flex">Lokasi (Lat,Lng)</td><td class="text-right break-words pl-2">${this.infoGempa.lat} , ${this.infoGempa.lng}</td></tr>
</tbody>
</table>` : ''}
${this.setting?.description != null && this.setting?.description != '' ? `<hr><p class="mt-1 text-xs">${this.setting?.description}</p>` : ''}
</div>
</div>
`.trim()
.replace(/>\s+</g, "><");
if (this.gemaMarker) {
const popup = new AnimatedPopup({
@ -286,7 +300,7 @@ export class TitikGempa {
if (this.setting?.map != null) {
this.setting.map.flyTo({
center: [this.infoGempa.lng, this.infoGempa.lat],
zoom: 8
zoom: 6
});
}
}

View file

@ -79,12 +79,12 @@ export class TitikTsunami {
renderPopup() {
const placeholder = document.createElement('div');
placeholder.innerHTML = `
<div class="ews-card bordered bordered-red min-h-48 min-w-64 whitespace-pre-wrap">
<div class="ews-card bordered-red min-h-48 min-w-64 whitespace-pre-wrap">
<div class="ews-card-header bordered-red-bottom">
<div class="overflow-hidden">
<div class="stripe-wrapper"><div class="stripe-bar loop-stripe-reverse anim-duration-20"></div><div class="stripe-bar loop-stripe-reverse anim-duration-20"></div></div>
<div class="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center">
<p class="p-1 bg-black font-bold text-xs text-glow">TSUNAMI WARNING</p>
<p class="p-1 bg-black font-bold text-xs text-glow">PERINGATAN TSUNAMI</p>
</div>
</div>
</div>
@ -124,7 +124,7 @@ export class TitikTsunami {
if (this.setting?.map != null) {
this.setting.map.flyTo({
center: [this.infoTsunami.lng, this.infoTsunami.lat],
zoom: 8
zoom: 6
});
}
}

View file

@ -134,49 +134,45 @@
</div>
</div>
<div
class="w-11/12 md:w-3/4 overflow-hidden bg-black relative rounded flex justify-center items-center opacity-0 show-pop-up animation-delay-2"
class="w-3/4 overflow-hidden bg-black relative rounded flex justify-center items-center opacity-0 show-pop-up animation-delay-2"
>
<div
class="absolute w-full h-2 m-auto top-0 left-0 right-0 overflow-hidden"
>
<StripeBar color="red" loop={true} className="w-full h-2"
></StripeBar>
<div class="w-2 h-full stripe-bar-red stripe-animation"></div>
</div>
<div
class="absolute w-full h-2 m-auto bottom-0 left-0 right-0 overflow-hidden"
>
<StripeBar
color="red"
loop={true}
reverse={true}
className="w-full h-2"
></StripeBar>
<div
class="w-2 h-full stripe-bar-red stripe-animation-reverse"
></div>
</div>
<div
class="absolute w-2 h-full m-auto top-0 bottom-0 left-0 overflow-hidden"
>
<StripeBar
color="red"
orientation="vertical"
reverse={true}
loop={true}
className="w-2 h-full"
></StripeBar>
<div
class="w-2 h-full stripe-bar-red-vertical loop-stripe-vertical-reverse"
></div>
</div>
<div
class="absolute w-2 h-full m-auto top-0 bottom-0 right-0 overflow-hidden"
>
<StripeBar
color="red"
orientation="vertical"
loop={true}
className="w-2 h-full"
></StripeBar>
<div
class="w-2 h-full stripe-bar-red-vertical loop-stripe-vertical"
></div>
</div>
<div class="w-full h-full p-6">
<div class="bordered-red p-2 text-center w-full mb-2">
<div class="overflow-hidden relative">
<StripeBar loop={true} reverse={true} duration={20}></StripeBar>
<div class="stripe-wrapper">
<div
class="stripe-bar loop-stripe-reverse anim-duration-20"
></div>
<div
class="stripe-bar loop-stripe-reverse anim-duration-20"
></div>
</div>
<div
class="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center"
>
@ -184,11 +180,17 @@
</div>
</div>
</div>
<div class="ews-card ews-card-red w-full h-auto">
<div class="ews-card bordered-red w-full h-auto">
<div class="ews-card-header bordered-red-bottom">
<div class="overflow-hidden relative">
<StripeBar loop={true} reverse={true} duration={20}
></StripeBar>
<div class="stripe-wrapper">
<div
class="stripe-bar loop-stripe-reverse anim-duration-20"
></div>
<div
class="stripe-bar loop-stripe-reverse anim-duration-20"
></div>
</div>
<div
class="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center"
>

View file

@ -1,716 +0,0 @@
<script context="module" lang="ts">
// Data point structure
export interface DataPoint {
t: number; // timestamp in MS
v: number; // amplitude value
}
</script>
<script lang="ts">
import "../styles/components/WaveformChart.css";
import { onMount, onDestroy } from "svelte";
import { browser } from "$app/environment";
export let waveformData: DataPoint[] = [];
// Colors
export let backgroundColor: string = "#000000";
export let gridColor: string = "#33cc55";
export let axesColor: string = "#fa0";
export let waveformColor: string = "#ff9900";
export let psychoWaveformColor: string = "#ff9900";
export let emptyTextColor: string = "#fa0";
// Zoom/Scale factor for Y-axis (Amplitude)
export let zoomLevel: number = 0.05;
export const MIN_ZOOM: number = 0.0001;
export const MAX_ZOOM: number = 0.1;
// Time View Window
export let timeWindowMs: number = 20000;
export let timeOffsetMs: number = 0;
// Horizontal (X-axis / time) zoom limits in milliseconds
export const MIN_TIME_WINDOW_MS: number = 2000; // 2 seconds minimum
export const MAX_TIME_WINDOW_MS: number = 300000; // 5 minutes maximum
// Timezone
export let selectedTimezone: number = 0; // 0: UTC, 7: WIB, 8: WITA, 9: WIT
// Demo Psycho mode
export let isDemoPsychoMode: boolean = false;
export let psychoPoints: { nx: number; ny: number }[] = [];
// Historical mode: set both to non-zero to activate
// When active, live loop pauses and X-axis is fixed to this range
export let historicalStartMs: number = 0;
export let historicalEndMs: number = 0;
// Method to resume viewing live data
export function resumeLive() {
timeOffsetMs = 0;
frozenLatestTime = 0;
}
// Jump to the end of the historical range (right edge = endTime)
export function jumpToHistoricalEnd() {
timeOffsetMs = 0;
frozenLatestTime = 0;
draw();
}
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let container: HTMLDivElement;
let width = 1200;
let height = 600;
let frozenLatestTime = 0;
// Automatically freeze live mode if timeOffsetMs is increased externally
$: if (timeOffsetMs > 0 && frozenLatestTime === 0 && historicalStartMs === 0) {
frozenLatestTime = Date.now();
}
let isDragging = false;
let lastMouseX = 0;
let initialPinchDistance = 0;
let startedOnCanvas = false;
let animId: number;
function resizeCanvas() {
if (!container || !canvas) return;
const rect = container.getBoundingClientRect();
width = rect.width;
height = rect.height;
canvas.width = width;
canvas.height = height;
draw();
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
const isHistoricalMode = historicalStartMs > 0 && historicalEndMs > 0;
const isMobile = width < 768;
const leftPadding = isMobile ? 40 : 70;
const drawWidth = width - leftPadding;
// CTRL + Scroll → Horizontal Zoom (X-axis / Time Window)
if (e.ctrlKey && e.deltaY !== 0) {
const oldWindow = timeWindowMs;
const zoomFactor = e.deltaY > 0 ? 1.15 : 1 / 1.15; // zoom out / zoom in
const newWindow = Math.min(
MAX_TIME_WINDOW_MS,
Math.max(MIN_TIME_WINDOW_MS, oldWindow * zoomFactor)
);
// Anchor zoom to mouse cursor X position on the time axis
const mouseXOnCanvas = e.offsetX - leftPadding;
const cursorRatio = Math.max(0, Math.min(1, mouseXOnCanvas / drawWidth));
// Determine the time under the cursor before zoom
let rightEdgeBefore: number;
if (isHistoricalMode) {
rightEdgeBefore = historicalEndMs - timeOffsetMs;
} else {
const latestTime =
timeOffsetMs > 0 && frozenLatestTime > 0 ? frozenLatestTime : Date.now();
rightEdgeBefore = latestTime - timeOffsetMs;
}
const leftEdgeBefore = rightEdgeBefore - oldWindow;
const timeUnderCursor = leftEdgeBefore + cursorRatio * oldWindow;
// After zoom, the right edge must remain so that timeUnderCursor stays at cursorRatio
// newRightEdge = timeUnderCursor + (1 - cursorRatio) * newWindow
const newRightEdge = timeUnderCursor + (1 - cursorRatio) * newWindow;
timeWindowMs = newWindow;
if (isHistoricalMode) {
timeOffsetMs = historicalEndMs - newRightEdge;
const maxOffset = (historicalEndMs - historicalStartMs) - newWindow;
if (timeOffsetMs < 0) timeOffsetMs = 0;
if (maxOffset > 0 && timeOffsetMs > maxOffset) timeOffsetMs = maxOffset;
} else {
// Freeze the live clock at the current "right edge" reference point
if (frozenLatestTime === 0 && timeOffsetMs === 0) {
frozenLatestTime = Date.now();
}
const refTime = frozenLatestTime > 0 ? frozenLatestTime : Date.now();
timeOffsetMs = refTime - newRightEdge;
if (timeOffsetMs <= 0) {
timeOffsetMs = 0;
frozenLatestTime = 0;
}
}
draw();
return; // Don't run other scroll handlers on CTRL+scroll
}
// Vertical Scroll (Y-Axis Zoom) — no modifier key
if (e.deltaY !== 0 && !e.shiftKey && !e.ctrlKey) {
const zoomStep = zoomLevel * 0.1; // 10% change
if (e.deltaY < 0) {
zoomLevel = Math.min(MAX_ZOOM, zoomLevel + zoomStep);
} else {
zoomLevel = Math.max(MIN_ZOOM, zoomLevel - zoomStep);
}
}
// Horizontal Scroll with Shift key or Trackpad horizontal scroll (Pan Time)
// Also allow horizontal wheel events (deltaX)
if (e.deltaX !== 0 || (e.deltaY !== 0 && e.shiftKey)) {
if (!isHistoricalMode && timeOffsetMs === 0) {
frozenLatestTime = Date.now();
}
const delta = Math.abs(e.deltaX) > 0 ? e.deltaX : e.deltaY;
// Map pixels to milliseconds
const msPerPixel = timeWindowMs / width;
timeOffsetMs += delta * msPerPixel * 2;
// Prevent panning into the future (live) or past the start (historical)
if (isHistoricalMode) {
const maxOffset = (historicalEndMs - historicalStartMs) - timeWindowMs;
if (timeOffsetMs < 0) timeOffsetMs = 0;
if (maxOffset > 0 && timeOffsetMs > maxOffset) timeOffsetMs = maxOffset;
} else {
if (timeOffsetMs <= 0) {
timeOffsetMs = 0;
frozenLatestTime = 0;
}
}
}
draw();
}
// Mouse Dragging for Panning
function handleMouseDown(e: MouseEvent) {
isDragging = true;
lastMouseX = e.clientX;
}
function handleMouseMove(e: MouseEvent) {
if (!isDragging) return;
const dx = e.clientX - lastMouseX;
lastMouseX = e.clientX;
// Map pixel drag to time change
const msPerPixel = timeWindowMs / width;
const isHistoricalMode = historicalStartMs > 0 && historicalEndMs > 0;
// Moving right (dx > 0) means going back in time (increasing timeOffset)
if (!isHistoricalMode && timeOffsetMs === 0 && dx > 0) {
frozenLatestTime = Date.now();
}
// Drag left = positive dx = go earlier in time (increase offset)
timeOffsetMs -= dx * msPerPixel;
if (isHistoricalMode) {
const maxOffset = (historicalEndMs - historicalStartMs) - timeWindowMs;
if (timeOffsetMs < 0) timeOffsetMs = 0;
if (maxOffset > 0 && timeOffsetMs > maxOffset) timeOffsetMs = maxOffset;
} else {
if (timeOffsetMs <= 0) {
timeOffsetMs = 0;
frozenLatestTime = 0;
}
}
draw();
}
function handleMouseUp() {
isDragging = false;
}
function handleTouchStart(e: TouchEvent) {
startedOnCanvas = true;
if (e.touches.length === 1) {
isDragging = true;
lastMouseX = e.touches[0].clientX;
} else if (e.touches.length === 2) {
isDragging = false;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
initialPinchDistance = Math.hypot(dx, dy);
}
}
function handleTouchMove(e: TouchEvent) {
if (!startedOnCanvas) return;
if (e.cancelable) {
e.preventDefault(); // Prevent page scrolling
}
if (isDragging && e.touches.length === 1) {
const dx = e.touches[0].clientX - lastMouseX;
lastMouseX = e.touches[0].clientX;
const msPerPixel = timeWindowMs / width;
const isHistoricalMode = historicalStartMs > 0 && historicalEndMs > 0;
if (!isHistoricalMode && timeOffsetMs === 0 && dx > 0) {
frozenLatestTime = Date.now();
}
// Drag right (dx > 0) = scroll into the past (increase offset)
timeOffsetMs -= dx * msPerPixel;
if (isHistoricalMode) {
const maxOffset = (historicalEndMs - historicalStartMs) - timeWindowMs;
if (timeOffsetMs < 0) timeOffsetMs = 0;
if (maxOffset > 0 && timeOffsetMs > maxOffset) timeOffsetMs = maxOffset;
} else {
if (timeOffsetMs <= 0) {
timeOffsetMs = 0;
frozenLatestTime = 0;
}
}
draw();
} else if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const currentDistance = Math.hypot(dx, dy);
const diff = currentDistance - initialPinchDistance;
if (Math.abs(diff) > 2) {
const zoomStep = zoomLevel * 0.05 * (Math.abs(diff) / 5);
if (diff > 0) {
zoomLevel = Math.min(MAX_ZOOM, zoomLevel + zoomStep);
} else {
zoomLevel = Math.max(MIN_ZOOM, zoomLevel - zoomStep);
}
initialPinchDistance = currentDistance;
draw();
}
}
}
function handleTouchEnd(e: TouchEvent) {
if (!startedOnCanvas) return;
if (e.touches.length < 2) {
initialPinchDistance = 0;
}
if (e.touches.length === 0) {
isDragging = false;
startedOnCanvas = false;
} else if (e.touches.length === 1) {
isDragging = true;
lastMouseX = e.touches[0].clientX;
}
}
function formatTimeStr(ms: number) {
// Apply timezone offset (selectedTimezone is in hours)
const tzOffsetMs = selectedTimezone * 60 * 60 * 1000;
const d = new Date(ms + tzOffsetMs);
return d.toISOString().substring(11, 19); // HH:MM:SS
}
function draw() {
if (!ctx || !canvas) return;
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, width, height);
if (waveformData.length === 0 && !isDemoPsychoMode) {
// Draw empty state
ctx.fillStyle = emptyTextColor;
ctx.font = 'bold 16px "Inter", sans-serif';
ctx.textAlign = "center";
ctx.fillText("WAITING FOR SIGNAL...", width / 2, height / 2);
return;
}
// Determine right/left edge: historical mode uses fixed range, live mode uses Date.now()
const isHistoricalMode = historicalStartMs > 0 && historicalEndMs > 0;
let rightEdgeTime: number;
let leftEdgeTime: number;
if (isHistoricalMode) {
// In historical mode, the right edge starts at endTime and user scrolls left
rightEdgeTime = historicalEndMs - timeOffsetMs;
leftEdgeTime = rightEdgeTime - timeWindowMs;
} else {
// Live mode: smooth sliding clock
const latestTime =
timeOffsetMs > 0 && frozenLatestTime > 0
? frozenLatestTime
: Date.now();
rightEdgeTime = latestTime - timeOffsetMs;
leftEdgeTime = rightEdgeTime - timeWindowMs;
}
const isMobile = width < 768;
const leftPadding = isMobile ? 40 : 70;
const bottomPadding = isMobile ? 30 : 40;
const drawWidth = width - leftPadding;
const drawHeight = height - bottomPadding;
// X-Axis Grid inside graph area (Neon Green with Crosshairs)
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
const xGridStep = drawWidth / 8;
const yGridStep = drawHeight / 12; // 12 divisions like the EVANGELION reference
for (let xPos = leftPadding; xPos <= width; xPos += xGridStep) {
ctx.moveTo(xPos, 0);
ctx.lineTo(xPos, drawHeight);
// Draw crosshairs on the lines
for (let yPos = 0; yPos <= drawHeight; yPos += yGridStep) {
// Crosshairs length: 10px
ctx.moveTo(xPos - 5, yPos);
ctx.lineTo(xPos + 5, yPos);
}
// Draw floating crosshairs between the vertical lines (shifted by half a grid step)
if (xPos + xGridStep <= width) {
const midX = xPos + xGridStep / 2;
for (
let yPos = yGridStep / 2;
yPos <= drawHeight;
yPos += yGridStep
) {
ctx.moveTo(midX - 5, yPos);
ctx.lineTo(midX + 5, yPos);
ctx.moveTo(midX, yPos - 5);
ctx.lineTo(midX, yPos + 5);
}
}
}
ctx.stroke();
// Left Label Ruler (Amplitude)
ctx.strokeStyle = axesColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(leftPadding, 0);
ctx.lineTo(leftPadding, drawHeight);
ctx.fillStyle = axesColor;
ctx.font = isMobile
? 'bold 10px "Inter", sans-serif'
: 'bold 14px "Inter", sans-serif';
ctx.textAlign = "right";
ctx.textBaseline = "middle";
for (let y = 0; y <= drawHeight; y += yGridStep) {
// Ticks
ctx.moveTo(leftPadding - (isMobile ? 4 : 8), y);
ctx.lineTo(leftPadding, y);
const actualValue = (drawHeight / 2 - y) / zoomLevel;
// Format to integer since amplitude counts are generally large or zero
ctx.fillText(
Math.round(actualValue).toString(),
leftPadding - (isMobile ? 6 : 10),
y,
);
}
ctx.stroke();
// Bottom Ruler (Time/Ticks) — Adaptive intervals
ctx.strokeStyle = axesColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, drawHeight);
ctx.lineTo(width, drawHeight);
ctx.stroke();
ctx.fillStyle = axesColor;
ctx.font = isMobile
? 'bold 10px "Inter", sans-serif'
: 'bold 12px "Inter", sans-serif';
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.strokeStyle = axesColor;
ctx.lineWidth = 1.5;
ctx.beginPath();
const pixelsPerMs = drawWidth / timeWindowMs;
// ── Adaptive tick intervals ──────────────────────────────────────────
// Each entry: [windowThreshold_ms, majorInterval_ms, minorInterval_ms, labelFormat]
// labelFormat: 'hms' = HH:MM:SS, 'hm' = HH:MM, 'hmd' = HH:MM + date
type LabelFmt = 'hms' | 'hm';
const tickTable: [number, number, number, LabelFmt][] = [
[ 3_000, 500, 100, 'hms'], // < 3 s major 0.5s, minor 0.1s
[ 8_000, 1_000, 250, 'hms'], // < 8 s major 1s, minor 0.25s
[ 20_000, 2_000, 500, 'hms'], // < 20 s major 2s, minor 0.5s
[ 45_000, 5_000, 1_000, 'hms'], // < 45 s major 5s, minor 1s
[ 90_000, 10_000, 2_000, 'hms'], // < 90 s major 10s, minor 2s
[ 180_000, 20_000, 5_000, 'hms'], // < 3 min major 20s, minor 5s
[ 360_000, 60_000, 15_000, 'hms'], // < 6 min major 1min, minor 15s
[ 900_000, 120_000, 30_000, 'hms'], // < 15 min major 2min, minor 30s
[ 1_800_000, 300_000, 60_000, 'hm' ], // < 30 min major 5min, minor 1min
[ 3_600_000, 600_000,120_000, 'hm' ], // < 1 hr major 10min,minor 2min
[ 7_200_000,1_200_000,300_000,'hm' ], // < 2 hr major 20min,minor 5min
[ 18_000_000,3_600_000,900_000,'hm' ], // < 5 hr major 1hr, minor 15min
];
let majorMs = 3_600_000; // default ≥ 5 hr
let minorMs = 900_000;
let labelFmt: LabelFmt = 'hm';
for (const [threshold, maj, min, fmt] of tickTable) {
if (timeWindowMs < threshold) {
majorMs = maj;
minorMs = min;
labelFmt = fmt;
break;
}
}
// Helper: format a timestamp for the label
function formatLabel(ms: number): string {
const tzOffsetMs = selectedTimezone * 60 * 60 * 1000;
const d = new Date(ms + tzOffsetMs);
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(d.getUTCMinutes()).padStart(2, '0');
const ss = String(d.getUTCSeconds()).padStart(2, '0');
return labelFmt === 'hms' ? `${hh}:${mm}:${ss}` : `${hh}:${mm}`;
}
// Draw minor ticks (no label)
const minorStart = Math.floor(leftEdgeTime / minorMs) * minorMs;
for (let t = minorStart; t <= rightEdgeTime; t += minorMs) {
// Skip positions that will be covered by a major tick
if (t % majorMs === 0) continue;
const x = leftPadding + (t - leftEdgeTime) * pixelsPerMs;
if (x >= leftPadding && x <= width) {
ctx.moveTo(x, drawHeight);
ctx.lineTo(x, drawHeight + 8);
}
}
// Draw major ticks + labels
const majorStart = Math.floor(leftEdgeTime / majorMs) * majorMs;
for (let t = majorStart; t <= rightEdgeTime; t += majorMs) {
const x = leftPadding + (t - leftEdgeTime) * pixelsPerMs;
if (x >= leftPadding && x <= width) {
ctx.moveTo(x, drawHeight);
ctx.lineTo(x, drawHeight + 18);
ctx.fillText(formatLabel(t), x, drawHeight + 20);
}
}
ctx.stroke();
// Draw the waveform line
if (isDemoPsychoMode) {
ctx.save();
ctx.beginPath();
ctx.rect(leftPadding, 0, drawWidth, drawHeight);
ctx.clip();
ctx.strokeStyle = psychoWaveformColor;
ctx.shadowBlur = 12;
ctx.lineWidth = 2.5;
ctx.lineJoin = "round";
ctx.beginPath();
const startX = leftPadding;
const boundingBoxLeft = leftPadding + drawWidth * 0.3;
const boundingBoxRight = leftPadding + drawWidth * 0.55;
const boundingBoxTop = drawHeight * 0.1;
const boundingBoxBottom = drawHeight * 0.9;
const boxWidth = boundingBoxRight - boundingBoxLeft;
const boxHeight = boundingBoxBottom - boundingBoxTop;
ctx.moveTo(startX, drawHeight - 2);
ctx.bezierCurveTo(
leftPadding + drawWidth * 0.15,
drawHeight,
leftPadding + drawWidth * 0.25,
drawHeight * 0.1,
boundingBoxLeft,
boundingBoxTop,
);
if (psychoPoints.length > 0) {
const firstPx =
boundingBoxLeft +
boxWidth / 2 +
(psychoPoints[0].nx * boxWidth) / 2;
const firstPy =
boundingBoxTop +
boxHeight / 2 +
(psychoPoints[0].ny * boxHeight) / 2;
ctx.lineTo(firstPx, firstPy);
for (let i = 1; i < psychoPoints.length - 1; i++) {
const px =
boundingBoxLeft +
boxWidth / 2 +
(psychoPoints[i].nx * boxWidth) / 2;
const py =
boundingBoxTop +
boxHeight / 2 +
(psychoPoints[i].ny * boxHeight) / 2;
const nextPx =
boundingBoxLeft +
boxWidth / 2 +
(psychoPoints[i + 1].nx * boxWidth) / 2;
const nextPy =
boundingBoxTop +
boxHeight / 2 +
(psychoPoints[i + 1].ny * boxHeight) / 2;
const midX = (px + nextPx) / 2;
const midY = (py + nextPy) / 2;
ctx.quadraticCurveTo(px, py, midX, midY);
}
const lastPx =
boundingBoxLeft +
boxWidth / 2 +
(psychoPoints[psychoPoints.length - 1].nx * boxWidth) / 2;
const lastPy =
boundingBoxTop +
boxHeight / 2 +
(psychoPoints[psychoPoints.length - 1].ny * boxHeight) / 2;
ctx.lineTo(lastPx, lastPy);
ctx.lineTo(boundingBoxRight, drawHeight * 0.4);
let stairX = boundingBoxRight;
let stairY = drawHeight * 0.4;
stairX += drawWidth * 0.05;
ctx.lineTo(stairX, stairY);
stairY += drawHeight * 0.2;
ctx.lineTo(stairX, stairY);
stairX += drawWidth * 0.05;
ctx.lineTo(stairX, stairY);
stairY += drawHeight * 0.2;
ctx.lineTo(stairX, stairY);
stairX += drawWidth * 0.05;
ctx.lineTo(stairX, stairY);
stairY = drawHeight - 2;
ctx.lineTo(stairX, stairY);
ctx.lineTo(width, stairY);
}
ctx.stroke();
ctx.restore();
} else {
const visiblePoints = waveformData.filter(
(p) =>
p.t >= leftEdgeTime - 1000 && p.t <= rightEdgeTime + 1000,
);
if (visiblePoints.length > 0) {
ctx.save();
ctx.beginPath();
ctx.rect(leftPadding, 0, drawWidth, drawHeight);
ctx.clip();
ctx.strokeStyle = waveformColor;
ctx.shadowBlur = 12;
ctx.lineWidth = 2.5;
ctx.lineJoin = "round";
ctx.beginPath();
visiblePoints.forEach((p, i) => {
const timeRatio = (p.t - leftEdgeTime) / timeWindowMs;
const x = leftPadding + timeRatio * drawWidth;
const y = drawHeight / 2 - p.v * zoomLevel;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.restore();
}
}
}
onMount(() => {
if (!browser) return;
ctx = canvas.getContext("2d")!;
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
canvas.addEventListener("wheel", handleWheel, { passive: false });
canvas.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
canvas.addEventListener("touchstart", handleTouchStart, {
passive: false,
});
window.addEventListener("touchmove", handleTouchMove, {
passive: false,
});
window.addEventListener("touchend", handleTouchEnd);
window.addEventListener("touchcancel", handleTouchEnd);
function updateLoop() {
const isHistoricalMode = historicalStartMs > 0 && historicalEndMs > 0;
// In historical mode, we don't need continuous re-draws based on clock;
// draw() is called on user interaction. But we still run the loop
// at a low rate so panning/dragging still works smoothly.
if (!isDragging || isHistoricalMode) {
draw();
}
animId = requestAnimationFrame(updateLoop);
}
updateLoop();
});
onDestroy(() => {
if (!browser) return;
window.removeEventListener("resize", resizeCanvas);
if (canvas) {
canvas.removeEventListener("wheel", handleWheel);
canvas.removeEventListener("mousedown", handleMouseDown);
canvas.removeEventListener("touchstart", handleTouchStart);
}
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchend", handleTouchEnd);
window.addEventListener("touchcancel", handleTouchEnd);
if (animId) {
cancelAnimationFrame(animId);
}
});
// Handle incoming data/props changes to trigger draw if needed,
// although updateLoop is already calling draw() repeatedly.
</script>
<div bind:this={container} class="ews-waveform-chart-wrapper">
<!-- Slot for putting overlays on top of canvas if needed -->
<slot></slot>
<canvas bind:this={canvas} class="ews-waveform-chart-canvas"></canvas>
</div>

View file

@ -1,242 +0,0 @@
[
{
"id": "gempa_dirasakan",
"category": "feel",
"source_url": "https://bmkg-content-inatews.storage.googleapis.com/datagempa.json",
"type": "json",
"single_data": true,
"attribute": "",
"data_mapping": {
"id": {
"attribute": "identifier"
},
"lng": {
"attribute": "info.point.coordinates",
"func": [
{
"split": {
"separator": ",",
"index": 0
}
},
{
"toFloat": {}
}
]
},
"lat": {
"attribute": "info.point.coordinates",
"func": [
{
"split": {
"separator": ",",
"index": 1
}
},
{
"toFloat": {}
}
]
},
"mag": {
"attribute": "info.magnitude",
"func": [
{
"toFloat": {}
}
]
},
"depth": {
"attribute": "info.depth"
},
"place": {
"attribute": "info.felt"
},
"message": {
"attribute": "info.description"
},
"time": {
"attribute": "sent",
"func": [
{
"replace": {
"from": "WIB",
"to": ""
}
},
{
"fromISO": {
"zone": "Asia/Jakarta"
}
},
{
"formatReadable": {}
}
]
}
}
},
{
"id": "gempa_kecil",
"category": "small",
"source_url": "https://bmkg-content-inatews.storage.googleapis.com/lastQL.json",
"type": "json",
"single_data": false,
"attribute": "features",
"data_mapping": {
"id": {
"attribute": "properties.id"
},
"lng": {
"attribute": "geometry.coordinates",
"index": 0,
"func": [
{
"toFloat": {}
}
]
},
"lat": {
"attribute": "geometry.coordinates",
"index": 1,
"func": [
{
"toFloat": {}
}
]
},
"mag": {
"attribute": "properties.mag",
"func": [
{
"toFloat": {}
}
]
},
"depth": {
"attribute": "properties.depth"
},
"place": {
"attribute": "properties.place"
},
"time": {
"attribute": "properties.time",
"func": [
{
"utcSqlToJakarta": {}
},
{
"formatReadable": {}
}
]
}
}
},
{
"id": "titik_gempa",
"category": "all",
"source_url": "https://bmkg-content-inatews.storage.googleapis.com/gempaQL.json",
"type": "json",
"single_data": false,
"attribute": "features",
"data_mapping": {
"id": {
"attribute": "properties.id"
},
"lng": {
"attribute": "geometry.coordinates",
"index": 0,
"func": [
{
"toFloat": {}
}
]
},
"lat": {
"attribute": "geometry.coordinates",
"index": 1,
"func": [
{
"toFloat": {}
}
]
},
"mag": {
"attribute": "properties.mag",
"func": [
{
"toFloat": {}
}
]
},
"depth": {
"attribute": "properties.depth"
},
"place": {
"attribute": "properties.place"
},
"time": {
"attribute": "properties.time",
"func": [
{
"utcSqlToJakarta": {}
},
{
"formatReadable": {}
}
]
}
}
},
{
"id": "live_30_event",
"category": "all",
"source_url": "https://bmkg-content-inatews.storage.googleapis.com/live30event.xml",
"type": "xml",
"single_data": false,
"attribute": "Infogempa.gempa",
"data_mapping": {
"id": {
"attribute": "eventid"
},
"lng": {
"attribute": "bujur",
"func": [
{
"toFloat": {}
}
]
},
"lat": {
"attribute": "lintang",
"func": [
{
"toFloat": {}
}
]
},
"mag": {
"attribute": "mag",
"func": [
{
"toFloat": {}
}
]
},
"depth": {
"attribute": "dalam"
},
"place": {
"attribute": "area"
},
"time": {
"attribute": "waktu",
"func": [
{
"formatReadable": {}
}
]
}
}
}
]

View file

@ -1,76 +0,0 @@
[
{
"id": "ssn_mexico",
"category": "all",
"source_url": "http://www.ssn.unam.mx/j/656823557",
"type": "json",
"single_data": false,
"attribute": "",
"data_mapping": {
"id": {
"attribute": "",
"func": [
{
"template": {
"format": "${6}-${0}-${1}"
}
}
]
},
"lng": {
"attribute": "",
"index": 3,
"func": [
{
"toFloat": {}
}
]
},
"lat": {
"attribute": "",
"index": 2,
"func": [
{
"toFloat": {}
}
]
},
"mag": {
"attribute": "",
"index": 5,
"func": [
{
"toFloat": {}
}
]
},
"depth": {
"attribute": "",
"index": 4
},
"place": {
"attribute": "",
"index": 6
},
"time": {
"attribute": "",
"func": [
{
"template": {
"format": "${0} ${1}"
}
},
{
"fromFormat": {
"format": "yyyy-MM-dd HH:mm:ss",
"zone": "UTC"
}
},
{
"formatReadable": {}
}
]
}
}
}
]

View file

@ -1,220 +0,0 @@
import type { DataPoint } from "$lib/components/WaveformChart.svelte";
export class WaveformService {
private buffer: DataPoint[] = [];
private maxBufferMs: number;
private seis: any;
constructor(maxBufferMs: number = 300000) {
this.maxBufferMs = maxBufferMs;
}
public async init() {
if (!this.seis) {
this.seis = await import("seisplotjs");
}
}
/**
* Parse raw miniseed ArrayBuffer dari HTTP response (tanpa skip WebSocket prefix).
* @param expectedStartMs - Hint waktu awal (Unix ms) untuk fallback jika header gagal diparsing.
* @param skipTrim - Jika true, buffer tidak di-trim (gunakan untuk data historis besar).
*/
public processMiniseedRaw(
mseedDataBuffer: ArrayBuffer,
nominalSampleRateMs: number = 10,
expectedStartMs?: number,
skipTrim: boolean = false
) {
if (!this.seis) {
console.warn("WaveformService not initialized with seisplotjs, ignoring data.");
return;
}
const records = this.seis.miniseed.parseDataRecords(mseedDataBuffer);
const newPoints: DataPoint[] = [];
// Used to chain records sequentially when header timestamps can't be trusted
let cumulativeMs: number | null = expectedStartMs ?? null;
// Reasonable range: 1970 ~ 10 years from now (to reject Date.now()-like fallback noise)
const MIN_VALID_TS = 0;
const MAX_VALID_TS = Date.now() + 365 * 24 * 60 * 60 * 1000 * 10;
records.forEach((r: any) => {
const samples = r.decompress();
if (!samples || samples.length === 0) return;
const mean = samples.reduce((sum: number, v: number) => sum + v, 0) / samples.length;
const demeaned = samples.map((v: number) => v - mean);
let msPerSample = nominalSampleRateMs;
if (r.header && r.header.sampleRate) {
msPerSample = 1000 / r.header.sampleRate;
}
// Try to extract start timestamp from header; accept only if it's in valid historical range
let startTimeMs: number | null = null;
if (r.header && r.header.start) {
try {
let parsed: number;
const h = r.header.start;
if (typeof h.toMillis === 'function') {
parsed = h.toMillis();
} else if (typeof h.toEpochMilli === 'function') {
parsed = h.toEpochMilli();
} else if (typeof h.toJSDate === 'function') {
parsed = h.toJSDate().getTime();
} else {
parsed = h.valueOf();
}
if (
typeof parsed === 'number' &&
!isNaN(parsed) &&
parsed > MIN_VALID_TS &&
parsed < MAX_VALID_TS
) {
// Extra sanity: if expectedStartMs provided, parsed should be close to it (within ±3 hours)
if (expectedStartMs !== undefined) {
const diff = Math.abs(parsed - expectedStartMs);
if (diff < 3 * 60 * 60 * 1000) {
startTimeMs = parsed;
}
} else {
startTimeMs = parsed;
}
}
} catch (e) {
console.warn("Failed to parse miniseed header start time:", e);
}
}
// Fallback: chain from previous record end OR expectedStartMs
if (startTimeMs === null) {
if (cumulativeMs !== null) {
startTimeMs = cumulativeMs;
} else {
startTimeMs = Date.now();
}
}
for (let i = 0; i < demeaned.length; i++) {
newPoints.push({
t: startTimeMs! + i * msPerSample,
v: demeaned[i],
});
}
cumulativeMs = startTimeMs! + demeaned.length * msPerSample;
});
if (skipTrim) {
// Add without trimming (historical data can span hours)
for (let i = 0; i < newPoints.length; i++) {
this.buffer.push(newPoints[i]);
}
this.buffer.sort((a, b) => a.t - b.t);
} else {
this.addPoints(newPoints);
}
}
public processMiniseed(mseedDataBuffer: ArrayBuffer, nominalSampleRateMs: number = 10) {
if (!this.seis) {
console.warn("WaveformService not initialized with seisplotjs, ignoring data.");
return;
}
const mseedData = mseedDataBuffer.slice(8);
const records = this.seis.miniseed.parseDataRecords(mseedData);
const newPoints: DataPoint[] = [];
records.forEach((r: any) => {
const samples = r.decompress();
const mean = samples.reduce((sum: number, v: number) => sum + v, 0) / samples.length;
const demeaned = samples.map((v: number) => v - mean);
let startTimeMs = Date.now();
if (r.header && r.header.start) {
try {
startTimeMs = r.header.start.valueOf();
} catch (e) {
console.warn(e);
}
}
let msPerSample = nominalSampleRateMs;
if (r.header && r.header.sampleRate) {
msPerSample = 1000 / r.header.sampleRate;
}
for (let i = 0; i < demeaned.length; i++) {
newPoints.push({
t: startTimeMs + i * msPerSample,
v: demeaned[i],
});
}
});
this.addPoints(newPoints);
}
public generateDemoData(demoPhase: number, samplesToGenerate: number = 4, msPerSample: number = 10): number {
const now = Date.now();
let currentPhase = demoPhase;
const newPoints: DataPoint[] = [];
for (let i = 0; i < samplesToGenerate; i++) {
currentPhase += 0.1;
const noise = (Math.random() - 0.5) * 500;
const primaryWave = Math.sin(currentPhase) * 3000;
const secondaryWave = Math.cos(currentPhase * 0.5) * 1000;
const spike = Math.random() > 0.98 ? (Math.random() - 0.5) * 10000 : 0;
newPoints.push({
t: now - (samplesToGenerate - i - 1) * msPerSample,
v: primaryWave + secondaryWave + noise + spike,
});
}
this.addPoints(newPoints);
return currentPhase;
}
public addPoints(points: DataPoint[]) {
if (points.length === 0) return;
// Use a more performant way to push large arrays if needed, but for miniseed packets this is usually fine
for(let i=0; i<points.length; i++){
this.buffer.push(points[i]);
}
this.trimBuffer();
}
private trimBuffer() {
this.buffer.sort((a, b) => a.t - b.t);
const latestTime = this.buffer[this.buffer.length - 1].t;
const cutoffTime = latestTime - this.maxBufferMs;
let trimIndex = 0;
while (trimIndex < this.buffer.length && this.buffer[trimIndex].t < cutoffTime) {
trimIndex++;
}
if (trimIndex > 0) {
this.buffer.splice(0, trimIndex);
}
}
public getBuffer(): DataPoint[] {
return this.buffer;
}
public clearBuffer(): void {
this.buffer.length = 0;
}
}

View file

@ -9,7 +9,7 @@ export const SOUNDS = {
};
export const VOICES = {
EARTHQUAKE: "/voice/gempabumi.wav",
GEMPABUMI: "/voice/gempabumi.wav",
TERDETEKSI: "/voice/terdeteksi.wav",
POTENSI: "/voice/potensi.wav",
EVAKUASI: "/voice/evakuasi.wav",
@ -30,14 +30,14 @@ export class AudioService {
if (audioDangerElement) {
audioDangerElement.play();
} else {
const danger = new Audio(SOUNDS.DANGER);
danger.play();
const danger = new Audio(SOUNDS.DANGER);
danger.play();
}
setTimeout(() => {
new Audio(VOICES.EARTHQUAKE).play();
new Audio(VOICES.GEMPABUMI).play();
}, 2000);
setTimeout(() => fadeOutAudio(bgNotif, 2000), 6000);
}, 2000);
}

View file

@ -1,156 +0,0 @@
import { DateTime } from "luxon";
import { XMLParser } from "fast-xml-parser";
export interface DataMappingConfig {
id: string;
category: "all" | "feel" | "small";
source_url: string;
type: "json" | "xml";
single_data: boolean;
attribute: string;
data_mapping: Record<string, FieldMapping>;
}
export interface FieldMapping {
attribute: string;
index?: number;
func?: Array<Record<string, any> | string>;
}
export class DataNormalizer {
private parser = new XMLParser();
/**
* Resolve a nested attribute path like "info.point.coordinates"
*/
private resolvePath(obj: any, path: string): any {
if (!path) return obj;
return path.split(".").reduce((prev, curr) => (prev ? prev[curr] : undefined), obj);
}
/**
* Apply transformation functions to a value
*/
private applyTransform(value: any, funcs: Array<Record<string, any> | string>, context?: any): any {
let result = value;
for (const func of funcs) {
if (typeof func === "string") {
result = this.executeFunc(func, result, {}, context);
} else {
const [funcName, params] = Object.entries(func)[0];
result = this.executeFunc(funcName, result, params, context);
}
}
return result;
}
private executeFunc(name: string, value: any, params: any, context?: any): any {
switch (name) {
case "split":
if (typeof value !== "string") return value;
const parts = value.split(params.separator);
return parts[params.index] !== undefined ? parts[params.index] : value;
case "replace":
if (typeof value !== "string") return value;
return value.replace(params.from, params.to);
case "toFloat":
return parseFloat(String(value));
case "toInt":
return parseInt(String(value));
case "fromISO":
return DateTime.fromISO(value, { zone: params.zone || "UTC" });
case "fromFormat":
return DateTime.fromFormat(value, params.format, { zone: params.zone || "UTC" });
case "utcSqlToJakarta":
if (!value) return value;
// Handle SQL format like "2026-03-15 22:45:47.386256"
let dt = DateTime.fromSQL(value, { zone: "UTC" });
if (!dt.isValid) {
// Try fromISO as fallback
dt = DateTime.fromISO(value.replace(" ", "T"), { zone: "UTC" });
}
if (!dt.isValid) {
// Last resort: just try to parse the first 19 chars (YYYY-MM-DD HH:mm:ss)
dt = DateTime.fromFormat(value.substring(0, 19), "yyyy-MM-dd HH:mm:ss", { zone: "UTC" });
}
return dt.setZone("Asia/Jakarta");
case "template":
// Replace ${key} or ${index} with values from context
const template = params.format || "";
return template.replace(/\$\{(\w+)\}/g, (_: string, path: string) => {
return this.resolvePath(context, path);
});
case "formatReadable":
if (value instanceof DateTime) {
return value.toFormat("yyyy-MM-dd HH:mm:ss");
}
return value;
case "toMmi":
// Special logic for MMI calculation if needed
const str = String(value).replaceAll("-", "").replaceAll(" ", "").replaceAll(":", "");
return parseInt(str || "0");
default:
console.warn(`Unknown function: ${name}`);
return value;
}
}
/**
* Normalize raw data item based on field mappings
*/
public normalizeItem<T>(item: any, mapping: Record<string, FieldMapping>): T {
const result: any = {};
for (const [targetField, fieldCfg] of Object.entries(mapping)) {
let value = this.resolvePath(item, fieldCfg.attribute);
if (fieldCfg.index !== undefined && Array.isArray(value)) {
value = value[fieldCfg.index];
}
if (fieldCfg.func && fieldCfg.func.length > 0) {
value = this.applyTransform(value, fieldCfg.func, item);
}
result[targetField] = value;
}
// Calculated fields (MMI)
if (result.time && !result.mmi) {
result.mmi = this.executeFunc("toMmi", result.time, {});
}
return result as T;
}
/**
* Parse raw response and normalize list/single item
*/
public async parseAndNormalize<T>(rawData: string | any, config: DataMappingConfig): Promise<T[]> {
let data = rawData;
// Parse if it's a string (e.g. from fetch response)
if (typeof rawData === "string") {
if (config.type === "xml") {
data = this.parser.parse(rawData);
} else {
data = JSON.parse(rawData);
}
}
// Navigate to the root data attribute
let items = this.resolvePath(data, config.attribute);
if (config.single_data) {
return [this.normalizeItem<T>(items, config.data_mapping)];
}
if (!Array.isArray(items)) {
items = items ? [items] : [];
}
return items.map((item: any) => this.normalizeItem<T>(item, config.data_mapping));
}
}

View file

@ -1,45 +1,6 @@
import { XMLParser } from "fast-xml-parser";
import { DateTime } from "luxon";
import type { InfoGempa, InfoTsunami } from "$lib/types";
import { DataNormalizer, type DataMappingConfig } from "./dataNormalizer";
import sourceDataConfig from "$lib/config/source-data.json";
// ── Utility Helpers ────────────────────────────────────────────────────────
/**
* Robustly parses a SQL-style timestamp (UTC) and converts it to Jakarta time.
* Handles formats like "2026-03-15 22:45:47.386256"
*/
function utcSqlToJakarta(value: string | undefined): DateTime {
if (!value) return DateTime.now().setZone("Asia/Jakarta");
// Try direct SQL parse
let dt = DateTime.fromSQL(value, { zone: "UTC" });
if (!dt.isValid) {
// Try fromISO fallback (replacing space with T)
dt = DateTime.fromISO(value.replace(" ", "T"), { zone: "UTC" });
}
if (!dt.isValid) {
// Try fromFormat fallback (first 19 chars)
dt = DateTime.fromFormat(value.substring(0, 19), "yyyy-MM-dd HH:mm:ss", { zone: "UTC" });
}
return dt.isValid ? dt.setZone("Asia/Jakarta") : DateTime.now().setZone("Asia/Jakarta");
}
function formatReadableTime(dt: DateTime): string {
if (!dt.isValid) return "Invalid Date";
return dt.toFormat("yyyy-MM-dd HH:mm:ss");
}
function timeToMmi(readableTime: string): number {
return parseInt(
readableTime
?.replaceAll("-", "")
.replaceAll(" ", "")
.replaceAll(":", "") || "0",
);
}
// ── Types ──────────────────────────────────────────────────────────────────
@ -100,18 +61,27 @@ function cacheBust(url: string): string {
return `${url}${sep}t=${Date.now()}`;
}
function utcSqlToJakarta(sqlTime: string): DateTime {
return DateTime.fromSQL(sqlTime, { zone: "UTC" }).setZone("Asia/Jakarta");
}
function formatReadableTime(dt: DateTime): string {
return `${dt.toISODate()} ${dt.toLocaleString(DateTime.TIME_24_WITH_SECONDS)}`;
}
function timeToMmi(readableTime: string): number {
return parseInt(
readableTime
?.replaceAll("-", "")
.replaceAll(" ", "")
.replaceAll(":", "") || "0",
);
}
// ── Service ────────────────────────────────────────────────────────────────
export class EarthquakeDataService {
private parser = new XMLParser();
private normalizer = new DataNormalizer();
private configs = sourceDataConfig as DataMappingConfig[];
private getConfig(id: string): DataMappingConfig {
const cfg = this.configs.find((c) => c.id === id);
if (!cfg) throw new Error(`Configuration not found for: ${id}`);
return cfg;
}
// ──── gempaQL.json → GeoJSON + InfoGempa list ────────────────────────
@ -216,26 +186,23 @@ export class EarthquakeDataService {
const jObj = this.parser.parse(text);
const items: GempaLiveItem[] = [];
if (jObj.Infogempa && jObj.Infogempa.gempa) {
const gempas = Array.isArray(jObj.Infogempa.gempa) ? jObj.Infogempa.gempa : [jObj.Infogempa.gempa];
for (const f of gempas) {
if (existingEventIds.has(f.eventid)) continue;
const dt = utcSqlToJakarta(f.waktu);
const readableTime = formatReadableTime(dt);
items.push({
for (const f of jObj.Infogempa.gempa) {
if (existingEventIds.has(f.eventid)) continue;
const dt = utcSqlToJakarta(f.waktu);
const readableTime = formatReadableTime(dt);
items.push({
id: f.eventid,
info: {
id: f.eventid,
info: {
id: f.eventid,
lng: f.bujur,
lat: f.lintang,
mag: f.mag,
depth: f.dalam,
place: f.area,
time: readableTime,
mmi: 0,
},
});
}
lng: f.bujur,
lat: f.lintang,
mag: f.mag,
depth: f.dalam,
place: f.area,
time: readableTime,
mmi: 0,
},
});
}
return items;
@ -254,106 +221,58 @@ export class EarthquakeDataService {
// ──── Unified Initialization ───────────────────────────────────────────
async fetchAllFromConfig(): Promise<Map<string, any>> {
const results = new Map<string, any>();
const promises = this.configs.map(async (cfg) => {
try {
const url = cacheBust(cfg.source_url);
const res = await fetch(url);
const data = cfg.type === "xml" ? await res.text() : await res.json();
const normalized = await this.normalizer.parseAndNormalize<InfoGempa>(data, cfg);
results.set(cfg.id, { config: cfg, raw: data, normalized });
} catch (error) {
console.error(`Error fetching/normalizing source ${cfg.id}:`, error);
results.set(cfg.id, null);
}
});
await Promise.all(promises);
return results;
}
async initializeAllEarthquakes(): Promise<InitializeEarthquakesResult> {
const [titikResult, dirasakanResult, kecilResult, liveResult] =
await Promise.allSettled([
this.fetchTitikGempa(),
this.fetchGempaDirasakan(),
this.fetchGempaKecil(),
this.fetchGempaLive(),
]);
// ──── Unified Initialization ───────────────────────────────────────────
async initializeAllEarthquakes(
customConfigs?: DataMappingConfig[],
): Promise<InitializeEarthquakesResult> {
const configsToUse = customConfigs || this.configs;
const results = new Map<string, any>();
const promises = configsToUse.map(async (cfg) => {
try {
const url = cacheBust(cfg.source_url);
const res = await fetch(url);
const data = cfg.type === "xml" ? await res.text() : await res.json();
const normalized = await this.normalizer.parseAndNormalize<InfoGempa>(
data,
cfg,
);
results.set(cfg.id, { config: cfg, raw: data, normalized });
} catch (error) {
console.error(`Error fetching/normalizing source ${cfg.id}:`, error);
results.set(cfg.id, null);
}
});
await Promise.all(promises);
const configResults = results;
let dirasakanInfo: FetchGempaDirasakanResult | null = null;
let kecilInfo: FetchGempaKecilResult | null = null;
let mainGeoJson: GeoJsonFeatureCollection | null = null;
// Extract successful data
const dirasakanInfo =
dirasakanResult.status === "fulfilled" ? dirasakanResult.value : null;
const kecilInfo =
kecilResult.status === "fulfilled" ? kecilResult.value : null;
const liveItems =
liveResult.status === "fulfilled" ? liveResult.value : [];
// Map `id` to `InfoGempa` and `GeoJsonFeature` for deduplication
const infoMap = new Map<string, InfoGempa>();
const featureMap = new Map<string, GeoJsonFeature>();
for (const [id, result] of configResults.entries()) {
if (!result) continue;
const { config, raw, normalized } = result;
// Handle main GeoJSON (usually from the first "all" source that has features)
if (config.category === "all" && !mainGeoJson && raw.type === "FeatureCollection") {
mainGeoJson = raw;
} else if (config.category === "all" && !mainGeoJson && Array.isArray(raw.features)) {
mainGeoJson = { type: "FeatureCollection", features: raw.features };
}
// Route data based on category
switch (config.category) {
case "feel":
if (!dirasakanInfo && normalized[0]) {
const info = normalized[0];
const sentTime = DateTime.fromISO(raw.sent.replace("WIB", "").trim(), {
zone: "Asia/Jakarta",
});
dirasakanInfo = { id: info.id, info, sentTime, raw };
}
break;
case "small":
if (!kecilInfo && normalized[0]) {
const info = normalized[0];
const feature = raw.features?.[0];
const dt = feature ? utcSqlToJakarta(feature.properties.time) : DateTime.now();
kecilInfo = { feature, info, sentTime: dt, raw };
}
break;
}
// Add all items to global maps for deduplication and sorting
normalized.forEach((info: InfoGempa) => {
if (!infoMap.has(info.id)) {
infoMap.set(info.id, info);
// Try to find corresponding feature in raw data or generate one
let feature = null;
if (Array.isArray(raw.features)) {
feature = raw.features.find((f: any) => f.properties.id === info.id);
}
featureMap.set(info.id, feature || this.toGeoJsonFeature(info));
}
// Priority 1: Main List (Titik Gempa)
if (titikResult.status === "fulfilled") {
titikResult.value.infoList.forEach((info) => infoMap.set(info.id, info));
titikResult.value.geoJson.features.forEach((f) => {
featureMap.set(f.properties.id, f);
});
}
// Priority 2: Live Events
for (const item of liveItems) {
if (!infoMap.has(item.id)) {
infoMap.set(item.id, item.info);
featureMap.set(item.id, this.toGeoJsonFeature(item.info));
}
}
// Priority 3: Gempa Kecil
if (kecilInfo && !infoMap.has(kecilInfo.info.id)) {
infoMap.set(kecilInfo.info.id, kecilInfo.info);
featureMap.set(kecilInfo.info.id, kecilInfo.feature);
}
// Priority 4: Gempa Dirasakan
if (dirasakanInfo && !infoMap.has(dirasakanInfo.info.id)) {
infoMap.set(dirasakanInfo.info.id, dirasakanInfo.info);
featureMap.set(
dirasakanInfo.info.id,
this.toGeoJsonFeature(dirasakanInfo.info),
);
}
// Convert map values to arrays and sort
const finalInfoList = Array.from(infoMap.values());
finalInfoList.sort(
@ -365,16 +284,11 @@ export class EarthquakeDataService {
(info) => featureMap.get(info.id)!,
);
const mergedGeoJson: GeoJsonFeatureCollection = mainGeoJson || {
const mergedGeoJson: GeoJsonFeatureCollection = {
type: "FeatureCollection",
features: finalGeoJsonFeatures,
};
// If we merged multiple sources, ensure features match the info list
if (configResults.size > 1) {
mergedGeoJson.features = finalGeoJsonFeatures;
}
return {
geoJson: mergedGeoJson,
infoList: finalInfoList,

View file

@ -20,7 +20,7 @@ function createDemoStore() {
if (browser) {
channel = new BroadcastChannel('ews-demo-events');
channel.onmessage = (event) => {
if (event.data.type === 'TRIGGER_EARTHQUAKE') {
if (event.data.type === 'TRIGGER_GEMPA') {
update(s => ({ ...s, gempaAlert: event.data.payload }));
} else if (event.data.type === 'TRIGGER_TSUNAMI') {
update(s => ({ ...s, tsunamiAlert: event.data.payload }));
@ -34,7 +34,7 @@ function createDemoStore() {
subscribe,
triggerGempa: (data: InfoGempa) => {
update(s => ({ ...s, gempaAlert: data }));
channel?.postMessage({ type: 'TRIGGER_EARTHQUAKE', payload: data });
channel?.postMessage({ type: 'TRIGGER_GEMPA', payload: data });
},
triggerTsunami: (data: InfoTsunami) => {
update(s => ({ ...s, tsunamiAlert: data }));

View file

@ -1,41 +0,0 @@
import { browser } from "$app/environment";
// BBox format: [minLng, minLat, maxLng, maxLat]
export type BBox = [number, number, number, number];
const DEFAULT_BBOX: BBox = [95, -11, 141, 6];
class MapStore {
#bbox = $state<BBox>(DEFAULT_BBOX);
constructor() {
if (browser) {
const saved = localStorage.getItem("map_bbox");
if (saved) {
try {
this.#bbox = JSON.parse(saved);
} catch (e) {
console.error("Failed to parse saved bbox", e);
}
}
}
}
get bbox() {
return this.#bbox;
}
set bbox(newBBox: BBox) {
this.#bbox = newBBox;
if (browser) {
localStorage.setItem("map_bbox", JSON.stringify(newBBox));
}
}
get urlParams() {
const [minLng, minLat, maxLng, maxLat] = this.#bbox;
return `minlatitude=${minLat}&maxlatitude=${maxLat}&minlongitude=${minLng}&maxlongitude=${maxLng}`;
}
}
export const mapStore = new MapStore();

View file

@ -1,106 +0,0 @@
import { writable, get } from 'svelte/store';
import { browser } from '$app/environment';
import { demoStore } from './demoStore';
export type SerialStatus = 'connected' | 'disconnected' | 'unsupported' | 'connecting';
interface SerialState {
status: SerialStatus;
error: string | null;
}
function createSerialStore() {
const { subscribe, set, update } = writable<SerialState>({
status: browser && 'serial' in navigator ? 'disconnected' : 'unsupported',
error: null
});
let port: SerialPort | null = null;
let writer: WritableStreamDefaultWriter | null = null;
async function connect() {
if (!browser || !('serial' in navigator)) return;
update(s => ({ ...s, status: 'connecting', error: null }));
try {
port = await navigator.serial.requestPort();
await port.open({
baudRate: 115200,
dataTerminalReady: true,
requestToSend: true
});
writer = port.writable?.getWriter() || null;
update(s => ({ ...s, status: 'connected' }));
port.addEventListener('error', () => {
console.error('Serial port error:');
});
// Listen for port disconnection
port.addEventListener('disconnect', () => {
disconnect();
});
} catch (err: any) {
console.error('Serial connection error:', err);
update(s => ({ ...s, status: 'disconnected', error: err.message }));
}
}
async function disconnect() {
if (writer) {
await writer.releaseLock();
writer = null;
}
if (port) {
await port.close();
port = null;
}
update(s => ({ ...s, status: 'disconnected' }));
}
async function sendData(data: any) {
if (!writer) return;
try {
const encoder = new TextEncoder();
const jsonString = JSON.stringify(data) + '\n';
console.log(jsonString);
await writer.write(encoder.encode(jsonString));
} catch (err) {
console.error('Failed to send data:', err);
}
}
// Auto-subscribe to demoStore for earthquake alerts
if (browser) {
demoStore.subscribe(state => {
if (state.gempaAlert) {
sendData({
type: 'GEMPA',
id: state.gempaAlert.id,
mag: state.gempaAlert.mag,
// depth: state.gempaAlert.depth,
// mmi: state.gempaAlert.mmi,
// place: state.gempaAlert.place
});
}
});
}
async function testConnection() {
await sendData({ type: 'TEST', message: 'HEARTBEAT' });
}
return {
subscribe,
connect,
disconnect,
sendData,
testConnection
};
}
export const serialStore = createSerialStore();

View file

@ -13,7 +13,7 @@
}
.loop-stripe-vertical.reverse {
animation: stripeAnimationVertical 15s infinite linear reverse;
animation: loopStripVertical 15s infinite linear reverse;
}
.loop-stripe-vertical-reverse {
@ -30,31 +30,33 @@
@keyframes loopStripVertical {
from {
background-position: 0px 0px;
transform: translateY(0);
}
to {
background-position: 0px calc(-42.4264px * 47);
transform: translateY(calc(-42.4264px * 47));
/* ≈ -2000px, but exact multiple of repeating unit */
}
}
@keyframes stripeAnimationVertical {
from {
background-position: 0px 0px;
transform: translateY(0);
}
to {
background-position: 0px calc(-42.4264px * 47);
transform: translateY(calc(-42.4264px * 47));
/* ≈ -2000px, but exact multiple of repeating unit */
}
}
@keyframes stripeAnimation {
from {
background-position: 0px 0px;
transform: translateX(0);
}
to {
background-position: calc(-42.4264px * 47) 0px;
transform: translateX(calc(-42.4264px * 47));
}
}
@ -86,11 +88,12 @@
@keyframes loopStrip {
from {
background-position: 0px 0px;
transform: translateX(0);
}
to {
background-position: calc(-42.4264px * 47) 0px;
transform: translateX(calc(-42.4264px * 47));
/* exact loop point */
}
}

View file

@ -1,15 +1,12 @@
:root {
--orange: #fa0;
--red: #e60908;
--glow-rgb: 255, 170, 0;
--fill-color: #fa0;
--red: red;
--glow-rgb: 255, 102, 0;
--text-color: #fa0;
--danger-fill-color: #e60908;
--danger-fill-color: #f23;
--danger-glow-rgb: 255, 0, 0;
--danger-text-color: #e60908;
--danger-text-color: #f23;
--gutter-size: 8px;
--border-width: 3px;
color-scheme: dark;
}
@ -37,10 +34,4 @@ body {
border-radius: 10px;
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
background-color: var(--red);
}
@media (max-width: 768px) {
:root {
--border-width: 1px;
}
}

View file

@ -383,67 +383,116 @@
.bordered {
--text-color: var(--orange);
color: var(--text-color);
--border-color: rgba(var(--glow-rgb));
--border-glow-color: rgba(var(--glow-rgb));
border-radius: var(--gutter-size);
border-style: solid;
border-width: var(--border-width);
border-color: var(--border-color);
border-width: 3px;
border-color: var(--orange);
/* box-shadow:
inset 0 0 0 1px var(--border-glow-color),
0 0 0 1px var(--border-glow-color); */
}
.bordered-bottom {
/* color: var(--text-color); */
/* --border-glow-color: rgba(var(--glow-rgb)); */
border-color: unset;
border-bottom: var(--border-width) solid var(--border-color);
}
.bordered-top {
/* color: var(--text-color); */
/* --border-glow-color: rgba(var(--glow-rgb)); */
border-color: unset;
border-top: var(--border-width) solid var(--border-color);
}
.bordered-red {
--text-color: var(--red);
color: var(--text-color);
/* color: var(--danger-text-color);
color: var(--danger-text-color);
--border-glow-color: rgba(var(--danger-glow-rgb));
border-radius: var(--gutter-size);
border-style: solid;
border-width: 3px;
border-color: var(--red); */
--border-color: rgba(var(--danger-glow-rgb));
border-radius: var(--gutter-size);
border-style: solid;
border-width: var(--border-width);
border-color: var(--border-color);
border-color: var(--red);
}
.bordered-red-bottom {
/* color: var(--danger-text-color); */
/* --border-glow-color: rgba(var(--danger-glow-rgb)); */
color: var(--danger-text-color);
--border-glow-color: rgba(var(--danger-glow-rgb));
border-color: unset;
border-bottom: var(--border-width) solid var(--border-color);
border-bottom: 3px solid rgb(var(--danger-glow-rgb));
}
.bordered-red-top {
/* color: var(--danger-text-color); */
/* --border-glow-color: rgba(var(--danger-glow-rgb)); */
color: var(--danger-text-color);
--border-glow-color: rgba(var(--danger-glow-rgb));
border-color: unset;
border-top: var(--border-width) solid var(--border-color);
border-top: 3px solid rgb(var(--danger-glow-rgb));
}
.ews-card {
background-color: black;
transition: 0.3s;
}
.ews-card-header {
padding: 6px;
color: var(--orange);
position: relative;
border-radius: 10px 10px 0px 0px;
}
.ews-card-header .ews-card-text {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
}
.ews-card-header button {
cursor: pointer;
}
.ews-card-footer {
padding: 6px;
/* border-top: 3px var(--red) solid; */
color: var(--orange);
position: relative;
border-radius: 0px 0px 10px 10px;
}
.ews-card-footer .ews-card-text {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
}
/* .ews-card-content {
padding: unset;
} */
.ews-card-content tbody {
font-size: 10px !important;
}
.ews-card-float {
transition: all 0.3s ease-in-out;
}
.ews-card-float .ews-card-content {
display: block;
max-height: 45vh;
overflow-y: auto;
overflow-x: hidden;
}
.ews-card-close-button {
font-size: 24px;
color: #e60003;
padding: 2px 4px;
background-color: black !important;
right: 10px !important;
top: 10px !important;
}
.parallelogram {
height: 30px;
@ -541,19 +590,19 @@
width: 100%;
}
.-striped {
--stripe-color: var(--danger-fill-color);
--stripe-size: 15px;
.-stripeed {
--stripee-color: var(--danger-fill-color);
--stripee-size: 15px;
--glow-color: rgba(var(--danger-glow-rgb), 0.8);
--glow-size: 3px;
background-image: repeating-linear-gradient(-45deg,
var(--glow-color) calc(-1 * var(--glow-size)),
var(--stripe-color) 0,
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripe-size)),
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size)));
var(--stripee-color) 0,
var(--stripee-color) calc(var(--stripee-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripee-size) + var(--glow-size) / 2),
transparent calc(var(--stripee-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripee-size)),
var(--glow-color) calc(2 * var(--stripee-size) - var(--glow-size)));
}
.-blink {
@ -573,6 +622,31 @@
padding-top: var(--label-gutter-size);
}
@media (max-width: 768px) {
.ews-card-float .ews-card-content {
height: 0px;
padding: 0px;
}
.ews-card-float.open .ews-card-content {
height: unset;
padding: 6px;
}
/* .ews-card-float {
margin: auto;
right: 0.25rem;
left: 0.25rem;
} */
.ews-card-float .ews-card-header {
border-bottom: unset;
}
.ews-card-header {
cursor: pointer;
}
}
.github-icon {
width: 20px;
@ -805,12 +879,8 @@
transparent 50%,
rgba(255, 0, 0, 0.1) 50%);
background-size: 100% 4px;
/* animation: scanline 8s linear infinite; */
pointer-events: none;
}
.scanline.animate::after {
animation: scanline 8s linear infinite;
pointer-events: none;
}
table tr td {
@ -978,62 +1048,4 @@ table tr td {
button {
text-box: trim-both cap alphabetic;
}
@media (max-width: 768px) {
.ews-title.internal .text.-characters {
font-size: 2rem;
}
.ews-title.badge .text.-characters {
font-size: 2rem;
}
.ews-title.internal .decal {
height: 50px;
}
}
/* Custom Range Slider */
.custom-range {
-webkit-appearance: none;
width: 100%;
height: 4px;
background: rgba(255, 165, 0, 0.2);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.custom-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: var(--orange);
border: 2px solid black;
border-radius: 0; /* Square/Diamond look for EWS */
cursor: pointer;
box-shadow: 0 0 10px rgba(var(--glow-rgb), 0.5);
transition: all 0.2s ease;
transform: rotate(45deg);
}
.custom-range::-webkit-slider-thumb:hover {
transform: rotate(45deg) scale(1.2);
box-shadow: 0 0 15px rgba(var(--glow-rgb), 0.8);
}
.custom-range::-moz-range-thumb {
width: 16px;
height: 16px;
background: var(--orange);
border: 2px solid black;
border-radius: 0;
cursor: pointer;
box-shadow: 0 0 10px rgba(var(--glow-rgb), 0.5);
transition: all 0.2s ease;
transform: rotate(45deg);
}

View file

@ -1,136 +0,0 @@
.ews-card {
--ews-card-color: var(--orange, #fa0);
--ews-card-radius: var(--gutter-size, 8px);
--ews-card-border-width: 3px;
background-color: black;
transition: 0.3s;
border-radius: var(--ews-card-radius);
border-style: solid;
border-width: var(--ews-card-border-width);
border-color: var(--ews-card-color);
}
.ews-card.ews-card-red {
--ews-card-color: var(--red, #e60908);
}
.ews-card-header {
padding: 6px;
color: var(--ews-card-color);
position: relative;
border-radius: 10px 10px 0px 0px;
border-bottom: var(--ews-card-border-width) solid var(--ews-card-color);
}
.ews-card-header .ews-card-text {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
}
.ews-card-header button {
cursor: pointer;
}
.ews-card-footer {
padding: 6px;
color: var(--ews-card-color);
position: relative;
border-radius: 0px 0px 10px 10px;
border-top: var(--ews-card-border-width) solid var(--ews-card-color);
}
.ews-card-footer .ews-card-text {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
}
.ews-card-content {
color: var(--ews-card-color);
}
.ews-card-content::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
border-radius: 10px;
background-color: rgb(61, 61, 61);
}
.ews-card-content::-webkit-scrollbar {
width: 12px;
height: 12px;
background-color: rgb(61, 61, 61);
}
.ews-card-content::-webkit-scrollbar-thumb {
border-radius: 10px;
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
background-color: var(--red);
}
.ews-card-content tbody {
font-size: 10px !important;
}
.ews-card-float {
transition: all 0.3s ease-in-out;
}
.ews-card-float .ews-card-content {
display: block;
max-height: 45vh;
overflow-y: auto;
overflow-x: hidden;
}
.ews-card-close-button {
font-size: 24px;
color: #e60003;
padding: 2px 4px;
background-color: black !important;
right: 10px !important;
top: 10px !important;
}
@media (max-width: 768px) {
.ews-card {
--ews-card-border-width: 1px;
}
.ews-card-float .ews-card-content {
display: none;
padding: 0px;
}
.ews-card-float.open .ews-card-content {
display: block;
padding: 6px;
}
.ews-card-float {
margin: auto;
right: 0.25rem;
left: 0.25rem;
}
.ews-card-float .ews-card-header {
border-bottom: unset;
}
.ews-card-header {
cursor: pointer;
}
}

View file

@ -1,304 +0,0 @@
/* =============================================
HEXAGONAL GRID STYLES
============================================= */
/* --- Shared hex clip-path (flat-top orientation) --- */
.ews-hex-clip {
clip-path: polygon(24.96% 100%,
0% 50%,
24.96% 0%,
74.87% 0%,
99.84% 50%,
74.87% 100%);
}
/* --- Shared hex clip-path (pointy-top / rotated 90°) --- */
.ews-hex-clip-pointy {
clip-path: polygon(0% 25.13%,
50% 0%,
100% 25.13%,
100% 74.87%,
50% 100%,
0% 74.87%);
}
/* ---- 1. Basic Flat Hex Grid ---- */
.ews-hex-grid-flat {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.ews-hex-cell-flat {
position: relative;
width: 80px;
height: 70px;
aspect-ratio: 584 / 507;
clip-path: polygon(24.96% 100%, 0% 50%, 24.96% 0%, 74.87% 0%, 99.84% 50%, 74.87% 100%);
background-color: rgba(255, 170, 0, 0.08);
border: none;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.25s ease, transform 0.2s ease;
cursor: default;
}
.ews-hex-cell-flat::before {
content: '';
position: absolute;
inset: 2px;
clip-path: polygon(24.96% 100%, 0% 50%, 24.96% 0%, 74.87% 0%, 99.84% 50%, 74.87% 100%);
background-color: rgba(255, 170, 0, 0.04);
z-index: 0;
}
.ews-hex-cell-flat:hover {
background-color: rgba(255, 170, 0, 0.18);
transform: scale(1.06);
}
.ews-hex-cell-flat.ews-hex-danger {
background-color: rgba(255, 34, 51, 0.15);
box-shadow: 0 0 12px 2px rgba(255, 34, 51, 0.3);
}
.ews-hex-cell-flat.ews-hex-danger::before {
background-color: rgba(255, 34, 51, 0.06);
}
.ews-hex-cell-flat.ews-hex-warn {
background-color: rgba(255, 170, 0, 0.15);
box-shadow: 0 0 10px 2px rgba(255, 170, 0, 0.25);
}
.ews-hex-cell-flat.ews-hex-safe {
background-color: rgba(0, 200, 80, 0.12);
box-shadow: 0 0 8px 1px rgba(0, 200, 80, 0.2);
}
.ews-hex-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: var(--orange);
text-align: center;
}
/* ---- 2. Honeycomb Offset Grid ---- */
.ews-hex-honeycomb {
display: flex;
flex-direction: column;
gap: 0;
}
.ews-hex-row {
display: flex;
flex-direction: row;
gap: 4px;
}
.ews-hex-row-offset {
margin-left: calc(72px / 2 + 2px);
margin-top: -14px;
}
.ews-hex-hive {
position: relative;
width: 72px;
height: 83px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, background-color 0.2s ease;
}
.ews-hex-hive.bg-hex {
clip-path: polygon(0% 25.13%, 50% 0%, 100% 25.13%, 100% 74.87%, 50% 100%, 0% 74.87%);
display: unset;
transition: unset;
background-color: transparent;
/* height: unset; */
background-image: url('data:image/svg+xml,<svg width="115" height="133" viewBox="0 0 115 133" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M115 99.1859L57.5 132.25L7.62939e-06 99.1859L7.62939e-06 33.0641L57.5 3.05176e-05L115 33.0641L115 99.1859Z" fill="%23E60003"/><path d="M113.69 98.4426L57.6307 130.678L1.57149 98.4426L1.57149 33.9776L57.6307 1.74199L113.69 33.9776L113.69 98.4426Z" fill="black"/><path d="M111.071 97.0671L57.6309 127.796L4.1913 97.0671L4.1913 35.6145L57.6309 4.88519L111.071 35.6145L111.071 97.0671Z" fill="%23E60003"/></svg>');
background-size: contain;
background-position: center;
background-repeat: no-repeat;
}
.bg-hex.yellow {
background-image: url('data:image/svg+xml,<svg width="115" height="133" viewBox="0 0 115 133" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M115 99.1859L57.5 132.25L7.62939e-06 99.1859L7.62939e-06 33.0641L57.5 3.05176e-05L115 33.0641L115 99.1859Z" fill="%23fa0"/><path d="M113.69 98.4426L57.6307 130.678L1.57149 98.4426L1.57149 33.9776L57.6307 1.74199L113.69 33.9776L113.69 98.4426Z" fill="black"/><path d="M111.071 97.0671L57.6309 127.796L4.1913 97.0671L4.1913 35.6145L57.6309 4.88519L111.071 35.6145L111.071 97.0671Z" fill="%23fa0"/></svg>');
}
.ews-hex-hive.bg-hex-flat {
clip-path: polygon(24.96% 100%, 0% 50%, 24.96% 0%, 74.87% 0%, 99.84% 50%, 74.87% 100%);
display: unset;
transition: unset;
background-color: transparent;
background-image: url('data:image/svg+xml,<svg width="584" height="507" viewBox="0 0 584 507" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M145.77 507L0 253.5L145.77 -3.05176e-05H437.28L583.05 253.5L437.28 507H145.77Z" fill="%23E60003"/><path d="M149.046 500.648L6.92932 253.5L149.046 6.35181H433.253L575.37 253.5L433.253 500.648H149.046Z" fill="black"/><path d="M155.007 492L18 253.5L155.007 15H428.993L566 253.5L428.993 492H155.007Z" fill="%23E60003"/></svg>');
background-size: contain;
background-position: center;
background-repeat: no-repeat;
}
.ews-hex-hive.flat {
clip-path: polygon(24.96% 100%, 0% 50%, 24.96% 0%, 74.87% 0%, 99.84% 50%, 74.87% 100%);
display: unset;
transition: unset;
background-color: transparent;
}
.bg-hex-flat.yellow {
background-image: url('data:image/svg+xml,<svg width="584" height="507" viewBox="0 0 584 507" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M145.77 507L0 253.5L145.77 -3.05176e-05H437.28L583.05 253.5L437.28 507H145.77Z" fill="%23fa0"/><path d="M149.046 500.648L6.92932 253.5L149.046 6.35181H433.253L575.37 253.5L433.253 500.648H149.046Z" fill="black"/><path d="M155.007 492L18 253.5L155.007 15H428.993L566 253.5L428.993 492H155.007Z" fill="%23fa0"/></svg>');
}
.ews-hex-hive.ews-hex-danger {
background-color: rgba(255, 34, 51, 0.18);
filter: drop-shadow(0 0 8px rgba(255, 34, 51, 0.5));
}
.ews-hex-hive.ews-hex-warn {
background-color: rgba(255, 170, 0, 0.18);
filter: drop-shadow(0 0 6px rgba(255, 170, 0, 0.4));
}
.ews-hex-hive-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
text-align: center;
color: var(--orange);
}
/* ---- 3. Animated Status Hex Cells ---- */
.ews-hex-status-cell {
position: relative;
width: 90px;
height: 104px;
clip-path: polygon(0% 25.13%, 50% 0%, 100% 25.13%, 100% 74.87%, 50% 100%, 0% 74.87%);
background-color: rgba(255, 170, 0, 0.06);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.25s ease;
}
.ews-hex-status-cell::after {
content: '';
position: absolute;
inset: 3px;
clip-path: polygon(0% 25.13%, 50% 0%, 100% 25.13%, 100% 74.87%, 50% 100%, 0% 74.87%);
background: transparent;
border: none;
z-index: 0;
}
.ews-hex-status-cell:hover {
transform: scale(1.08);
}
.ews-hex-status-cell.ews-hex-danger {
background-color: rgba(255, 34, 51, 0.12);
filter: drop-shadow(0 0 10px rgba(255, 34, 51, 0.45));
}
.ews-hex-status-cell.ews-hex-warn {
background-color: rgba(255, 170, 0, 0.12);
filter: drop-shadow(0 0 8px rgba(255, 170, 0, 0.4));
}
.ews-hex-status-cell.ews-hex-caution {
background-color: rgba(255, 255, 0, 0.08);
filter: drop-shadow(0 0 6px rgba(255, 255, 0, 0.25));
}
.ews-hex-status-cell.ews-hex-safe {
background-color: rgba(0, 200, 80, 0.08);
filter: drop-shadow(0 0 6px rgba(0, 200, 80, 0.2));
}
.ews-hex-status-cell.ews-hex-pulse {
animation: hexPulse 1.4s ease-in-out infinite;
}
@keyframes hexPulse {
0%,
100% {
filter: drop-shadow(0 0 8px rgba(255, 34, 51, 0.3));
}
50% {
filter: drop-shadow(0 0 22px rgba(255, 34, 51, 0.85));
}
}
.ews-hex-status-inner {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 2px;
color: var(--orange);
}
/* ---- 4. Hex with Strip Decoration ---- */
.ews-hex-stripe-cell {
position: relative;
width: 110px;
height: 127px;
clip-path: polygon(0% 25.13%, 50% 0%, 100% 25.13%, 100% 74.87%, 50% 100%, 0% 74.87%);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 170, 0, 0.05);
transition: transform 0.2s ease;
}
.ews-hex-stripe-cell:hover {
transform: scale(1.06);
}
.ews-hex-stripe-cell.ews-hex-danger {
background-color: rgba(255, 34, 51, 0.08);
}
.ews-hex-stripe-bg {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.ews-hex-stripe-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 1px;
color: var(--orange);
background-color: rgba(0, 0, 0, 0.55);
padding: 6px 10px;
clip-path: polygon(0% 25.13%, 50% 0%, 100% 25.13%, 100% 74.87%, 50% 100%, 0% 74.87%);
width: 88%;
height: 88%;
}

View file

@ -1,73 +0,0 @@
.ews-hex-shape {
position: relative;
width: 100%;
aspect-ratio: 0.866 / 1;
background-image: url("data:image/svg+xml,%3Csvg width='115' height='133' viewBox='0 0 115 133' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M115 99.1859L57.5 132.25L7.62939e-06 99.1859L7.62939e-06 33.0641L57.5 3.05176e-05L115 33.0641L115 99.1859Z' fill='%23E60003'/%3E%3Cpath d='M113.69 98.4426L57.6307 130.678L1.57149 98.4426L1.57149 33.9776L57.6307 1.74199L113.69 33.9776L113.69 98.4426Z' fill='black'/%3E%3Cpath d='M111.071 97.0671L57.6309 127.796L4.1913 97.0671L4.1913 35.6145L57.6309 4.88519L111.071 35.6145L111.071 97.0671Z' fill='%23E60003'/%3E%3C/svg%3E%0A");
background-size: contain;
background-position: center;
background-repeat: no-repeat;
--polygon-shape: polygon(0% 25.13%,
/* top-left point */
50% 0%,
/* top center point */
100% 25.13%,
/* top-right point */
100% 74.87%,
/* bottom-right point */
50% 100%,
/* bottom center point */
0% 74.87%
/* bottom-left point */
);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.ews-hex-shape.clip-content {
overflow: hidden;
clip-path: var(--polygon-shape);
}
.ews-hex-shape.clip-content .inner-content {
--ews-hex-padding: 10px;
width: calc(100% - var(--ews-hex-padding));
height: calc(100% - var(--ews-hex-padding));
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
clip-path: var(--polygon-shape);
}
.ews-hex-shape.flat-top {
aspect-ratio: 1.1547 / 1;
background-image: url("data:image/svg+xml,%3Csvg width='584' height='507' viewBox='0 0 584 507' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M145.77 507L0 253.5L145.77 -3.05176e-05H437.28L583.05 253.5L437.28 507H145.77Z' fill='%23E60003'/%3E%3Cpath d='M149.046 500.648L6.92932 253.5L149.046 6.35181H433.253L575.37 253.5L433.253 500.648H149.046Z' fill='black'/%3E%3Cpath d='M155.007 492L18 253.5L155.007 15H428.993L566 253.5L428.993 492H155.007Z' fill='%23E60003'/%3E%3C/svg%3E%0A");
--polygon-shape: polygon(24.96% 100%,
/* 145.77/584, 507/507 */
0% 50%,
/* 0/584, 253.5/507 */
24.96% 0%,
/* 145.77/584, 0/507 */
74.87% 0%,
/* 437.28/584, 0/507 */
99.84% 50%,
/* 583.05/584, 253.5/507 */
74.87% 100%
/* 437.28/584, 507/507 */
);
}
.ews-hex-shape.orange {
background-image: url("data:image/svg+xml,%3Csvg width='115' height='133' viewBox='0 0 115 133' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M115 99.1859L57.5 132.25L7.62939e-06 99.1859L7.62939e-06 33.0641L57.5 3.05176e-05L115 33.0641L115 99.1859Z' fill='%23fa0'/%3E%3Cpath d='M113.69 98.4426L57.6307 130.678L1.57149 98.4426L1.57149 33.9776L57.6307 1.74199L113.69 33.9776L113.69 98.4426Z' fill='black'/%3E%3Cpath d='M111.071 97.0671L57.6309 127.796L4.1913 97.0671L4.1913 35.6145L57.6309 4.88519L111.071 35.6145L111.071 97.0671Z' fill='%23fa0'/%3E%3C/svg%3E%0A");
}
.ews-hex-shape.orange.flat-top {
background-image: url("data:image/svg+xml,%3Csvg width='584' height='507' viewBox='0 0 584 507' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M145.77 507L0 253.5L145.77 -3.05176e-05H437.28L583.05 253.5L437.28 507H145.77Z' fill='%23fa0'/%3E%3Cpath d='M149.046 500.648L6.92932 253.5L149.046 6.35181H433.253L575.37 253.5L433.253 500.648H149.046Z' fill='black'/%3E%3Cpath d='M155.007 492L18 253.5L155.007 15H428.993L566 253.5L428.993 492H155.007Z' fill='%23fa0'/%3E%3C/svg%3E%0A");
}

View file

@ -1,266 +0,0 @@
.ews-rib-layout {
display: inline-flex;
height: auto;
justify-content: center;
gap: 1rem;
width: 100%;
padding-left: 1rem;
padding-right: 1rem;
position: relative;
}
.ews-rib-layout__branch {
position: relative;
padding-top: 1rem;
padding-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 1024px) {
.ews-rib-layout__branch {
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}
}
.ews-rib-layout__spine {
position: absolute;
height: auto;
left: 50%;
top: 0;
bottom: 0;
width: 0.25rem;
transform: translateX(-50%);
z-index: 0;
background-color: var(--orange);
}
.ews-rib-layout__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
position: relative;
z-index: 10;
}
.ews-rib-layout__node {
display: flex;
align-items: center;
position: relative;
}
.ews-rib-layout__node--left {
flex-grow: 1;
justify-content: flex-end;
padding-right: 0;
grid-column-start: 1;
}
.ews-rib-layout__node--right {
justify-content: flex-start;
padding-left: 0;
grid-column-start: 2;
width: auto;
}
.ews-rib-layout__node-content {
position: relative;
display: flex;
}
.ews-rib-layout__connector-wrapper {
width: 6rem;
display: flex;
position: relative;
}
.ews-rib-layout__connector-wrapper--left {
justify-content: flex-end;
}
.ews-rib-layout__connector-wrapper--right {
justify-content: flex-start;
}
.ews-rib-layout__connector-line {
height: 2px;
width: 6rem;
z-index: 0;
background-color: var(--orange);
}
.ews-rib-layout__connector-text {
font-weight: 700;
font-size: 0.75rem;
line-height: 1rem;
text-transform: uppercase;
position: absolute;
top: 0.25rem;
z-index: 10;
color: var(--orange);
}
.ews-rib-layout__connector-text--left {
left: 0.5rem;
text-align: left;
}
.ews-rib-layout__connector-text--right {
right: 0.5rem;
text-align: right;
}
.ews-rib-node {
--bg-url: url('data:image/svg+xml,<svg width="500" height="100" viewBox="0 0 500 100" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M500 0H37.5414L-1.63437e-05 100H462.459L500 0Z" fill="%2300FF80"/><path d="M168.5 0H37.1618L30.5 19H161.838L168.5 0Z" fill="%23FC8416"/></svg>');
background-image: var(--bg-url);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
position: relative;
z-index: 1;
width: 6rem;
height: 1.5rem;
display: flex;
flex-grow: 1;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
margin-top: 1.5rem;
margin-right: -0.5rem;
}
.parent-node {
transform: translateX(0px);
rotate: -21deg !important;
transition: all 0.2s ease-in-out;
margin-left: 0px;
z-index: 1;
}
.parent-node.flip {
rotate: 21deg !important;
}
.node:hover .parent-node {
cursor: pointer;
transform: translateX(-20px);
}
.ews-rib-node.danger {
--bg-url: url('data:image/svg+xml,<svg width="500" height="100" viewBox="0 0 500 100" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M500 0H37.5414L-1.63437e-05 100H462.459L500 0Z" fill="%23E60003"/><path d="M168.5 0H37.1618L30.5 19H161.838L168.5 0Z" fill="%23FC8416"/></svg>');
}
.ews-rib-node.flip {
margin-left: -0.5rem;
--bg-url: url('data:image/svg+xml,<svg width="500" height="100" viewBox="0 0 500 100" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 0H462.459L500 100H37.5415L0 0Z" fill="%2300FF80"/><path d="M0 0H131.338L138 19H6.66178L0 0Z" fill="%23FC8416"/></svg>');
}
.node-flip:hover .parent-node {
cursor: pointer;
transform: translateX(20px);
}
.ews-rib-node.flip.danger {
--bg-url: url('data:image/svg+xml,<svg width="500" height="100" viewBox="0 0 500 100" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 0H462.459L500 100H37.5415L0 0Z" fill="%23E60003"/><path d="M0 0H131.338L138 19H6.66178L0 0Z" fill="%23FC8416"/></svg>');
}
.ews-rib-node.slide-fade-in {
opacity: 0;
transform: translateX(-20px);
animation: slideFadeIn 0.5s ease-in-out forwards;
}
.ews-rib-node-flip.slide-fade-in {
opacity: 0;
transform: translateX(20px);
animation: slideFadeInFlip 0.5s ease-in-out forwards;
}
/* Slide and fade in animation */
.slide-fade-in {
animation: slideFadeIn 0.5s ease-in-out forwards;
}
@keyframes slideFadeIn {
0% {
opacity: 0;
transform: translateX(-20px);
}
50% {
opacity: 1;
transform: translateX(-15px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.slide-fade-in-flip {
animation: slideFadeInFlip 0.5s ease-in-out forwards;
}
@keyframes slideFadeInFlip {
0% {
opacity: 0;
transform: translateX(20px);
}
50% {
opacity: 1;
transform: translateX(15px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.line-node {
width: 0%;
animation: slideWidth 0.5s ease-in-out forwards;
}
@keyframes slideWidth {
0% {
width: 0%;
}
100% {
width: calc(var(--spacing) * 24);
}
}
.line-central {
height: 0%;
animation: slideHeight 0.5s ease-in-out forwards;
}
@keyframes slideHeight {
0% {
height: 0%;
}
100% {
height: 100%;
}
}

View file

@ -1,128 +0,0 @@
/* Strip Bar Styles */
.ews-stripe-wrapper {
width: max(200vw, 2000px);
height: 30px;
overflow: hidden;
white-space: nowrap;
margin: 0px !important;
padding: 0px !important;
display: flex;
}
.ews-stripe-wrapper.vertical {
height: 100%;
width: 30px;
display: flex;
flex-direction: column;
}
.ews-stripe-bar {
width: max(200vw, 2000px);
height: 100%;
display: inline-block;
flex-shrink: 0;
margin-right: 0px !important;
margin-left: 0px !important;
/* margin-bottom: -5px; */
--ews-ews-stripe-color: var(--orange, #fa0);
--ews-ews-stripe-size: 15px;
--ews-glow-color: rgba(255, 94, 0, 0.8);
--ews-glow-size: 3px;
background-image: repeating-linear-gradient(-45deg,
var(--ews-glow-color) calc(-1 * var(--ews-glow-size)),
var(--ews-ews-stripe-color) 0,
var(--ews-ews-stripe-color) calc(var(--ews-ews-stripe-size) - var(--ews-glow-size) / 2),
var(--ews-glow-color) calc(var(--ews-ews-stripe-size) + var(--ews-glow-size) / 2),
transparent calc(var(--ews-ews-stripe-size) + var(--ews-glow-size) / 2),
transparent calc(2 * var(--ews-ews-stripe-size)),
var(--ews-glow-color) calc(2 * var(--ews-ews-stripe-size) - var(--ews-glow-size)));
/* background-size: var(--background-width) var(--background-height); */
background-size: 47px 47px;
}
.ews-stripe-bar.red {
--ews-ews-stripe-color: var(--red, #e60908);
--ews-ews-stripe-size: 15px;
--ews-glow-color: rgba(255, 17, 0, 0.8);
--ews-glow-size: 3px;
}
.ews-stripe-bar.vertical {
width: 100%;
height: 100%;
}
.ews-stripe-bar-red {
width: max(200vw, 2000px);
height: 30px;
display: inline-block;
flex-shrink: 0;
/* margin-bottom: -5px; */
--ews-ews-stripe-color: var(--red, #e60908);
--ews-ews-stripe-size: 15px;
--ews-glow-color: rgba(255, 17, 0, 0.8);
--ews-glow-size: 3px;
background-image: repeating-linear-gradient(-45deg,
var(--ews-glow-color) calc(-1 * var(--ews-glow-size)),
var(--ews-ews-stripe-color) 0,
var(--ews-ews-stripe-color) calc(var(--ews-ews-stripe-size) - var(--ews-glow-size) / 2),
var(--ews-glow-color) calc(var(--ews-ews-stripe-size) + var(--ews-glow-size) / 2),
transparent calc(var(--ews-ews-stripe-size) + var(--ews-glow-size) / 2),
transparent calc(2 * var(--ews-ews-stripe-size)),
var(--ews-glow-color) calc(2 * var(--ews-ews-stripe-size) - var(--ews-glow-size)));
}
.ews-stripe-bar-vertical {
height: max(2000px, 200vh);
transform: translate3d(0, 0, 0);
--ews-ews-stripe-color: var(--orange, #fa0);
--ews-ews-stripe-size: 15px;
--ews-glow-color: rgba(255, 94, 0, 0.8);
--ews-glow-size: 3px;
background-image: repeating-linear-gradient(45deg,
var(--ews-glow-color) calc(-1 * var(--ews-glow-size)),
var(--ews-ews-stripe-color) 0,
var(--ews-ews-stripe-color) calc(var(--ews-ews-stripe-size) - var(--ews-glow-size) / 2),
var(--ews-glow-color) calc(var(--ews-ews-stripe-size) + var(--ews-glow-size) / 2),
transparent calc(var(--ews-ews-stripe-size) + var(--ews-glow-size) / 2),
transparent calc(2 * var(--ews-ews-stripe-size)),
var(--ews-glow-color) calc(2 * var(--ews-ews-stripe-size) - var(--ews-glow-size)));
}
.ews-stripe-bar-red-vertical {
height: max(2000px, 200vh);
transform: translate3d(0, 0, 0);
--ews-ews-stripe-color: var(--red, #e60908);
--ews-ews-stripe-size: 15px;
--ews-glow-color: rgba(255, 17, 0, 0.8);
--ews-glow-size: 3px;
background-image: repeating-linear-gradient(45deg,
var(--ews-glow-color) calc(-1 * var(--ews-glow-size)),
var(--ews-ews-stripe-color) 0,
var(--ews-ews-stripe-color) calc(var(--ews-ews-stripe-size) - var(--ews-glow-size) / 2),
var(--ews-glow-color) calc(var(--ews-ews-stripe-size) + var(--ews-glow-size) / 2),
transparent calc(var(--ews-ews-stripe-size) + var(--ews-glow-size) / 2),
transparent calc(2 * var(--ews-ews-stripe-size)),
var(--ews-glow-color) calc(2 * var(--ews-ews-stripe-size) - var(--ews-glow-size)));
}
.ews-stripe-wrapper-vertical {
height: max(200vh, 2000px);
overflow: hidden;
white-space: nowrap;
margin: 0px !important;
padding: 0px !important;
display: flex;
}
.ews-stripe {
background-color: black;
width: 100vw;
border-top: 1px solid var(--red, #e60908);
border-bottom: 1px solid var(--red, #e60908);
position: fixed;
}

View file

@ -1,19 +0,0 @@
.ews-waveform-chart-wrapper {
width: 100%;
height: 100%;
position: relative;
cursor: crosshair;
}
.ews-waveform-chart-canvas {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none;
z-index: 0;
}

View file

@ -10,11 +10,8 @@
}
.stripe-wrapper.vertical {
/* height: max(200vh, 2000px); */
height: 100%;
height: max(200vh, 2000px);
width: 30px;
display: flex;
flex-direction: column;
}
@ -25,35 +22,32 @@
flex-shrink: 0;
margin-right: 0px !important;
margin-left: 0px !important;
/* margin-bottom: -5px; */
--stripe-color: var(--orange);
--stripe-size: 15px;
margin-bottom: -5px;
--stripee-color: var(--orange);
--stripee-size: 15px;
--glow-color: rgba(255, 94, 0, 0.8);
--glow-size: 3px;
background-image: repeating-linear-gradient(-45deg,
var(--glow-color) calc(-1 * var(--glow-size)),
var(--stripe-color) 0,
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripe-size)),
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size)));
/* background-size: var(--background-width) var(--background-height); */
background-size: 47px 47px;
var(--stripee-color) 0,
var(--stripee-color) calc(var(--stripee-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripee-size) + var(--glow-size) / 2),
transparent calc(var(--stripee-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripee-size)),
var(--glow-color) calc(2 * var(--stripee-size) - var(--glow-size)));
}
.stripe-bar.red {
--stripe-color: var(--red);
--stripe-size: 15px;
--stripee-color: var(--red);
--stripee-size: 15px;
--glow-color: rgba(255, 17, 0, 0.8);
--glow-size: 3px;
}
.stripe-bar.vertical {
width: 100%;
/* height: max(2000px, 200vh);
transform: translate3d(0, 0, 0); */
height: 100%;
height: max(200vw, 200vh);
transform: translate3d(0, 0, 0);
}
@ -62,53 +56,53 @@
height: 30px;
display: inline-block;
flex-shrink: 0;
/* margin-bottom: -5px; */
--stripe-color: var(--red);
--stripe-size: 15px;
margin-bottom: -5px;
--stripee-color: var(--red);
--stripee-size: 15px;
--glow-color: rgba(255, 17, 0, 0.8);
--glow-size: 3px;
background-image: repeating-linear-gradient(-45deg,
var(--glow-color) calc(-1 * var(--glow-size)),
var(--stripe-color) 0,
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripe-size)),
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size)));
var(--stripee-color) 0,
var(--stripee-color) calc(var(--stripee-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripee-size) + var(--glow-size) / 2),
transparent calc(var(--stripee-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripee-size)),
var(--glow-color) calc(2 * var(--stripee-size) - var(--glow-size)));
}
.stripe-bar-vertical {
height: max(2000px, 200vh);
height: max(200vw, 200vh);
transform: translate3d(0, 0, 0);
--stripe-color: var(--orange);
--stripe-size: 15px;
--stripee-color: var(--orange);
--stripee-size: 15px;
--glow-color: rgba(255, 94, 0, 0.8);
--glow-size: 3px;
background-image: repeating-linear-gradient(45deg,
var(--glow-color) calc(-1 * var(--glow-size)),
var(--stripe-color) 0,
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripe-size)),
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size)));
var(--stripee-color) 0,
var(--stripee-color) calc(var(--stripee-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripee-size) + var(--glow-size) / 2),
transparent calc(var(--stripee-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripee-size)),
var(--glow-color) calc(2 * var(--stripee-size) - var(--glow-size)));
}
.stripe-bar-red-vertical {
height: max(2000px, 200vh);
height: max(200vw, 200vh);
transform: translate3d(0, 0, 0);
--stripe-color: var(--red);
--stripe-size: 15px;
--stripee-color: var(--red);
--stripee-size: 15px;
--glow-color: rgba(255, 17, 0, 0.8);
--glow-size: 3px;
background-image: repeating-linear-gradient(45deg,
var(--glow-color) calc(-1 * var(--glow-size)),
var(--stripe-color) 0,
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripe-size)),
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size)));
var(--stripee-color) 0,
var(--stripee-color) calc(var(--stripee-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripee-size) + var(--glow-size) / 2),
transparent calc(var(--stripee-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripee-size)),
var(--glow-color) calc(2 * var(--stripee-size) - var(--glow-size)));
}
.stripe-wrapper-vertical {

View file

@ -166,7 +166,6 @@
/* Textarea variant */
.ews-textarea {
background-color: rgba(0, 0, 0, 0.8);
color-scheme: dark;
color: var(--text-color);
font-family: "Roboto Condensed", Arial, Helvetica, sans-serif;
font-size: 0.875rem;
@ -215,7 +214,6 @@
/* Select variant */
.ews-select {
background-color: rgba(0, 0, 0, 0.8);
color-scheme: dark;
color: var(--text-color);
font-family: "Roboto Condensed", Arial, Helvetica, sans-serif;
text-transform: uppercase;

View file

@ -1,67 +0,0 @@
export interface Snapshot {
id: string;
timestamp: number;
time: string;
place: string;
mag: number | string;
imageBase64: string;
}
const DB_NAME = "ews-snapshots-db";
const DB_VERSION = 1;
const STORE_NAME = "snapshots";
export function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "id" });
}
};
request.onsuccess = (event) => {
resolve((event.target as IDBOpenDBRequest).result);
};
request.onerror = (event) => {
reject((event.target as IDBOpenDBRequest).error);
};
});
}
export async function saveSnapshot(snapshot: Snapshot): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], "readwrite");
const store = transaction.objectStore(STORE_NAME);
const request = store.put(snapshot);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
export async function getSnapshots(): Promise<Snapshot[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], "readonly");
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
const results = request.result as Snapshot[];
results.sort((a, b) => b.timestamp - a.timestamp); // newest first
resolve(results);
};
request.onerror = () => reject(request.error);
});
}
export async function deleteSnapshot(id: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], "readwrite");
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}

View file

@ -10,21 +10,19 @@ export function createGempaPopupHTML(data: {
time: string;
lat: number;
lng: number;
place?: string;
}): string {
return `
<div class="ews-card ews-card-red min-h-48 min-w-48 whitespace-pre-wrap" data-id="${data.id}">
<div class="ews-card bordered-red min-h-48 min-w-48 whitespace-pre-wrap" data-id="${data.id}">
<div class="ews-card-header bordered-red-bottom overflow-hidden">
<div class="ews-stripe-wrapper " style="height: 30px;"><div class="ews-stripe-bar red loop-stripe reverse anim-duration-20"></div> <div class="ews-stripe-bar red loop-stripe reverse anim-duration-20"></div></div>
<div class="stripe-wrapper"><div class="stripe-bar-red loop-stripe-reverse anim-duration-20"></div><div class="stripe-bar-red loop-stripe-reverse anim-duration-20"></div></div>
<div class="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center">
<p class="p-1 bg-black font-bold text-xs ews-title">EARTHQUAKE</p>
<p class="p-1 bg-black font-bold text-xs ews-title">GEMPA BUMI</p>
</div>
</div>
<div class="ews-card-content p-1 lg:p-2 text-sm w-full" style="font-size:10px">
<table class="w-full">
<tbody>
<tr><td class="flex">Magnitudo</td><td class="text-right break-words pl-2">${Number(data.mag).toFixed(1)}</td></tr>
<tr><td class="flex">Place</td><td class="text-right break-words pl-2">${data.place}</td></tr>
<tr><td class="flex">Kedalaman</td><td class="text-right break-words pl-2">${data.depth}</td></tr>
<tr><td class="flex">Waktu</td><td class="text-right break-words pl-2">${data.time}</td></tr>
<tr><td class="flex">Lokasi (Lat,Lng)</td><td class="text-right break-words pl-2">${data.lat} , ${data.lng}</td></tr>

View file

@ -27,7 +27,7 @@
</svelte:head>
<div class="backgroundline absolute inset-0 pointer-events-none z-10"></div>
<div class="no-snapshot scanline fixed inset-0 pointer-events-none z-10"></div>
<div class="scanline fixed inset-0 pointer-events-none z-10"></div>
{@render children()}
{#if $demoStore.gempaAlert}

File diff suppressed because it is too large Load diff

View file

@ -1,32 +0,0 @@
import { json } from '@sveltejs/kit';
import { EarthquakeDataService } from '$lib/services/earthquakeDataService';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
try {
const service = new EarthquakeDataService();
const data = await service.initializeAllEarthquakes();
return json(data, {
headers: {
'Cache-Control': 'public, max-age=60'
}
});
} catch (error) {
console.error('API Error:', error);
return json({ error: 'Failed to fetch earthquake data' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const customConfigs = await request.json();
const service = new EarthquakeDataService();
const data = await service.initializeAllEarthquakes(customConfigs);
return json(data);
} catch (error) {
console.error('API Error (POST):', error);
return json({ error: 'Failed to fetch earthquake data with custom config' }, { status: 500 });
}
};

File diff suppressed because it is too large Load diff

View file

@ -7,16 +7,11 @@
import type { TitikTsunami } from "$lib/components/TitikTsunami";
import HexGrid from "$lib/components/HexGrid.svelte";
// import Highlight from "svelte-highlight";
// import css from "svelte-highlight/languages/css";
// import vbscriptHtml from "svelte-highlight/languages/vbscript-html";
import Highlight from "svelte-highlight";
import css from "svelte-highlight/languages/css";
import vbscriptHtml from "svelte-highlight/languages/vbscript-html";
import StripeBar from "$lib/components/StripeBar.svelte";
import InfiniteScroll from "$lib/components/InfiniteScroll.svelte";
import HexShape from "$lib/components/HexShape.svelte";
import MentalToxicityLevel from "$lib/components/MentalToxicityLevel.svelte";
import ThreadedComments, {
type ThreadTone,
} from "$lib/components/ThreadedComments.svelte";
let showGempaBumiAlert = $state(false);
let showTsunamiAlert = $state(false);
@ -52,103 +47,13 @@
{ label: "ZONA F", val: "5.9", warn: true },
{ label: "ZONA G", val: "8.1", danger: true },
];
const threadedSpineItems: {
id: string;
label: string;
level: number;
tone: ThreadTone;
}[] = [
{ id: "spine-1", label: "MAIN THREAD TOPIC", level: 1, tone: "danger" },
{ id: "spine-2", label: "FOLLOW-UP COMMENT", level: 2, tone: "normal" },
{ id: "spine-3", label: "NESTED DETAIL NOTE", level: 3, tone: "normal" },
];
const threadedNestedItems: {
id: string;
label: string;
level: number;
tone: ThreadTone;
children?: typeof threadedNestedItems;
}[] = [
{
id: "threaded-1",
label: "MAIN THREAD TOPIC",
level: 1,
tone: "danger",
children: [
{
id: "threaded-1-1",
label: "REPLY CHILD A",
level: 2,
tone: "normal",
children: [
{
id: "threaded-1-1-1",
label: "DEEP NESTED REPLY",
level: 3,
tone: "normal",
children: [
{
id: "threaded-1-1-1-1",
label: "DEEP DEEP NESTED REPLY",
level: 4,
tone: "muted",
},
{
id: "threaded-1-1-1-2",
label: "DEEP DEEP NESTED REPLY",
level: 4,
tone: "muted",
},
],
},
],
},
{
id: "threaded-1-2",
label: "REPLY CHILD B",
level: 2,
tone: "muted",
},
],
},
{
id: "threaded-2",
label: "SECOND COMMENT",
level: 1,
tone: "normal",
children: [
{
id: "threaded-2-1",
label: "REPLY TO SECOND",
level: 2,
tone: "normal",
},
],
},
{
id: "threaded-3",
label: "THIRD COMMENT",
level: 1,
tone: "muted",
},
];
let lastToggledId = $state<string | null>(null);
let lastToggledState = $state<boolean | null>(null);
function handleToggle(id: string, collapsed: boolean) {
lastToggledId = id;
lastToggledState = collapsed;
}
</script>
<svelte:head>
<title>Showcase UI Components</title>
</svelte:head>
<div class="p-8 min-h-screen max-w-4xl mx-auto text-xs">
<div class="p-8 min-h-screen w-4xl mx-auto text-xs">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Showcase UI Components</h1>
</div>
@ -162,37 +67,23 @@
<div class="max-w-24">
<p>Default</p>
<div class="h-[30px]">
<StripeBar className="my-2 "></StripeBar>
</div>
<StripeBar className="my-2"></StripeBar>
<p>Animated</p>
<div class="h=[30px]">
<StripeBar loop={true}></StripeBar>
</div>
<StripeBar loop={true}></StripeBar>
<p>Red</p>
<div class="h-[30px]">
<StripeBar color="red" loop={true} duration={20}></StripeBar>
</div>
<StripeBar color="red" loop={true} duration={20}></StripeBar>
<p>Reverse</p>
<div class="h-[30px]">
<StripeBar color="red" loop={true} reverse={true} duration={20}
></StripeBar>
</div>
<p>Flip</p>
<div class="h-[30px]">
<StripeBar className="my-2 -scale-x-100"></StripeBar>
</div>
<StripeBar color="red" loop={true} reverse={true} duration={20}
></StripeBar>
</div>
<div class="w-full">
<p>Vertical</p>
<div class="h-[100px] flex gap-2">
<StripeBar orientation="vertical" className="w-[30px] h-full"
></StripeBar>
<StripeBar orientation="vertical"></StripeBar>
<StripeBar orientation="vertical" color="red"></StripeBar>
</div>
@ -212,7 +103,6 @@
</div>
</div>
</section>
<!-- CARD DEMO -->
<section>
<h2 class="text-xl font-semibold mb-4 border-b border-gray-700 pb-2">
@ -295,30 +185,34 @@
</div>
<div class="flex gap-2 w-full justify-center items-center">
<div class="">
<HexShape clipContent={true} color="orange" className="h-[100px]">
<StripeBar
className="bg-black"
loop={true}
reverse={true}
duration={20}
color="red"
></StripeBar>
</HexShape>
<div
class="hex-shape orange flat-top clip-content h-[100px] flex flex-col justify-center items-center"
>
<div class="inner-content">
<StripeBar
className="bg-black"
color="red"
loop={true}
reverse={true}
duration={20}
></StripeBar>
</div>
</div>
</div>
<div class="">
<HexShape
clipContent={true}
flatTop={false}
color="orange"
className="h-[100px]"
<div
class="hex-shape orange clip-content h-[100px] flex flex-col justify-center items-center"
>
<StripeBar
className="bg-black"
loop={true}
color="red"
duration={20}
></StripeBar>
</HexShape>
<div class="inner-content">
<StripeBar
className="bg-black"
color="red"
loop={true}
reverse={true}
duration={20}
></StripeBar>
</div>
</div>
</div>
</div>
<div class="long-hex h-[100px]"></div>
@ -330,121 +224,42 @@
</div>
</section>
<section class="col-span-1 md:col-span-2 lg:col-span-3">
<h2 class="text-xl font-semibold mb-4 border-b border-gray-700 pb-2">
Infinite Scroll
</h2>
<!-- Demo 1: Ticker bar with emergency notices -->
<div class="flex w-full relative mb-4">
<StripeBar className="my-2 " size="200px"></StripeBar>
<StripeBar className="my-2 -scale-x-100 " size="200px"></StripeBar>
<div
class="absolute top-0 left-0 bottom-0 right-0 flex items-center justify-center text-center"
>
<div class="p-1 bg-black rounded-lg w-full">
<div class="bordered-red bg-black p-2 w-full text-primary">
<InfiniteScroll speed={60} gap={48}>
{#snippet children()}
<div class="flex flex-col text-center px-4">
<span class="text-xs">CONDITION: RED</span>
<b class="text-4xl" style="line-height: 0.8;">EMERGENCY</b>
<span class="text-xs">CODE: 102</span>
</div>
{/snippet}
</InfiniteScroll>
</div>
</div>
</div>
</div>
<!-- Demo 2: Variasi speed & direction -->
<div class="flex flex-col gap-3">
<div>
<p class="text-gray-500 text-xs mb-1">speed=40 (slow), gap=32px</p>
<div class="bordered p-2">
<InfiniteScroll speed={40} gap={32}>
{#snippet children()}
<span class="ews-text text-sm px-4">⬡ SEISMIC ALERT</span>
<span class="ews-text danger text-sm px-4">⚠ AWAS GEMPA</span>
<span class="ews-text text-sm px-4">⬡ ZONE: SUMATERA</span>
<span class="ews-text danger text-sm px-4">⚠ MAG 7.2</span>
{/snippet}
</InfiniteScroll>
</div>
</div>
<div>
<p class="text-gray-500 text-xs mb-1">
speed=120 (fast), direction=right
</p>
<div class="bordered p-2">
<InfiniteScroll speed={120} gap={32} direction="right">
{#snippet children()}
<span class="ews-text-digital text-sm px-4"
>STATION: BDG-01</span
>
<span class="ews-text-digital text-sm px-4">DEPTH: 10km</span>
<span class="ews-text-digital text-sm px-4">LAT: -6.208</span>
<span class="ews-text-digital text-sm px-4">LNG: 106.845</span>
{/snippet}
</InfiniteScroll>
</div>
</div>
<div>
<p class="text-gray-500 text-xs mb-1">
pauseOnHover, speed=80, gap=64px — single large item
</p>
<div class="bordered-red p-2">
<InfiniteScroll speed={80} gap={64} pauseOnHover={true}>
{#snippet children()}
<div class="flex flex-col text-center px-2">
<span class="text-xs text-gray-400">HOVER TO PAUSE</span>
<b class="text-2xl ews-text danger">⚠ TSUNAMI WARNING</b>
<span class="text-xs">EVAKUASI SEGERA</span>
</div>
{/snippet}
</InfiniteScroll>
</div>
</div>
</div>
</section>
<!-- HEXAGONAL GRID -->
<section class="col-span-1 md:col-span-2 lg:col-span-3">
<h2 class="text-xl font-semibold mb-4 border-b border-gray-700 pb-2">
Hexagonal Grid
</h2>
<div class="w-full flex flex-col md:flex-row gap-2">
<!-- Honeycomb Offset Grid -->
<div class="basis-0 flex-1">
<p class="text-gray-500 text-xs mb-3">Honeycomb Offset Grid</p>
<HexGrid gap={0}>
{#each { length: 30 } as _, i}
<div class="ews-hex-hive">
<HexShape clipContent={true} flatTop={false}>
{i}
</HexShape>
</div>
{/each}
</HexGrid>
</div>
<div class="flex flex-col gap-8">
<div class="w-full flex gap-2">
<!-- Honeycomb Offset Grid -->
<div class="basis-0 flex-1">
<p class="text-gray-500 text-xs mb-3">Honeycomb Offset Grid</p>
<HexGrid>
{#each { length: 30 } as _, i}
<div class="hex-hive bg-hex">
<HexShape clipContent={true} flatTop={false}>
{i}
</HexShape>
</div>
{/each}
</HexGrid>
</div>
<div class="basis-0 flex-1">
<p class="text-gray-500 text-xs mb-3">
Honeycomb Variant 2 Offset Grid
</p>
<HexGrid variant="flat">
{#each { length: 30 } as _, i}
<div class="hex-hive flat">
<HexShape clipContent={true}>
{i}
</HexShape>
</div>
{/each}
</HexGrid>
<div class="basis-0 flex-1">
<p class="text-gray-500 text-xs mb-3">
Honeycomb Variant 2 Offset Grid
</p>
<HexGrid variant="flat">
{#each { length: 30 } as _, i}
<div class="hex-hive bg-hex-flat">
<HexShape clipContent={true}>
{i}
</HexShape>
</div>
{/each}
</HexGrid>
</div>
</div>
</div>
</section>
@ -463,11 +278,7 @@
<div class="text">MAG</div>
</div>
<div class="decal">
<StripeBar
className="w-full h-full"
size={"100%"}
orientation="vertical"
></StripeBar>
<div class="w-full h-full stripe-bar vertical"></div>
</div>
</div>
@ -479,12 +290,7 @@
<div class="text">MAG</div>
</div>
<div class="decal">
<StripeBar
className="w-full h-full"
size={"100%"}
orientation="vertical"
color="red"
></StripeBar>
<div class="w-full h-full stripe-bar red vertical"></div>
</div>
</div>
</div>
@ -497,12 +303,9 @@
<div class="text">MAG</div>
</div>
<div class="decal">
<StripeBar
className="w-full h-full"
size={"100%"}
orientation="vertical"
loop={true}
></StripeBar>
<div
class="w-full h-full stripe-bar vertical loop-stripe-vertical-reverse anim-duration-10"
></div>
</div>
</div>
@ -514,13 +317,9 @@
<div class="text">MAG</div>
</div>
<div class="decal">
<StripeBar
className="w-full h-full"
size={"100%"}
orientation="vertical"
color="red"
loop={true}
></StripeBar>
<div
class="w-full h-full stripe-bar red vertical loop-stripe-vertical anim-duration-10"
></div>
</div>
</div>
</div>
@ -586,7 +385,7 @@
</div>
<div>
<p class="text-gray-500 text-xs mb-1">ews-text danger</p>
<p class="ews-text danger text-lg">EARLY WARNING</p>
<p class="ews-text danger text-lg">PERINGATAN DINI</p>
</div>
<div>
<p class="text-gray-500 text-xs mb-1">ews-text</p>
@ -682,7 +481,7 @@
<div>
<p class="text-gray-500 text-xs mb-1">ews-select danger</p>
<select class="ews-select danger">
<option>LEVEL WARNING</option>
<option>LEVEL PERINGATAN</option>
<option>SIAGA</option>
<option>WASPADA</option>
<option>AWAS</option>
@ -855,103 +654,6 @@
</div>
</div>
</section>
<!-- THREADED COMMENTS / NESTED LIST -->
<section class="col-span-1 md:col-span-2 lg:col-span-3">
<h2 class="text-xl font-semibold mb-4 border-b border-gray-700 pb-2">
Threaded Comments / Nested List
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="p-3">
<p class="text-gray-500 text-xs mb-3">Variation 1 — Spine</p>
<ThreadedComments
variant="spine"
items={threadedSpineItems}
animated={false}
/>
</div>
<div class="p-3">
<p class="text-gray-500 text-xs mb-3">Variation 2 — Threaded</p>
<ThreadedComments
variant="threaded"
items={threadedSpineItems}
animated={false}
/>
</div>
<div class="p-3">
<p class="text-gray-500 text-xs mb-3">
Variation 3 — Spine (Expandable)
</p>
<ThreadedComments
variant="spine"
items={threadedNestedItems}
tone="danger"
expandable={true}
animated={false}
/>
</div>
<div class="p-3">
<p class="text-gray-500 text-xs mb-3">
Variation 4 — Spine (Animated)
</p>
<ThreadedComments
variant="spine"
items={threadedNestedItems}
expandable={true}
animated={true}
/>
</div>
<div class="p-3">
<p class="text-gray-500 text-xs mb-3">
Variation 5 — Threaded (Expandable)
</p>
<ThreadedComments
variant="threaded"
items={threadedNestedItems}
tone="danger"
expandable={true}
animated={false}
/>
</div>
<div class="p-3">
<p class="text-gray-500 text-xs mb-3">
Variation 6 — Threaded (Animated)
</p>
<ThreadedComments
variant="threaded"
items={threadedNestedItems}
expandable={true}
animated={true}
/>
</div>
<div class="p-3 col-span-1 lg:col-span-2">
<p class="text-gray-500 text-xs mb-3">
Variation 7 — Threaded (With Toggle Callback)
</p>
<div class="mb-2 text-xs text-gray-400">
Last toggled: {lastToggledId ?? "none"}{lastToggledState === null
? ""
: lastToggledState
? "collapsed"
: "expanded"}
</div>
<ThreadedComments
variant="threaded"
items={threadedNestedItems}
onToggle={handleToggle}
/>
</div>
</div>
</section>
<!-- MENTAL TOXICITY LEVEL -->
<section class="col-span-1 md:col-span-2 lg:col-span-3">
<h2 class="text-xl font-semibold mb-4 border-b border-gray-700 pb-2">
Mental Toxicity Level
</h2>
<MentalToxicityLevel />
</section>
</div>
</div>
@ -978,7 +680,7 @@
{#if showTsunamiAlert}
<!-- Tsunami Alert takes up the full screen and has animations -->
<div class="fixed inset-0 z-50 pointer-events-auto">
<TsunamiAlert infoTsunami={dummyTsunami.infoTsunami} />
<TsunamiAlert alertTsunami={dummyTsunami} />
<button
class="absolute top-4 right-4 z-[60] bg-black text-white px-4 py-2"
onclick={() => (showTsunamiAlert = false)}

View file

@ -1,388 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { xmlToJson, type JsonNode } from "$lib/xmlUtils";
import StripeBar from "$lib/components/StripeBar.svelte";
import MentalToxicityLevel from "$lib/components/MentalToxicityLevel.svelte";
import Card from "$lib/components/Card.svelte";
import { PUBLIC_MAPBOX_ACCESS_TOKEN } from "$env/static/public";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import * as turf from "@turf/turf";
import { mapStore } from "$lib/stores/mapStore.svelte";
let mapContainer: HTMLElement;
let map: mapboxgl.Map;
let markers: mapboxgl.Marker[] = [];
const zoom = 4; // Tetap di zoom level tertentu agar fokus ke area
let isLocked = $state(true);
let networkStats = $state<any[]>([]);
function fetchStations() {
// Clear existing markers
markers.forEach((m) => m.remove());
markers = [];
networkStats = [];
const url = `https://geofon.gfz-potsdam.de/fdsnws/station/1/query?${mapStore.urlParams}&level=station`;
fetch(url)
.then((response) => {
if (!response.ok)
throw new Error("Gagal mengambil data jaringan");
return response.text();
})
.then((xmlString) => {
const el = document.getElementById("loading-screen");
if (el) el.style.display = "none";
const data = xmlToJson(xmlString);
const fdsn = data.FDSNStationXML as JsonNode;
const networksList = fdsn.Network as JsonNode[];
// Handle both single network and multiple networks
const networks = Array.isArray(networksList)
? networksList
: networksList
? [networksList]
: [];
networks.forEach((networkNode) => {
let _totalStations = 0;
let _activeStations = 0;
let _inactiveStations = 0;
const stationsList = networkNode.Station as JsonNode[];
const stations = Array.isArray(stationsList)
? stationsList
: stationsList
? [stationsList]
: [];
stations.forEach((stationNode) => {
_totalStations++;
const endDate = (stationNode["@attributes"] as any)
?.endDate;
if (endDate) {
_inactiveStations++;
} else {
_activeStations++;
}
const markerEl = document.createElement("div");
markerEl.className = "custom-marker";
markerEl.innerHTML = `
<svg width="12" height="12" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 24L0 0H24L12 24Z" fill="#${endDate ? "ff0000" : "fa0"}" />
</svg>
`;
markerEl.style.cursor = "pointer";
const customPopup = new mapboxgl.Popup({
offset: 28,
closeButton: true,
closeOnClick: true,
}).setHTML(`
<div class="bordered" style="background-color: black; padding: 5px;">
<h3 style="margin: 0 0 5px 0; font-size: 16px;">${(stationNode as any)["@attributes"]["code"]}</h3>
<p style="margin: 0; font-size: 14px;">${(stationNode as any).Site.Name}</p>
</div>
`);
const marker = new mapboxgl.Marker({
element: markerEl,
anchor: "bottom",
})
.setLngLat([
parseFloat((stationNode as any).Longitude),
parseFloat((stationNode as any).Latitude),
])
.setPopup(customPopup)
.addTo(map);
markers.push(marker);
});
networkStats.push({
id:
(networkNode["@attributes"] as any)?.code ||
"UNKNOWN",
name:
(networkNode["@attributes"] as any)?.code ||
"UNKNOWN",
total_channel: _totalStations,
active_channel: _activeStations,
inactive_channel: _inactiveStations,
});
});
})
.catch((error) => {
console.error("Terjadi kesalahan:", error);
const el = document.getElementById("loading-screen");
if (el) el.style.display = "none";
});
}
function toggleLock() {
isLocked = !isLocked;
if (map) {
if (isLocked) {
map.dragPan.disable();
} else {
map.dragPan.enable();
}
}
}
function saveArea() {
if (!map) return;
const bounds = map.getBounds();
if (!bounds) return;
mapStore.bbox = [
bounds.getWest(),
bounds.getSouth(),
bounds.getEast(),
bounds.getNorth(),
];
isLocked = true;
map.dragPan.disable();
fetchStations();
}
onMount(() => {
if (!PUBLIC_MAPBOX_ACCESS_TOKEN) {
console.error("Mapbox token is missing!");
return;
}
mapboxgl.accessToken = PUBLIC_MAPBOX_ACCESS_TOKEN;
map = new mapboxgl.Map({
container: mapContainer,
style: {
version: 8,
sources: {},
layers: [
{
id: "background",
type: "background",
paint: {
"background-color": "#000000",
},
},
],
},
projection: "mercator",
// Disable all interactions by default
interactive: true,
dragPan: false,
scrollZoom: false,
boxZoom: false,
dragRotate: false,
doubleClickZoom: false,
touchZoomRotate: false,
});
// Fit map to bbox initially
map.fitBounds(mapStore.bbox as any, {
padding: 20,
animate: false,
});
map.on("load", () => {
map.addSource("world-boundaries", {
type: "geojson",
data: "/geojson/world.geo.json",
});
map.addLayer({
id: "world-boundaries-line",
type: "line",
source: "world-boundaries",
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": "#fa0",
"line-width": 1,
},
});
fetchStations();
});
});
onDestroy(() => {
if (map) map.remove();
});
</script>
<svelte:head>
<title>Status Map | Mapbox BBox</title>
</svelte:head>
<div
class="min-h-screen py-1 md:py-4 flex flex-col items-center overflow-x-hidden overflow-y-auto font-mono"
>
<div
class="flex no-snapshot fixed right-2 translate-y-0 top-2 left-0 right-0 m-auto flex-row justify-center items-center z-5 gap-2 pointer-events-none"
style="width:fit-content"
>
<a
class="ews-btn ews-btn-primary scale-75 md:scale-100 pointer-events-auto"
href="/">HOME</a
>
<a
class="ews-btn ews-btn-primary scale-75 md:scale-100 pointer-events-auto"
href="/status-ui">STATION STATUS</a
>
</div>
<div
class="mb-2 text-center p-2 z-10 w-full bordered flex justify-center items-center relative show-pop-up mt-6"
>
<div class="overflow-hidden">
<StripeBar loop={true} duration={20} color="red"></StripeBar>
<div
class="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center"
>
<h1
class="text-xl p-1 font-bold ews-title text-3xl danger uppercase bg-black"
>
Station Status Map
</h1>
</div>
</div>
</div>
<div
class="flex flex-col lg:flex-row gap-4 w-full mb-2 items-stretch h-full"
>
<Card className="w-full lg:w-1/3">
{#snippet title()}
<h1>NETWORK CHANNEL STATUS</h1>
{/snippet}
<div class="overflow-y-auto h-[80vh]">
<MentalToxicityLevel networks={networkStats} />
</div>
</Card>
<div class="w-full lg:w-2/3 bordered relative overflow-hidden">
<div
class="w-full h-[80vh] bg-black rounded"
bind:this={mapContainer}
></div>
<!-- MAP OVERLAY CONTROLS -->
<div
class="absolute top-4 left-4 z-5 flex flex-col gap-3 w-64 pointer-events-none"
>
<!-- TOP STRIPE DECORATION -->
<div class="pointer-events-auto">
<button
class="w-full cursor-pointer p-0 b-0 overflow-hidden flex items-center justify-center bordered p-1 transition-all hover:scale-[1.02] active:scale-[0.98]"
onclick={toggleLock}
>
<StripeBar
loop={!isLocked}
reverse={true}
duration={20}
color={isLocked ? "blue" : "red"}
></StripeBar>
<span
class="absolute bg-black ews-label px-3 py-1 font-bold text-[10px] tracking-widest"
>
{isLocked
? "UNLOCK INTERACTION"
: "LOCK INTERACTION"}
</span>
</button>
</div>
{#if !isLocked}
<div
class="pointer-events-auto animate-in fade-in slide-in-from-left duration-300"
>
<button
class="w-full cursor-pointer p-0 b-0 overflow-hidden flex items-center justify-center bordered-red p-1 transition-all hover:scale-[1.02] active:scale-[0.98]"
onclick={saveArea}
>
<StripeBar
color="red"
loop={true}
reverse={false}
duration={15}
></StripeBar>
<span
class="absolute bg-black ews-label danger px-3 py-1 font-bold text-[10px] tracking-widest"
>
CONFIRM & SAVE AREA
</span>
</button>
<div
class="mt-2 p-2 bg-black/80 bordered-red backdrop-blur-sm"
>
<p
class="text-[9px] text-red-500 font-bold leading-tight uppercase tracking-tighter"
>
⚠ EDIT MODE ACTIVE
</p>
<p
class="text-[9px] text-gray-400 mt-1 leading-tight"
>
PANNING ENABLED. ZOOMING LOCKED. ADJUST VIEWPORT
AND CONFIRM.
</p>
</div>
</div>
{/if}
</div>
<!-- STATUS INDICATOR -->
<div class="absolute bottom-4 right-4 z-5 pointer-events-none">
<div
class="bg-black/80 bordered p-2 backdrop-blur-sm flex items-center gap-3"
>
<div class="flex flex-col">
<span
class="text-[8px] text-gray-500 font-bold uppercase"
>Map Status</span
>
<span
class="text-[10px] {isLocked
? 'text-blue-400'
: 'text-red-500 animate-pulse'} font-bold uppercase tracking-widest"
>
{isLocked ? "SECURED" : "USER ADJUSTMENT"}
</span>
</div>
<div
class="w-8 h-8 flex items-center justify-center bordered"
>
<div
class="w-2 h-2 rounded-full {isLocked
? 'bg-blue-500'
: 'bg-red-500 animate-ping'}"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- LOADING SCREEN -->
<div
class="fixed m-auto top-0 bottom-0 left-0 right-0 flex flex-col justify-center items-center overlay-bg text-center z-10 -red"
id="loading-screen"
>
<span class="loader"></span>
<p class="my-2 red-color p-2">
THIS IS A CONCEPT DESIGN - DATA STATION DARI GEOFON
</p>
</div>
<style>
/* Ensure the grid rows naturally align left and right items on the same horizontal plane */
</style>

View file

@ -1,9 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { xmlToJson, type JsonNode } from "$lib/xmlUtils";
import StripeBar from "$lib/components/StripeBar.svelte";
import RibCageLayout from "$lib/components/RibCageLayout.svelte";
import { mapStore } from "$lib/stores/mapStore.svelte";
// Dummy data for the status list
let statuses = $state<
@ -17,10 +14,47 @@
}[]
>([]);
// Jumlah branch responsive berdasarkan screen size
let branchCount = $state(5);
let windowWidth = $state(0);
// Function untuk menentukan branch count berdasarkan screen width
function getBranchCount(width: number): number {
if (width < 768) return 1;
if (width < 1024) return 2; // Mobile
if (width < 1300) return 4; // Medium
return 5; // Large
}
// Function untuk update branch count saat resize
function handleResize() {
windowWidth = typeof window !== "undefined" ? window.innerWidth : 0;
branchCount = getBranchCount(windowWidth);
}
// Membagi data ke dalam beberapa branch
let chunkedStatuses = $derived.by(() => {
if (statuses.length === 0) return [];
// Batasi minimal 1 branch
const count = Math.max(1, branchCount);
const result = [];
const itemsPerBranch = Math.ceil(statuses.length / count);
for (let i = 0; i < statuses.length; i += itemsPerBranch) {
result.push(statuses.slice(i, i + itemsPerBranch));
}
return result;
});
onMount(() => {
// https://geofon.bmkg.go.id/fdsnws/station/1/
// Inisialisasi branch count berdasarkan screen width saat mount
handleResize();
// https://geof.bmkg.go.id/fdsnws/station/1/
// URL GEOFON (tanpa format=text agar mengembalikan XML)
const url = `https://geofon.gfz-potsdam.de/fdsnws/station/1/query?${mapStore.urlParams}&level=station`;
const url =
"https://geofon.gfz-potsdam.de/fdsnws/station/1/query?minlatitude=-11&maxlatitude=6&minlongitude=95&maxlongitude=141&level=station";
fetch(url)
.then((response) => {
@ -51,7 +85,6 @@
: [stationsList];
stations.forEach((stationNode) => {
// console.log(stationNode);
const staCode =
(stationNode["@attributes"] as any)?.code || "UNKNOWN";
const startDate = (stationNode["@attributes"] as any)?.startDate;
@ -75,9 +108,16 @@
if (el) el.style.display = "none";
}, 1000);
});
// Tambahkan event listener untuk resize
window.addEventListener("resize", handleResize);
});
onDestroy(() => {
// Hapus event listener saat component destroyed
if (typeof window !== "undefined") {
window.removeEventListener("resize", handleResize);
}
console.log("Component destroyed");
});
</script>
@ -89,60 +129,133 @@
<div
class="min-h-screen py-1 md:py-4 flex flex-col items-center overflow-x-hidden overflow-y-auto font-mono"
>
<div
class="flex no-snapshot fixed right-2 translate-y-0 top-2 left-0 right-0 m-auto flex-row justify-center items-center z-5 gap-2 pointer-events-none"
style="width:fit-content"
>
<a
class="ews-btn ews-btn-primary scale-75 md:scale-100 pointer-events-auto"
href="/">HOME</a
{#if chunkedStatuses.length > 0}
<div
class="mb-2 text-center p-2 z-10 w-full bordered flex justify-center items-center relative show-pop-up"
>
<a
class="ews-btn ews-btn-primary scale-75 md:scale-100 pointer-events-auto"
href="/status-map">STATION MAP</a
>
</div>
<div
class="mb-2 text-center p-2 z-10 w-full bordered flex justify-center items-center relative show-pop-up mt-6"
>
<div class="overflow-hidden">
<StripeBar loop={true} duration={20} color="red"></StripeBar>
<div
class="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center"
>
<h1
class="text-xl p-1 font-bold ews-title text-3xl danger uppercase bg-black"
<div class="overflow-hidden">
<div class="stripe-wrapper h-12">
<div
class="stripe-bar-red loop-stripe anim-duration-10"
style="height: 100%;"
></div>
<div
class="stripe-bar-red loop-stripe anim-duration-10"
style="height: 100%;"
></div>
</div>
<div
class="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center"
>
Station Status
</h1>
<h1
class="text-xl p-1 font-bold ews-title text-3xl danger uppercase bg-black"
>
Station Status
</h1>
</div>
</div>
</div>
</div>
<div
class="mb-2 w-full bordered text-center p-1 hidden md:block show-pop-up"
>
<p class="text-black font-bold text-sm bg-primary p-4 uppercase">
GEOFON STATION - FDSN (International Federation of Digital Seismograph
Networks)
</p>
</div>
{/if}
<div class="w-full h-1 bg-primary"></div>
<RibCageLayout
items={statuses}
getHref={(item: any) =>
`/realtime?networkCode=${item.networkCode}&stationCode=${item.stationCode}`}
<div
class="inline-flex h-auto justify-center gap-4 w-full px-4 overflow-none relative"
>
{#snippet nodeContent(
item: any,
{ side, delay }: { side: string; delay: number },
)}
<div
class="slide-fade-in ews-rib-node {side === 'right'
? 'flip'
: ''} {item.type === 'danger' ? 'danger' : ''}"
style="animation-delay: {delay}ms;"
></div>
{/snippet}
{#each chunkedStatuses as branchStatuses, branchIndex}
<div class="relative py-4 lg:py-10 flex flex-col gap-4">
<!-- Central Spine -->
<div
class="absolute h-auto left-1/2 top-0 bottom-0 w-1 bg-primary transform -translate-x-1/2 z-0 line-central"
style="animation-delay: {branchIndex * 200}ms;"
></div>
{#snippet connectorContent(item: any)}
{item.title}
{/snippet}
</RibCageLayout>
<!-- Iterate in pairs essentially by grouping them two by two -->
<div class="grid grid-cols-2 relative z-10">
{#each branchStatuses as item, index}
{#if index % 2 === 0}
<!-- Left Item (Even index) -->
<a
href="/realtime?networkCode={item.networkCode}&stationCode={item.stationCode}"
class="flex flex-grow justify-end items-center relative pr-0 col-start-1 node"
>
<div class="relative flex parent-node">
<!-- node -->
<div
class="status-node slide-fade-in {item.type === 'danger'
? 'danger '
: ''} w-24 h-6 flex flex-grow flex-col items-center justify-center relative mt-6 -mr-2 z-5 text-black text-xs font-bold"
style="animation-delay: {(branchIndex + 1) *
(index + 1) *
10}ms;"
>
<!-- {item.type === "danger" ? item.status : ""} -->
</div>
</div>
<!-- Connecting Line to center -->
<div class="w-24 flex justify-end relative line">
<div
class="h-[2px] w-24 bg-primary z-0 line-node"
style="animation-delay: {(branchIndex + 1) *
(index + 1) *
10}ms;"
></div>
<span
class="font-bold text-xs uppercase absolute left-2 z-10 text-left top-1 fade-in animation-delay-5 text-primary"
>
{item.title}
</span>
</div>
</a>
{:else}
<!-- Right Item (Odd index) -->
<a
href="/realtime?networkCode={item.networkCode}&stationCode={item.stationCode}"
class="flex justify-start items-center relative pl-0 col-start-2 w-auto node-flip"
>
<!-- Connecting Line from center -->
<div class="w-24 flex justify-start relative">
<div
class="h-[2px] w-24 bg-primary z-0 line-node"
style="animation-delay: {(branchIndex + 1) *
(index + 1) *
10}ms;"
></div>
<span
class="font-bold text-xs uppercase absolute z-10 right-2 text-right top-1 fade-in animation-delay-5 text-primary"
>
{item.title}
</span>
</div>
<div class="relative flex parent-node flip">
<!-- node -->
<div
class="status-node-flip slide-fade-in {item.type ===
'danger'
? 'danger '
: ''} w-24 h-6 flex flex-col items-center justify-center relative mt-6 -ml-2 z-5 text-black text-xs font-bold"
style="animation-delay: {(branchIndex + 1) *
(index + 1) *
10}ms;"
>
<!-- {item.type === "danger" ? item.status : ""} -->
</div>
</div>
</a>
{/if}
{/each}
</div>
</div>
{/each}
</div>
</div>
<!-- LOADING SCREEN -->
@ -152,7 +265,7 @@
>
<span class="loader"></span>
<p class="my-2 red-color p-2">
THIS IS A CONCEPT DESIGN - DATA STATION DARI GEOFON
INI MERUPAKAN DESAIN KONSEP - DATA STATION DARI GEOFON
</p>
</div>

File diff suppressed because one or more lines are too long