Compare commits

...

55 commits

Author SHA1 Message Date
bagusindrayana
4145a34d2a eartquake running text 2026-04-25 18:02:34 +08:00
bagusindrayana
815ab6c43a add range slider and filter data 2026-04-18 00:57:29 +08:00
bagusindrayana
f91e1b0b39 connect esp32 in realtime page to control waveform chart 2026-04-16 22:08:31 +08:00
bagusindrayana
06a1354270 Merge branch 'web-serial' 2026-04-11 18:25:13 +08:00
bagusindrayana
df415da084 max width showcase 2026-04-11 17:49:00 +08:00
bagusindrayana
499fa24859 infinite scroll component 2026-04-11 13:19:37 +08:00
bagusindrayana
f02d8a7d42 fix usage stripe bar in showcase 2026-04-09 14:13:34 +08:00
bagusindrayana
ef3b5660bd navigation button 2026-04-09 14:01:50 +08:00
bagusindrayana
068096ab1c disable scanline animation 2026-04-09 13:54:41 +08:00
bagusindrayana
417108d65e generate html popup from one function 2026-04-09 13:54:33 +08:00
bagusindrayana
c43fbac2b1 fix broken UI because refactored stripe-bar 2026-04-04 00:14:42 +08:00
bagusindrayana
b457528084 fix refactored stripe bar 2026-04-04 00:04:39 +08:00
bagusindrayana
416bb9ef36 color scheme 2026-04-04 00:04:26 +08:00
bagusindrayana
c74d3b41d2 change color for red variant 2026-04-04 00:04:15 +08:00
bagusindrayana
255c67016c change color for red 2026-04-04 00:03:49 +08:00
bagusindrayana
9694f80060 load miniseed 2026-04-04 00:03:38 +08:00
bagusindrayana
a877e731f5 add setting to load historical data 2026-04-03 18:06:13 +08:00
bagusindrayana
6f3c5e8bae Merge branch 'main' of https://github.com/bagusindrayana/ews-concept-new 2026-04-03 01:05:00 +08:00
bagusindrayana
12412fead1 maxBranches for ribcagelayout 2026-04-03 01:04:48 +08:00
Bagus Indrayana
c1fd39fe46
Update support section with Sociabuzz badge 2026-03-28 18:20:19 +08:00
bagusindrayana
1ac6048366 min margin 2026-03-27 22:53:25 +08:00
bagusindrayana
8a1874f2ed sociabuzz link 2026-03-27 22:21:46 +08:00
bagusindrayana
77b991d0a0 fix export type in module 2026-03-27 22:19:39 +08:00
bagusindrayana
881fa9e133 using stipebar component 2026-03-27 22:18:07 +08:00
bagusindrayana
e418d2dd1e refactor status node class 2026-03-27 22:06:57 +08:00
bagusindrayana
174d1e1ab7 refactor css rib cage layout component 2026-03-27 21:52:24 +08:00
bagusindrayana
1f2f630194 clean comment, add setting to inital collapse 2026-03-27 00:27:13 +08:00
bagusindrayana
e05591f64a change line to path 2026-03-26 23:34:36 +08:00
bagusindrayana
44033f4558 lisensi MIT 2026-03-26 19:01:34 +08:00
bagusindrayana
72f8bc86bd lisensi MIT 2026-03-26 18:55:22 +08:00
bagusindrayana
79a343dd6c fix formula to render line, add some transition animation 2026-03-25 21:37:27 +08:00
Bagus Indrayana
f39552adb8 fix formula line for threaded comments 2026-03-25 16:12:43 +08:00
Bagus Indrayana
1fd78fb300 fix hex grip gap in variant 1 2026-03-25 09:39:13 +08:00
Bagus Indrayana
178fddd5b3 fix type for ThreadComments component 2026-03-25 09:26:49 +08:00
bagusindrayana
08ad1e9f76 thread/nest list component 2026-03-25 07:37:52 +08:00
bagusindrayana
262777b980 short text 2026-03-25 07:37:40 +08:00
bagusindrayana
db544dc769 translate some word to english 2026-03-24 23:37:53 +08:00
bagusindrayana
848af1895c teset web serial communiction 2026-03-24 18:52:34 +08:00
bagusindrayana
42c9e69f63 default height for layout status-map 2026-03-23 21:04:03 +08:00
bagusindrayana
b555111849 refactor hex grid component 2026-03-23 21:00:34 +08:00
bagusindrayana
950a207c32 refactor stripe bar and hex shape component to have own css class 2026-03-23 05:21:20 +08:00
bagusindrayana
935944fbfe loop stripe bar 2026-03-22 15:43:22 +08:00
bagusindrayana
79c6c15990 adjust padding and margin in mobile 2026-03-22 14:56:52 +08:00
bagusindrayana
b9b23fa83f hide some element in snapshot, make responsive button and change icon 2026-03-22 14:50:07 +08:00
bagusindrayana
2f402a8033 add iconify 2026-03-22 14:49:36 +08:00
bagusindrayana
0de977bcb2 new responsive layout on mobile 2026-03-22 07:51:30 +08:00
bagusindrayana
4f25fc71b5 feature: snapshot, to take screenshot when earthquake detected 2026-03-22 05:23:54 +08:00
bagusindrayana
961611e86d red stripe bar for last earthquake felt card 2026-03-22 03:28:16 +08:00
bagusindrayana
3ce5ac1cc3 standalone card component 2026-03-22 03:22:45 +08:00
bagusindrayana
7eea4fd98b card css 2026-03-21 15:42:16 +08:00
bagusindrayana
d76c5ee118 update socket.io-client 2026-03-20 11:06:49 +08:00
Bagus Indrayana
367e05c1f7
Merge pull request #2 from bagusindrayana/dependabot/npm_and_yarn/fast-xml-parser-5.5.7
Bump fast-xml-parser from 5.5.6 to 5.5.7
2026-03-20 11:05:39 +08:00
dependabot[bot]
0d5cf43cf2
Bump fast-xml-parser from 5.5.6 to 5.5.7
Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) from 5.5.6 to 5.5.7.
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.6...v5.5.7)

---
updated-dependencies:
- dependency-name: fast-xml-parser
  dependency-version: 5.5.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 03:04:54 +00:00
Bagus Indrayana
b0db9e361d
Merge pull request #1 from bagusindrayana/dependabot/npm_and_yarn/undici-7.24.5
Bump undici from 7.18.2 to 7.24.5
2026-03-20 10:58:53 +08:00
dependabot[bot]
fb40fe74b2
Bump undici from 7.18.2 to 7.24.5
Bumps [undici](https://github.com/nodejs/undici) from 7.18.2 to 7.24.5.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.18.2...v7.24.5)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.24.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 02:57:30 +00:00
41 changed files with 4301 additions and 688 deletions

26
LICENSE Normal file
View file

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

View file

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

View file

@ -1,8 +1,9 @@
{
"name": "ews-concept-new",
"private": true,
"version": "0.0.1",
"version": "0.0.2",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@ -12,6 +13,7 @@
"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",
@ -21,23 +23,24 @@
"@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",
"vite": "^7.3.1",
"undici": ">=7.24.0",
"devalue": ">=5.6.4"
"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.6",
"fast-xml-parser": ">=5.5.7",
"html-to-image": "^1.11.13",
"luxon": "^3.4.4",
"mapbox-gl": "^3.2.0",
"mapbox-gl-animated-popup": "^0.4.0",
"seisplotjs": "^3.2.1",
"socket.io-client": "^4.7.5",
"socket.io-client": "^4.8.1",
"svelte-highlight": "^7.9.0",
"tailwindcss": "^4.2.1",
"ws": "^8.19.0"

38
pnpm-lock.yaml generated
View file

@ -18,8 +18,11 @@ importers:
specifier: ^6.5
version: 6.5.0
fast-xml-parser:
specifier: '>=5.5.6'
version: 5.5.6
specifier: '>=5.5.7'
version: 5.5.7
html-to-image:
specifier: ^1.11.13
version: 1.11.13
luxon:
specifier: ^3.4.4
version: 3.7.2
@ -33,7 +36,7 @@ importers:
specifier: ^3.2.1
version: 3.2.1
socket.io-client:
specifier: ^4.7.5
specifier: ^4.8.1
version: 4.8.3
svelte-highlight:
specifier: ^7.9.0
@ -45,6 +48,9 @@ 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)))
@ -302,6 +308,14 @@ 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'}
@ -1452,8 +1466,8 @@ packages:
fast-xml-builder@1.1.4:
resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==}
fast-xml-parser@5.5.6:
resolution: {integrity: sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==}
fast-xml-parser@5.5.7:
resolution: {integrity: sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==}
hasBin: true
fdir@6.5.0:
@ -1530,6 +1544,9 @@ 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'}
@ -2237,6 +2254,13 @@ 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':
@ -3752,7 +3776,7 @@ snapshots:
dependencies:
path-expression-matcher: 1.1.3
fast-xml-parser@5.5.6:
fast-xml-parser@5.5.7:
dependencies:
fast-xml-builder: 1.1.4
path-expression-matcher: 1.1.3
@ -3834,6 +3858,8 @@ snapshots:
highlight.js@11.11.1: {}
html-to-image@1.11.13: {}
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2

View file

@ -5,9 +5,10 @@
@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/stripe-bar.css";
@import "./lib/styles/hex-shape.css";
@import "./lib/styles/components/RibLayout.css"; */

View file

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

View file

@ -1,5 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import StripeBar from "./StripeBar.svelte";
import InfiniteScroll from "./InfiniteScroll.svelte";
interface GempaBumiAlertProps {
magnitudo?: number;
@ -52,7 +54,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">PERINGATAN</span>
<span class="text-xl">WARNING</span>
<span class="text-xs">Gempa Bumi Terdeteksi</span>
</div>
<div
@ -103,16 +105,53 @@
></div>
</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 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>
</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
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>
</div>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import "../styles/components/HexGrid.css";
import type { Snippet } from "svelte";
let {
@ -7,7 +8,7 @@
variant = "pointy",
hexWidth,
hexHeight,
gap = 4
gap = 4,
}: {
children: Snippet;
className?: string;
@ -38,10 +39,12 @@
if (!isFlat) {
// Pointy (Variant 1)
const rowOffsetTop = -14;
const rowOffsetTop = gap + -20;
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;
@ -78,21 +81,18 @@
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="hex-honeycomb {className}" use:honeycombLayout>
<div class="ews-hex-honeycomb {className}" use:honeycombLayout>
{@render children()}
</div>

View file

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

View file

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

View file

@ -23,21 +23,21 @@
networks = [
{
id: "network-1",
name: "Network 1",
name: "NET 1",
active_channel: 2,
inactive_channel: 14,
total_channel: 16,
},
{
id: "network-2",
name: "Network 2",
name: "NET 2",
active_channel: 22,
inactive_channel: 7,
total_channel: 29,
},
{
id: "network-3",
name: "Network 3",
name: "NET 3",
active_channel: 12,
inactive_channel: 5,
total_channel: 17,

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { Snippet } from "svelte";
import StripeBar from "./StripeBar.svelte";
let {
show = $bindable(false),
@ -23,15 +24,14 @@
{#if show}
<div class="settings-modal-overlay" onclick={close} role="presentation">
<div
class="settings-modal ews-card bordered-red {variant === 'large' ? '!w-11/12 !max-w-4xl' : ''}"
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">
<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>
<StripeBar></StripeBar>
<div
class="absolute top-0 bottom-0 left-0 right-0 flex justify-between items-center px-3"
>
@ -45,7 +45,11 @@
>
</div>
</div>
<div class="ews-card-content {variant === 'large' ? 'p-4' : 'p-1 lg:p-2 p-4'} {contentClass}">
<div
class="ews-card-content {variant === 'large'
? 'p-4'
: 'p-1 lg:p-2 p-4'} {contentClass}"
>
{@render children()}
</div>
</div>

View file

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

View file

@ -1,4 +1,5 @@
<script lang="ts">
import "../styles/components/RibLayout.css";
import { onMount, onDestroy } from "svelte";
import type { Snippet } from "svelte";
@ -7,13 +8,31 @@
nodeContent,
connectorContent,
getHref,
maxBranches,
}: {
items: any[];
maxBranches?: number;
nodeContent: Snippet<
[any, { side: "left" | "right"; branchIndex: number; index: number; delay: number }]
[
any,
{
side: "left" | "right";
branchIndex: number;
index: number;
delay: number;
},
]
>;
connectorContent?: Snippet<
[any, { side: "left" | "right"; branchIndex: number; index: number; delay: number }]
[
any,
{
side: "left" | "right";
branchIndex: number;
index: number;
delay: number;
},
]
>;
getHref?: (item: any) => string;
} = $props();
@ -22,10 +41,15 @@
let windowWidth = $state(0);
function getBranchCount(width: number): number {
if (width < 768) return 1;
if (width < 1024) return 2;
if (width < 1300) return 4;
return 5;
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() {
@ -57,62 +81,76 @@
});
</script>
<div
class="inline-flex h-auto justify-center gap-4 w-full px-4 overflow-none relative"
>
<div class="ews-rib-layout">
{#each chunkedItems as branchItems, branchIndex}
<div class="relative py-4 lg:py-10 flex flex-col gap-4">
<div class="ews-rib-layout__branch">
<!-- 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"
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="grid grid-cols-2 relative z-10">
<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="{side === 'left'
? 'flex flex-grow justify-end items-center relative pr-0 col-start-1 node'
: 'flex justify-start items-center relative pl-0 col-start-2 w-auto node-flip'}"
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="relative flex parent-node">
<div class="ews-rib-layout__node-content parent-node">
{@render nodeContent(item, { side, branchIndex, index, delay })}
</div>
<div class="w-24 flex justify-end relative line">
<div
class="ews-rib-layout__connector-wrapper ews-rib-layout__connector-wrapper--left line"
>
<div
class="h-[2px] w-24 bg-primary z-0 line-node"
class="ews-rib-layout__connector-line line-node"
style="animation-delay: {delay}ms;"
></div>
{#if connectorContent}
<div
class="font-bold text-xs uppercase absolute left-2 z-10 text-left top-1 fade-in animation-delay-5 text-primary"
class="ews-rib-layout__connector-text ews-rib-layout__connector-text--left fade-in animation-delay-5"
>
{@render connectorContent(item, { side, branchIndex, index, delay })}
{@render connectorContent(item, {
side,
branchIndex,
index,
delay,
})}
</div>
{/if}
</div>
{:else}
<div class="w-24 flex justify-start relative">
<div
class="ews-rib-layout__connector-wrapper ews-rib-layout__connector-wrapper--right"
>
<div
class="h-[2px] w-24 bg-primary z-0 line-node"
class="ews-rib-layout__connector-line line-node"
style="animation-delay: {delay}ms;"
></div>
{#if connectorContent}
<div
class="font-bold text-xs uppercase absolute z-10 right-2 text-right top-1 fade-in animation-delay-5 text-primary"
class="ews-rib-layout__connector-text ews-rib-layout__connector-text--right fade-in animation-delay-5"
>
{@render connectorContent(item, { side, branchIndex, index, delay })}
{@render connectorContent(item, {
side,
branchIndex,
index,
delay,
})}
</div>
{/if}
</div>
<div class="relative flex parent-node flip">
<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}

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
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;
@ -152,30 +153,15 @@ export class TitikGempa {
renderPopup() {
const placeholder = document.createElement('div');
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">EARTHQUAKE</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, "><");
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 ?? "-",
});
if (this.gemaMarker) {
const popup = new AnimatedPopup({
@ -300,7 +286,7 @@ export class TitikGempa {
if (this.setting?.map != null) {
this.setting.map.flyTo({
center: [this.infoGempa.lng, this.infoGempa.lat],
zoom: 6
zoom: 8
});
}
}

View file

@ -124,7 +124,7 @@ export class TitikTsunami {
if (this.setting?.map != null) {
this.setting.map.flyTo({
center: [this.infoTsunami.lng, this.infoTsunami.lat],
zoom: 6
zoom: 8
});
}
}

View file

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

View file

@ -1,12 +1,15 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { browser } from "$app/environment";
<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[] = [];
@ -27,6 +30,10 @@
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
@ -34,12 +41,24 @@
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;
@ -48,6 +67,11 @@
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;
@ -71,8 +95,66 @@
function handleWheel(e: WheelEvent) {
e.preventDefault();
// Vertical Scroll (Y-Axis Zoom)
if (e.deltaY !== 0 && !e.shiftKey) {
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);
@ -84,7 +166,7 @@
// 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 (timeOffsetMs === 0) {
if (!isHistoricalMode && timeOffsetMs === 0) {
frozenLatestTime = Date.now();
}
@ -93,10 +175,16 @@
const msPerPixel = timeWindowMs / width;
timeOffsetMs += delta * msPerPixel * 2;
// Prevent panning into the future
if (timeOffsetMs <= 0) {
timeOffsetMs = 0;
frozenLatestTime = 0;
// 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;
}
}
}
@ -117,16 +205,25 @@
// 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 (timeOffsetMs === 0 && dx > 0) {
if (!isHistoricalMode && timeOffsetMs === 0 && dx > 0) {
frozenLatestTime = Date.now();
}
timeOffsetMs += dx * msPerPixel;
// Drag left = positive dx = go earlier in time (increase offset)
timeOffsetMs -= dx * msPerPixel;
if (timeOffsetMs <= 0) {
timeOffsetMs = 0;
frozenLatestTime = 0;
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();
@ -161,16 +258,24 @@
lastMouseX = e.touches[0].clientX;
const msPerPixel = timeWindowMs / width;
const isHistoricalMode = historicalStartMs > 0 && historicalEndMs > 0;
if (timeOffsetMs === 0 && dx > 0) {
if (!isHistoricalMode && timeOffsetMs === 0 && dx > 0) {
frozenLatestTime = Date.now();
}
timeOffsetMs += dx * msPerPixel;
// Drag right (dx > 0) = scroll into the past (increase offset)
timeOffsetMs -= dx * msPerPixel;
if (timeOffsetMs <= 0) {
timeOffsetMs = 0;
frozenLatestTime = 0;
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();
@ -231,15 +336,25 @@
return;
}
// Use real-time clock for smooth sliding instead of snapping to data points
const latestTime =
timeOffsetMs > 0 && frozenLatestTime > 0
? frozenLatestTime
: Date.now();
// Determine right/left edge: historical mode uses fixed range, live mode uses Date.now()
const isHistoricalMode = historicalStartMs > 0 && historicalEndMs > 0;
// The right edge of the screen represents (latestTime - timeOffsetMs)
const rightEdgeTime = latestTime - timeOffsetMs;
const leftEdgeTime = rightEdgeTime - timeWindowMs;
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;
@ -312,7 +427,7 @@
}
ctx.stroke();
// Bottom Ruler (Time/Ticks)
// Bottom Ruler (Time/Ticks) — Adaptive intervals
ctx.strokeStyle = axesColor;
ctx.lineWidth = 2;
ctx.beginPath();
@ -333,40 +448,71 @@
const pixelsPerMs = drawWidth / timeWindowMs;
// Find the first integer second within our visible window
const startSecond = Math.floor(leftEdgeTime / 1000);
const endSecond = Math.ceil(rightEdgeTime / 1000);
// ── 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
];
for (let sec = startSecond; sec <= endSecond; sec++) {
const timeAtTick = sec * 1000;
const x = leftPadding + (timeAtTick - leftEdgeTime) * pixelsPerMs;
let majorMs = 3_600_000; // default ≥ 5 hr
let minorMs = 900_000;
let labelFmt: LabelFmt = 'hm';
if (x >= leftPadding && x <= width) {
let tickLen = 6;
// Every 10 seconds is a major tick
if (sec % 10 === 0) {
tickLen = 20;
let timeLabel = formatTimeStr(timeAtTick);
ctx.fillText(timeLabel, x, drawHeight + 22);
} else if (sec % 5 === 0) {
tickLen = 12;
} else if (sec % 1 === 0) {
tickLen = 6;
}
ctx.moveTo(x, drawHeight);
ctx.lineTo(x, drawHeight + tickLen);
// Minor ticks every 0.25 sec
for (let minor = 1; minor < 4; minor++) {
const minorX = x + minor * 0.25 * 1000 * pixelsPerMs;
if (minorX > leftPadding && minorX <= width) {
ctx.moveTo(minorX, drawHeight);
ctx.lineTo(minorX, drawHeight + 4);
}
}
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
@ -528,7 +674,11 @@
window.addEventListener("touchcancel", handleTouchEnd);
function updateLoop() {
if (!isDragging) {
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);
@ -559,12 +709,8 @@
// although updateLoop is already calling draw() repeatedly.
</script>
<div bind:this={container} class="w-full h-full relative cursor-crosshair">
<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="absolute inset-0 block w-full h-full touch-none"
style="z-index: 0;"
></canvas>
<canvas bind:this={canvas} class="ews-waveform-chart-canvas"></canvas>
</div>

View file

@ -15,6 +15,111 @@ export class WaveformService {
}
}
/**
* 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.");

View file

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

View file

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

View file

@ -388,7 +388,7 @@
--border-color: rgba(var(--glow-rgb));
border-radius: var(--gutter-size);
border-style: solid;
border-width: 3px;
border-width: var(--border-width);
border-color: var(--border-color);
/* box-shadow:
inset 0 0 0 1px var(--border-glow-color),
@ -399,7 +399,7 @@
/* color: var(--text-color); */
/* --border-glow-color: rgba(var(--glow-rgb)); */
border-color: unset;
border-bottom: 3px solid var(--border-color);
border-bottom: var(--border-width) solid var(--border-color);
}
@ -408,7 +408,7 @@
/* color: var(--text-color); */
/* --border-glow-color: rgba(var(--glow-rgb)); */
border-color: unset;
border-top: 3px solid var(--border-color);
border-top: var(--border-width) solid var(--border-color);
}
.bordered-red {
@ -423,7 +423,7 @@
--border-color: rgba(var(--danger-glow-rgb));
border-radius: var(--gutter-size);
border-style: solid;
border-width: 3px;
border-width: var(--border-width);
border-color: var(--border-color);
}
@ -432,7 +432,7 @@
/* color: var(--danger-text-color); */
/* --border-glow-color: rgba(var(--danger-glow-rgb)); */
border-color: unset;
border-bottom: 3px solid var(--border-color);
border-bottom: var(--border-width) solid var(--border-color);
}
@ -441,82 +441,9 @@
/* color: var(--danger-text-color); */
/* --border-glow-color: rgba(var(--danger-glow-rgb)); */
border-color: unset;
border-top: 3px solid var(--border-color);
border-top: var(--border-width) solid var(--border-color);
}
.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;
@ -614,19 +541,19 @@
width: 100%;
}
.-stripeed {
--stripee-color: var(--danger-fill-color);
--stripee-size: 15px;
.-striped {
--stripe-color: var(--danger-fill-color);
--stripe-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(--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)));
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)));
}
.-blink {
@ -646,31 +573,6 @@
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;
@ -903,10 +805,14 @@
transparent 50%,
rgba(255, 0, 0, 0.1) 50%);
background-size: 100% 4px;
animation: scanline 8s linear infinite;
/* animation: scanline 8s linear infinite; */
pointer-events: none;
}
.scanline.animate::after {
animation: scanline 8s linear infinite;
}
table tr td {
padding: 0px;
}
@ -1072,4 +978,62 @@ table tr td {
button {
text-box: trim-both cap alphabetic;
}
@media (max-width: 768px) {
.ews-title.internal .text.-characters {
font-size: 2rem;
}
.ews-title.badge .text.-characters {
font-size: 2rem;
}
.ews-title.internal .decal {
height: 50px;
}
}
/* Custom Range Slider */
.custom-range {
-webkit-appearance: none;
width: 100%;
height: 4px;
background: rgba(255, 165, 0, 0.2);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.custom-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: var(--orange);
border: 2px solid black;
border-radius: 0; /* Square/Diamond look for EWS */
cursor: pointer;
box-shadow: 0 0 10px rgba(var(--glow-rgb), 0.5);
transition: all 0.2s ease;
transform: rotate(45deg);
}
.custom-range::-webkit-slider-thumb:hover {
transform: rotate(45deg) scale(1.2);
box-shadow: 0 0 15px rgba(var(--glow-rgb), 0.8);
}
.custom-range::-moz-range-thumb {
width: 16px;
height: 16px;
background: var(--orange);
border: 2px solid black;
border-radius: 0;
cursor: pointer;
box-shadow: 0 0 10px rgba(var(--glow-rgb), 0.5);
transition: all 0.2s ease;
transform: rotate(45deg);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,25 +26,25 @@
margin-right: 0px !important;
margin-left: 0px !important;
/* margin-bottom: -5px; */
--stripee-color: var(--orange);
--stripee-size: 15px;
--stripe-color: var(--orange);
--stripe-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(--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)));
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;
}
.stripe-bar.red {
--stripee-color: var(--red);
--stripee-size: 15px;
--stripe-color: var(--red);
--stripe-size: 15px;
--glow-color: rgba(255, 17, 0, 0.8);
--glow-size: 3px;
}
@ -63,52 +63,52 @@
display: inline-block;
flex-shrink: 0;
/* margin-bottom: -5px; */
--stripee-color: var(--red);
--stripee-size: 15px;
--stripe-color: var(--red);
--stripe-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(--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)));
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)));
}
.stripe-bar-vertical {
height: max(2000px, 200vh);
transform: translate3d(0, 0, 0);
--stripee-color: var(--orange);
--stripee-size: 15px;
--stripe-color: var(--orange);
--stripe-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(--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)));
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)));
}
.stripe-bar-red-vertical {
height: max(2000px, 200vh);
transform: translate3d(0, 0, 0);
--stripee-color: var(--red);
--stripee-size: 15px;
--stripe-color: var(--red);
--stripe-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(--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)));
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)));
}
.stripe-wrapper-vertical {

View file

@ -166,6 +166,7 @@
/* 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;
@ -214,6 +215,7 @@
/* 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;

67
src/lib/utils/db.ts Normal file
View file

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

View file

@ -10,11 +10,12 @@ export function createGempaPopupHTML(data: {
time: string;
lat: number;
lng: number;
place?: string;
}): string {
return `
<div class="ews-card bordered-red min-h-48 min-w-48 whitespace-pre-wrap" data-id="${data.id}">
<div class="ews-card ews-card-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="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="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="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>
</div>
@ -23,6 +24,7 @@ export function createGempaPopupHTML(data: {
<table class="w-full">
<tbody>
<tr><td class="flex">Magnitudo</td><td class="text-right break-words pl-2">${Number(data.mag).toFixed(1)}</td></tr>
<tr><td class="flex">Place</td><td class="text-right break-words pl-2">${data.place}</td></tr>
<tr><td class="flex">Kedalaman</td><td class="text-right break-words pl-2">${data.depth}</td></tr>
<tr><td class="flex">Waktu</td><td class="text-right break-words pl-2">${data.time}</td></tr>
<tr><td class="flex">Lokasi (Lat,Lng)</td><td class="text-right break-words pl-2">${data.lat} , ${data.lng}</td></tr>

View file

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

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,8 @@
import { PUBLIC_WEBSOCKET_URL } from "$env/static/public";
import { WaveformService } from "$lib/services/WaveformService";
import HexGrid from "$lib/components/HexGrid.svelte";
import HexShape from "$lib/components/HexShape.svelte";
import StripeBar from "$lib/components/StripeBar.svelte";
export let data: PageData;
let waveformChart: any;
@ -133,6 +135,328 @@
let isSettingsOpen = false;
let selectedTimezone = 0; // 0: UTC, 7: WIB, 8: WITA, 9: WIT
// Historical Data Mode
let startTimeInput: string = ""; // datetime-local format: "2026-04-02T00:00"
let endTimeInput: string = "";
let isHistoricalMode: boolean = false;
let isLoadingHistory: boolean = false;
let historyError: string = "";
let historicalStartMs: number = 0;
let historicalEndMs: number = 0;
// Local MiniSEED file upload
let miniseedFileInput: HTMLInputElement;
let isLoadingMiniseed: boolean = false;
let miniseedError: string = "";
let miniseedFileName: string = "";
// Web Serial ESP32
let isEspConnected = false;
let espSerialPort: any;
class LineBreakTransformer {
chunks: string;
constructor() {
this.chunks = "";
}
transform(chunk: string, controller: any) {
this.chunks += chunk;
const lines = this.chunks.split("\n");
this.chunks = lines.pop() || "";
lines.forEach((line) => controller.enqueue(line.trim()));
}
flush(controller: any) {
if (this.chunks) {
controller.enqueue(this.chunks.trim());
}
}
}
let targetP1 = -1;
let targetP2 = -1;
let targetP3 = -1;
let currentP1 = -1;
let currentP2 = -1;
let currentP3 = -1;
let lerpAnimFrame: number;
const LERP_SPEED = 0.1; // Tingkat kehalusan, semakin kecil makin halus
function lerpLoop() {
if (!isEspConnected) return;
if (targetP1 !== -1 && currentP1 !== -1) {
currentP1 += (targetP1 - currentP1) * LERP_SPEED;
zoomLevel = MIN_ZOOM + (currentP1 / 4095) * (MAX_ZOOM - MIN_ZOOM);
}
if (targetP2 !== -1 && currentP2 !== -1) {
currentP2 += (targetP2 - currentP2) * LERP_SPEED;
timeWindowMs = 2000 + (currentP2 / 4095) * (300000 - 2000);
}
if (targetP3 !== -1 && currentP3 !== -1) {
currentP3 += (targetP3 - currentP3) * LERP_SPEED;
// Snap ke 0 ketika target sudah pasti 0 agar resumeLive memicu
if (targetP3 === 0 && currentP3 < 1) {
currentP3 = 0;
}
if (currentP3 === 0) {
if (timeOffsetMs !== 0) {
timeOffsetMs = 0;
if (waveformChart) waveformChart.resumeLive();
}
} else {
timeOffsetMs = (currentP3 / 4095) * 300000;
}
}
lerpAnimFrame = requestAnimationFrame(lerpLoop);
}
async function connectEsp32() {
if (!browser) return;
try {
if ("serial" in navigator) {
if (!espSerialPort) {
espSerialPort = await (
navigator as any
).serial.requestPort();
await espSerialPort.open({ baudRate: 115200 });
isEspConnected = true;
logMessages += `${new Date().toLocaleString()} : ESP32 Serial Connected\n`;
targetP1 = -1;
targetP2 = -1;
targetP3 = -1;
currentP1 = -1;
currentP2 = -1;
currentP3 = -1;
if (lerpAnimFrame) cancelAnimationFrame(lerpAnimFrame);
lerpAnimFrame = requestAnimationFrame(lerpLoop);
readEsp32Serial();
} else {
// Close the port if already connected
await espSerialPort.close();
espSerialPort = null;
isEspConnected = false;
if (lerpAnimFrame) cancelAnimationFrame(lerpAnimFrame);
logMessages += `${new Date().toLocaleString()} : ESP32 Serial Disconnected\n`;
}
} else {
console.error("Web Serial API not supported in this browser.");
logMessages += `${new Date().toLocaleString()} : Web Serial API not supported.\n`;
}
} catch (err) {
console.error("Error connecting to ESP32:", err);
logMessages += `${new Date().toLocaleString()} : ESP32 Connection Error.\n`;
}
}
async function readEsp32Serial() {
if (!espSerialPort) return;
const textDecoder = new TextDecoderStream();
const readableStreamClosed = espSerialPort.readable.pipeTo(
textDecoder.writable,
);
const reader = textDecoder.readable
.pipeThrough(new TransformStream(new LineBreakTransformer()))
.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) {
try {
const data = JSON.parse(value);
if (
data.p1 !== undefined &&
data.p2 !== undefined &&
data.p3 !== undefined
) {
let p1Val = data.p1 < 50 ? 0 : data.p1;
let p2Val = data.p2 < 50 ? 0 : data.p2;
let p3Val = data.p3 < 50 ? 0 : data.p3;
targetP1 = p1Val;
targetP2 = p2Val;
targetP3 = p3Val;
if (currentP1 === -1) currentP1 = p1Val;
if (currentP2 === -1) currentP2 = p2Val;
if (currentP3 === -1) currentP3 = p3Val;
}
} catch (e) {
// ignore JSON parse errors or partial lines
}
}
}
} catch (err) {
console.error("Serial read error", err);
} finally {
isEspConnected = false;
espSerialPort = null;
if (lerpAnimFrame) cancelAnimationFrame(lerpAnimFrame);
}
}
async function loadHistoricalData() {
if (!startTimeInput || !endTimeInput) {
historyError = "Start Time dan End Time harus diisi.";
return;
}
// datetime-local gives "YYYY-MM-DDTHH:MM" or "YYYY-MM-DDTHH:MM:SS" (local time, no Z)
// We treat user input as UTC — append 'Z' to force UTC parsing
const toUTC = (dt: string) => {
const s = dt.length === 16 ? dt + ":00" : dt; // ensure HH:MM:SS
return new Date(s + "Z").getTime();
};
const startMs = toUTC(startTimeInput);
const endMs = toUTC(endTimeInput);
if (isNaN(startMs) || isNaN(endMs) || endMs <= startMs) {
historyError = "Range waktu tidak valid.";
return;
}
historyError = "";
isLoadingHistory = true;
// Format ISO for FDSN API (already UTC since startMs is UTC based)
const startISO = new Date(startMs).toISOString();
const endISO = new Date(endMs).toISOString();
const network = data.networkCode ?? "GE";
const station = data.stationCode ?? "LUWI";
const channel = selectedChannel
? selectedChannel["@attributes"].code
: "BHZ";
const url = `https://geofon.gfz.de/fdsnws/dataselect/1/query?starttime=${encodeURIComponent(startISO)}&endtime=${encodeURIComponent(endISO)}&nodata=404&network=${network}&station=${station}&channel=${channel}`;
try {
const response = await fetch(url);
if (!response.ok) {
historyError = `Gagal fetch data: HTTP ${response.status}`;
isLoadingHistory = false;
return;
}
const arrayBuffer = await response.arrayBuffer();
// Clear buffer and load historical data
// Pass startMs as hint for timestamp fallback, skipTrim=true so 1-hour+ data isn't cut
waveformService.clearBuffer();
waveformService.processMiniseedRaw(
arrayBuffer,
nominalSampleRateMs,
startMs,
true,
);
dataBuffer = waveformService.getBuffer();
if (dataBuffer.length === 0) {
historyError =
"Data kosong atau tidak ada data pada rentang waktu ini.";
isLoadingHistory = false;
return;
}
// Use USER's requested range for historicalStartMs/EndMs
// (don't use actualEnd — it may be Date.now() if timestamp parsing failed)
historicalStartMs = startMs;
historicalEndMs = endMs;
isHistoricalMode = true;
timeOffsetMs = 0; // Start at right edge (endTime)
// Jump chart to end of historical range
if (waveformChart) {
waveformChart.jumpToHistoricalEnd();
}
const actualStart = dataBuffer[0].t;
const actualEnd = dataBuffer[dataBuffer.length - 1].t;
logMessages += `${new Date().toLocaleString()} : Historical data loaded (${dataBuffer.length} pts | actual: ${new Date(actualStart).toISOString()} ~ ${new Date(actualEnd).toISOString()})\n`;
isSettingsOpen = false;
} catch (err: any) {
historyError = `Error: ${err.message}`;
console.error(err);
} finally {
isLoadingHistory = false;
}
}
function clearHistoricalMode() {
waveformService.clearBuffer();
dataBuffer = waveformService.getBuffer();
historicalStartMs = 0;
historicalEndMs = 0;
isHistoricalMode = false;
timeOffsetMs = 0;
historyError = "";
miniseedFileName = "";
miniseedError = "";
if (waveformChart) waveformChart.resumeLive();
logMessages += `${new Date().toLocaleString()} : Back to live mode\n`;
}
async function loadMiniseedFile(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
miniseedFileName = file.name;
miniseedError = "";
isLoadingMiniseed = true;
try {
const arrayBuffer = await file.arrayBuffer();
waveformService.clearBuffer();
// No expectedStartMs hint — let header timestamps drive it
waveformService.processMiniseedRaw(
arrayBuffer,
nominalSampleRateMs,
undefined,
true,
);
dataBuffer = waveformService.getBuffer();
if (dataBuffer.length === 0) {
miniseedError =
"Tidak ada data yang dapat dibaca dari file ini.";
isLoadingMiniseed = false;
return;
}
const actualStart = dataBuffer[0].t;
const actualEnd = dataBuffer[dataBuffer.length - 1].t;
historicalStartMs = actualStart;
historicalEndMs = actualEnd;
isHistoricalMode = true;
timeOffsetMs = 0;
if (waveformChart) waveformChart.jumpToHistoricalEnd();
logMessages += `${new Date().toLocaleString()} : MiniSEED file loaded "${file.name}" (${dataBuffer.length} pts | ${new Date(actualStart).toISOString()} ~ ${new Date(actualEnd).toISOString()})\n`;
} catch (err: any) {
miniseedError = `Error: ${err.message}`;
console.error(err);
} finally {
isLoadingMiniseed = false;
// Reset file input so the same file can be re-loaded
input.value = "";
}
}
function loadDataStation(network: string, station: string) {
const url = `https://geofon.gfz-potsdam.de/fdsnws/station/1/query?network=${network}&station=${station}&level=response&format=xml`;
@ -240,7 +564,8 @@
ws.onmessage = (e) => {
const dataBufferIncoming = e.data;
if (isDemoMode || isDemoPsychoMode) {
// Jangan proses data live saat historical mode aktif
if (isDemoMode || isDemoPsychoMode || isHistoricalMode) {
return;
}
@ -296,6 +621,19 @@
<div
class="min-h-screen px-1 lg:px-0 py-1 lg:py-8 flex flex-col justify-center overflow-hidden font-mono relative gap-2"
>
<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="w-full flex flex-col-reverse lg:flex-row gap-2 justify-center">
{#if stationData != null && stationData.Network.Station != undefined && !Array.isArray(stationData.Network.Station)}
<div class="flex flex-col gap-4 w-auto h-full items-stretch">
@ -306,9 +644,9 @@
</p>
{/snippet}
{#snippet children()}
<div class="w-full flex flex-row gap-2">
<div class="w-full flex flex-row gap-2 p-1 lg:p-2">
<div
class="badge ews-title text-3xl bordered flex justify-between mb-2 w-full"
class="badge ews-title text-3xl bordered flex justify-between w-full"
>
<div
class="flex flex-col items-center justify-between p-1"
@ -333,7 +671,7 @@
></div>
</div>
</div>
<div class="bordered p-2 w-full">
<div class="bordered p-1 lg:p-2 w-full">
<table class="w-full">
<tbody>
<tr>
@ -391,67 +729,137 @@
</p>
{/snippet}
{#snippet children()}
<HexGrid variant="flat">
{#each listChannel as channel, channelIndex (channel["@attributes"]["code"])}
<div
class="w-full h-full {channel['@attributes']
.endDate == ''
? 'yellow glow-orange-small'
: 'glow-red-small'}"
>
<button
class="w-full h-full cursor-pointer hex-hive bg-hex-flat opacity-0 show-pop-up {selectedChannel !=
undefined &&
selectedChannel != null &&
selectedChannel['@attributes'].code ==
channel['@attributes']['code']
? 'blink blink-fast'
: ''} {channel['@attributes']
.endDate == undefined ||
channel['@attributes'].endDate == ''
? 'yellow '
: ' '}"
<div class="p-1 lg:p-2 w-full">
<HexGrid variant="flat">
{#each listChannel as channel, channelIndex (channel["@attributes"]["code"])}
<div
class="ews-hex-hive flat opacity-0 show-pop-up"
style="animation-delay: {Math.min(
channelIndex * 50,
1000,
)}ms; width: 83px; height: 72px;"
on:click={() => {
selectedChannel = channel;
const request = {
net: data.networkCode ?? "GE",
sta: data.stationCode ?? "GSI",
cha: selectedChannel[
"@attributes"
].code,
};
if (
ws &&
ws.readyState === WebSocket.OPEN
) {
ws.send(
JSON.stringify(request),
);
}
}}
>
<div
class="w-full h-full flex justify-center items-center text-black text-center"
<HexShape
clipContent={true}
color={channel["@attributes"]
.endDate == "" ||
channel["@attributes"].endDate ==
undefined
? "orange"
: "red"}
className={selectedChannel !=
undefined &&
selectedChannel != null &&
selectedChannel["@attributes"]
.code ==
channel["@attributes"]["code"]
? "blink blink-fast"
: ""}
>
{channel["@attributes"]["code"]}
</div>
</button>
</div>
{/each}
</HexGrid>
<button
class="w-full h-full cursor-pointer"
on:click={() => {
selectedChannel = channel;
const request = {
net:
data.networkCode ??
"GE",
sta:
data.stationCode ??
"GSI",
cha: selectedChannel[
"@attributes"
].code,
};
if (
ws &&
ws.readyState ===
WebSocket.OPEN
) {
ws.send(
JSON.stringify(
request,
),
);
}
}}
>
<div
class="w-full h-full flex justify-center items-center text-black text-center"
>
{channel["@attributes"][
"code"
]}
</div>
</button>
</HexShape>
</div>
{/each}
</HexGrid>
</div>
{/snippet}
{#snippet footer()}
<!-- Hidden file input for miniseed upload -->
<input
bind:this={miniseedFileInput}
type="file"
accept=".mseed,.miniseed,.ms,application/octet-stream"
class="hidden"
on:change={loadMiniseedFile}
/>
<div class="flex flex-col gap-2 w-full">
<button
class="ews-btn ews-btn-primary"
on:click={() => (isSettingsOpen = true)}
>SETTING</button
>
<button
id="load-miniseed-btn"
class="ews-btn ews-btn-danger {isLoadingMiniseed
? 'opacity-60 cursor-not-allowed'
: 'cursor-pointer'}"
on:click={() =>
!isLoadingMiniseed &&
miniseedFileInput.click()}
disabled={isLoadingMiniseed}
title="Load local MiniSEED file"
>
{#if isLoadingMiniseed}
<span class="animate-spin inline-block mr-1"
>⟳</span
> LOADING...
{:else}
LOAD MINISEED
{/if}
</button>
<button
id="connect-esp32-btn"
class="ews-btn {isEspConnected
? 'ews-btn-danger'
: 'ews-btn-primary'} cursor-pointer"
on:click={connectEsp32}
>
{isEspConnected
? "ESP32 CONNECTED"
: "CONNECT ESP32"}
</button>
{#if miniseedFileName && !isLoadingMiniseed}
<div
class="text-xs text-center truncate px-1"
style="color: #fa0; opacity: 0.75;"
title={miniseedFileName}
>
📼 {miniseedFileName}
</div>
{/if}
{#if miniseedError}
<div
class="text-xs text-red-400 text-center px-1 break-words"
>
{miniseedError}
</div>
{/if}
</div>
{/snippet}
</Card>
@ -543,6 +951,8 @@
{selectedTimezone}
{isDemoPsychoMode}
{psychoPoints}
{historicalStartMs}
{historicalEndMs}
backgroundColor="#000000"
gridColor="#33cc55"
axesColor="#fa0"
@ -569,7 +979,18 @@
<div
class="flex flex-col lg:flex-row items-center gap-0 lg:gap-4 h-4"
>
{#if timeOffsetMs > 0}
{#if isHistoricalMode}
<span class="text-yellow-400 text-xs font-bold"
>HISTORICAL MODE</span
>
<button
class="bg-orange-950 border hover:bg-orange-800 text-white text-xs lg:text-md px-1 lg:px-3 py-0 lg:py-1 rounded cursor-pointer transition-colors"
style="border-color: #fa0;"
on:click={clearHistoricalMode}
>
Back to Live
</button>
{:else if timeOffsetMs > 0}
<button
class="bg-orange-950 border hover:bg-orange-800 text-white text-xs lg:text-md px-1 lg:px-3 py-0 lg:py-1 rounded cursor-pointer transition-colors"
style="border-color: #fa0;"
@ -608,7 +1029,79 @@
{/snippet}
{#snippet children()}
<div class="flex flex-col gap-6 w-full p-2">
<div class="flex flex-col gap-6 w-full p-1 lg:p-2">
<!-- Historical Data Filter -->
<div class="flex flex-col gap-2">
<p
class="text-orange-500 font-bold uppercase tracking-widest text-sm mb-1"
>
Historical Data (FDSN DataSelect):
</p>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<label
for="hist-start-time"
class="text-orange-400 text-xs uppercase tracking-widest"
>Start Time (UTC)</label
>
<input
id="hist-start-time"
type="datetime-local"
bind:value={startTimeInput}
class="bg-black border border-orange-700 text-orange-300 px-2 py-1 text-sm w-full focus:outline-none focus:border-orange-400"
style="color-scheme: dark;"
step="1"
/>
</div>
<div class="flex flex-col gap-1">
<label
for="hist-end-time"
class="text-orange-400 text-xs uppercase tracking-widest"
>End Time (UTC)</label
>
<input
id="hist-end-time"
type="datetime-local"
bind:value={endTimeInput}
class="bg-black border border-orange-700 text-orange-300 px-2 py-1 text-sm w-full focus:outline-none focus:border-orange-400"
style="color-scheme: dark;"
step="1"
/>
</div>
{#if historyError}
<p class="text-red-400 text-xs">
{historyError}
</p>
{/if}
<div class="flex gap-2">
<button
class="ews-btn ews-btn-primary flex-1 flex items-center justify-center gap-2"
on:click={loadHistoricalData}
disabled={isLoadingHistory}
>
{#if isLoadingHistory}
<span class="animate-spin"></span> Memuat...
{:else}
Load Historical Data
{/if}
</button>
{#if isHistoricalMode}
<button
class="border border-orange-700 hover:bg-orange-950 text-orange-400 text-sm px-3 py-1"
on:click={() => {
clearHistoricalMode();
isSettingsOpen = false;
}}
>
✕ Clear
</button>
{/if}
</div>
</div>
</div>
<hr class="border-orange-900 my-2" />
<!-- Timezone Settings -->
<div class="flex flex-col gap-2">
<p
@ -645,18 +1138,12 @@
class="cursor-pointer p-0 b-0 overflow-hidden flex items-center justify-center bordered p-1"
on:click={toggleDemoData}
>
<div class="stripe-wrapper">
<div
class="stripe-bar anim-duration-20 {isDemoMode
? 'loop-stripe-reverse'
: ''}"
></div>
<div
class="stripe-bar anim-duration-20 {isDemoMode
? 'loop-stripe-reverse'
: ''}"
></div>
</div>
<StripeBar
reverse={true}
loop={isDemoMode}
duration={20}
></StripeBar>
<span class="absolute bg-black ews-label px-2 py-1"
>⚠ DEMO DATA</span
>
@ -666,18 +1153,12 @@
class="cursor-pointer p-0 b-0 overflow-hidden flex items-center justify-center bordered p-1 mt-2"
on:click={toggleDemoPsycho}
>
<div class="stripe-wrapper">
<div
class="stripe-bar-red anim-duration-20 {isDemoPsychoMode
? 'loop-stripe-reverse'
: ''}"
></div>
<div
class="stripe-bar-red anim-duration-20 {isDemoPsychoMode
? 'loop-stripe-reverse'
: ''}"
></div>
</div>
<StripeBar
reverse={true}
color="red"
loop={isDemoPsychoMode}
duration={20}
></StripeBar>
<span class="absolute bg-black ews-label px-2 py-1"
>⚠ DEMO PSYCHOGRAPHIC DATA</span
>

View file

@ -7,12 +7,16 @@
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);
@ -48,13 +52,103 @@
{ 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 w-4xl mx-auto text-xs">
<div class="p-8 min-h-screen max-w-4xl mx-auto text-xs">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Showcase UI Components</h1>
</div>
@ -87,6 +181,11 @@
<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>
</div>
<div class="w-full">
@ -113,6 +212,7 @@
</div>
</div>
</section>
<!-- CARD DEMO -->
<section>
<h2 class="text-xl font-semibold mb-4 border-b border-gray-700 pb-2">
@ -195,34 +295,30 @@
</div>
<div class="flex gap-2 w-full justify-center items-center">
<div class="">
<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>
<HexShape clipContent={true} color="orange" className="h-[100px]">
<StripeBar
className="bg-black"
loop={true}
reverse={true}
duration={20}
color="red"
></StripeBar>
</HexShape>
</div>
<div class="">
<div
class="hex-shape orange clip-content h-[100px] flex flex-col justify-center items-center"
<HexShape
clipContent={true}
flatTop={false}
color="orange"
className="h-[100px]"
>
<div class="inner-content">
<StripeBar
className="bg-black"
color="red"
loop={true}
reverse={true}
duration={20}
></StripeBar>
</div>
</div>
<StripeBar
className="bg-black"
loop={true}
color="red"
duration={20}
></StripeBar>
</HexShape>
</div>
</div>
<div class="long-hex h-[100px]"></div>
@ -234,42 +330,121 @@
</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="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="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="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 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>
</div>
</section>
@ -288,7 +463,11 @@
<div class="text">MAG</div>
</div>
<div class="decal">
<div class="w-full h-full stripe-bar vertical"></div>
<StripeBar
className="w-full h-full"
size={"100%"}
orientation="vertical"
></StripeBar>
</div>
</div>
@ -300,7 +479,12 @@
<div class="text">MAG</div>
</div>
<div class="decal">
<div class="w-full h-full stripe-bar red vertical"></div>
<StripeBar
className="w-full h-full"
size={"100%"}
orientation="vertical"
color="red"
></StripeBar>
</div>
</div>
</div>
@ -313,9 +497,12 @@
<div class="text">MAG</div>
</div>
<div class="decal">
<div
class="w-full h-full stripe-bar vertical loop-stripe-vertical-reverse anim-duration-10"
></div>
<StripeBar
className="w-full h-full"
size={"100%"}
orientation="vertical"
loop={true}
></StripeBar>
</div>
</div>
@ -327,9 +514,13 @@
<div class="text">MAG</div>
</div>
<div class="decal">
<div
class="w-full h-full stripe-bar red vertical loop-stripe-vertical anim-duration-10"
></div>
<StripeBar
className="w-full h-full"
size={"100%"}
orientation="vertical"
color="red"
loop={true}
></StripeBar>
</div>
</div>
</div>
@ -395,7 +586,7 @@
</div>
<div>
<p class="text-gray-500 text-xs mb-1">ews-text danger</p>
<p class="ews-text danger text-lg">PERINGATAN DINI</p>
<p class="ews-text danger text-lg">EARLY WARNING</p>
</div>
<div>
<p class="text-gray-500 text-xs mb-1">ews-text</p>
@ -491,7 +682,7 @@
<div>
<p class="text-gray-500 text-xs mb-1">ews-select danger</p>
<select class="ews-select danger">
<option>LEVEL PERINGATAN</option>
<option>LEVEL WARNING</option>
<option>SIAGA</option>
<option>WASPADA</option>
<option>AWAS</option>
@ -665,6 +856,95 @@
</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">
@ -698,7 +978,7 @@
{#if showTsunamiAlert}
<!-- Tsunami Alert takes up the full screen and has animations -->
<div class="fixed inset-0 z-50 pointer-events-auto">
<TsunamiAlert alertTsunami={dummyTsunami} />
<TsunamiAlert infoTsunami={dummyTsunami.infoTsunami} />
<button
class="absolute top-4 right-4 z-[60] bg-black text-white px-4 py-2"
onclick={() => (showTsunamiAlert = false)}

View file

@ -227,7 +227,20 @@
class="min-h-screen py-1 md:py-4 flex flex-col items-center overflow-x-hidden overflow-y-auto font-mono"
>
<div
class="mb-2 text-center p-2 z-10 w-full bordered flex justify-center items-center relative show-pop-up"
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>
@ -249,14 +262,14 @@
{#snippet title()}
<h1>NETWORK CHANNEL STATUS</h1>
{/snippet}
<div class="overflow-y-auto max-h-[700px]">
<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-[700px] bg-black rounded"
class="w-full h-[80vh] bg-black rounded"
bind:this={mapContainer}
></div>

View file

@ -20,8 +20,7 @@
onMount(() => {
// https://geofon.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?${mapStore.urlParams}&level=station`;
fetch(url)
.then((response) => {
@ -91,7 +90,21 @@
class="min-h-screen py-1 md:py-4 flex flex-col items-center overflow-x-hidden overflow-y-auto font-mono"
>
<div
class="mb-2 text-center p-2 z-10 w-full bordered flex justify-center items-center relative show-pop-up"
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-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>
@ -114,15 +127,14 @@
getHref={(item: any) =>
`/realtime?networkCode=${item.networkCode}&stationCode=${item.stationCode}`}
>
{#snippet nodeContent(item: any, { side, delay }: { side: string; delay: number })}
{#snippet nodeContent(
item: any,
{ side, delay }: { side: string; delay: number },
)}
<div
class="slide-fade-in {side === 'left' ? 'status-node' : 'status-node-flip'} {item.type ===
'danger'
? 'danger'
: ''} w-24 h-6 flex flex-glow flex-col items-center justify-center relative mt-6 {side ===
'left'
? '-mr-2'
: '-ml-2'} z-5 text-black text-xs font-bold"
class="slide-fade-in ews-rib-node {side === 'right'
? 'flip'
: ''} {item.type === 'danger' ? 'danger' : ''}"
style="animation-delay: {delay}ms;"
></div>
{/snippet}