mirror of
https://github.com/bagusindrayana/ews-concept-new.git
synced 2026-06-08 09:45:34 +00:00
Compare commits
No commits in common. "main" and "old" have entirely different histories.
53 changed files with 1559 additions and 7341 deletions
|
|
@ -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
26
LICENSE
|
|
@ -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.
|
||||
|
|
@ -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!
|
||||
[](https://sociabuzz.com/bagusindrayana/tribe)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
13
package.json
13
package.json
|
|
@ -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
83
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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">{</span>
|
||||
<span class="ews-mtl-zone-text">CAUTION</span>
|
||||
<span class="ews-mtl-zone-bracket">}</span>
|
||||
</div>
|
||||
<div class="ews-mtl-zone-danger">
|
||||
<span class="ews-mtl-zone-bracket">{</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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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%;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue