mirror of
https://github.com/bagusindrayana/ews-concept-new.git
synced 2026-06-08 09:45:34 +00:00
Compare commits
37 commits
web-serial
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4145a34d2a | ||
|
|
815ab6c43a | ||
|
|
f91e1b0b39 | ||
|
|
06a1354270 | ||
|
|
df415da084 | ||
|
|
499fa24859 | ||
|
|
f02d8a7d42 | ||
|
|
ef3b5660bd | ||
|
|
068096ab1c | ||
|
|
417108d65e | ||
|
|
c43fbac2b1 | ||
|
|
b457528084 | ||
|
|
416bb9ef36 | ||
|
|
c74d3b41d2 | ||
|
|
255c67016c | ||
|
|
9694f80060 | ||
|
|
a877e731f5 | ||
|
|
6f3c5e8bae | ||
|
|
12412fead1 | ||
|
|
c1fd39fe46 |
||
|
|
1ac6048366 | ||
|
|
8a1874f2ed | ||
|
|
77b991d0a0 | ||
|
|
881fa9e133 | ||
|
|
e418d2dd1e | ||
|
|
174d1e1ab7 | ||
|
|
1f2f630194 | ||
|
|
e05591f64a | ||
|
|
44033f4558 | ||
|
|
72f8bc86bd | ||
|
|
79a343dd6c | ||
|
|
f39552adb8 | ||
|
|
1fd78fb300 | ||
|
|
178fddd5b3 | ||
|
|
08ad1e9f76 | ||
|
|
262777b980 | ||
|
|
db544dc769 |
29 changed files with 2929 additions and 319 deletions
26
LICENSE
Normal file
26
LICENSE
Normal 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.
|
||||
|
|
@ -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!
|
||||
[](https://sociabuzz.com/bagusindrayana/tribe)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"; */
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
|
||||
if (!isFlat) {
|
||||
// Pointy (Variant 1)
|
||||
const rowOffsetTop = -14;
|
||||
const rowOffsetTop = gap + -20;
|
||||
const itemFullWidth = w + gap;
|
||||
|
||||
let maxCols = Math.floor(
|
||||
|
|
|
|||
138
src/lib/components/InfiniteScroll.svelte
Normal file
138
src/lib/components/InfiniteScroll.svelte
Normal 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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import StripeBar from "./StripeBar.svelte";
|
||||
|
||||
let {
|
||||
show = $bindable(false),
|
||||
|
|
@ -30,10 +31,7 @@
|
|||
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"
|
||||
>
|
||||
|
|
|
|||
134
src/lib/components/RangeSlider.svelte
Normal file
134
src/lib/components/RangeSlider.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
718
src/lib/components/ThreadedComments.svelte
Normal file
718
src/lib/components/ThreadedComments.svelte
Normal 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>
|
||||
|
|
@ -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 ews-card-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({
|
||||
|
|
|
|||
|
|
@ -133,46 +133,50 @@
|
|||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-11/12 md:w-3/4 overflow-hidden bg-black relative rounded flex justify-center items-center opacity-0 show-pop-up animation-delay-2"
|
||||
>
|
||||
<div
|
||||
class="w-11/12 md:w-3/4 overflow-hidden bg-black relative rounded flex justify-center items-center opacity-0 show-pop-up animation-delay-2"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
|
|
@ -183,14 +187,8 @@
|
|||
<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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -1,14 +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;
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -805,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;
|
||||
}
|
||||
|
|
@ -990,4 +994,46 @@ button {
|
|||
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);
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
}
|
||||
|
||||
.ews-card.ews-card-red {
|
||||
--ews-card-color: var(--red, #f23);
|
||||
--ews-card-color: var(--red, #e60908);
|
||||
}
|
||||
|
||||
.ews-card-header {
|
||||
|
|
|
|||
266
src/lib/styles/components/RibLayout.css
Normal file
266
src/lib/styles/components/RibLayout.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
}
|
||||
|
||||
.ews-stripe-bar.red {
|
||||
--ews-ews-stripe-color: var(--red, #f23);
|
||||
--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;
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
/* margin-bottom: -5px; */
|
||||
--ews-ews-stripe-color: var(--red, #f23);
|
||||
--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;
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
.ews-stripe-bar-red-vertical {
|
||||
height: max(2000px, 200vh);
|
||||
transform: translate3d(0, 0, 0);
|
||||
--ews-ews-stripe-color: var(--red, #f23);
|
||||
--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;
|
||||
|
|
@ -122,7 +122,7 @@
|
|||
.ews-stripe {
|
||||
background-color: black;
|
||||
width: 100vw;
|
||||
border-top: 1px solid var(--red, #f23);
|
||||
border-bottom: 1px solid var(--red, #f23);
|
||||
border-top: 1px solid var(--red, #e60908);
|
||||
border-bottom: 1px solid var(--red, #e60908);
|
||||
position: fixed;
|
||||
}
|
||||
19
src/lib/styles/components/WaveformChart.css
Normal file
19
src/lib/styles/components/WaveformChart.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function createGempaPopupHTML(data: {
|
|||
return `
|
||||
<div class="ews-card ews-card-red min-h-48 min-w-48 whitespace-pre-wrap" data-id="${data.id}">
|
||||
<div class="ews-card-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>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
} from "$lib/utils/db";
|
||||
import Icon from "@iconify/svelte";
|
||||
import SerialStatus from "$lib/components/SerialStatus.svelte";
|
||||
import RangeSlider from "$lib/components/RangeSlider.svelte";
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
let map: mapboxgl.Map;
|
||||
|
|
@ -51,9 +52,9 @@
|
|||
|
||||
let geoJsonData: any = null;
|
||||
let geoJsonCoastline: any = null;
|
||||
let geoJsonTitikGempa: any = null;
|
||||
let geoJsonTitikGempa = $state<any>(null);
|
||||
let worker: Worker | null = null;
|
||||
let tgs: TitikGempa[] = [];
|
||||
let tgs = $state<TitikGempa[]>([]);
|
||||
let titikGempaBaru: TitikGempa[] = [];
|
||||
let tts: TitikTsunami[] = [];
|
||||
let markerDaerahs: number[][] = [];
|
||||
|
|
@ -81,6 +82,102 @@
|
|||
let sourceDataInput = $state(JSON.stringify(sourceDataConfig, null, 4));
|
||||
let historyRecords = $state<string[][]>([]);
|
||||
|
||||
// Filter state
|
||||
let showFilterModal = $state(false);
|
||||
let filters = $state({
|
||||
magMin: 0,
|
||||
magMax: 10,
|
||||
depthMin: 0,
|
||||
depthMax: 1000,
|
||||
timeMinHours: 0,
|
||||
timeMaxHours: 168, // 7 days
|
||||
});
|
||||
|
||||
function formatHours(h: number) {
|
||||
const days = Math.floor(h / 24);
|
||||
const hours = h % 24;
|
||||
let res = "";
|
||||
if (days > 0) res += `${days}d `;
|
||||
if (hours > 0 || days === 0) res += `${hours}h`;
|
||||
return res.trim();
|
||||
}
|
||||
|
||||
const filteredEvents = $derived.by(() => {
|
||||
return tgs.filter((tg) => {
|
||||
const mag = tg.infoGempa.mag || 0;
|
||||
const depth =
|
||||
parseFloat(String(tg.infoGempa.depth).replace(" Km", "")) || 0;
|
||||
|
||||
const magMatch = mag >= filters.magMin && mag <= filters.magMax;
|
||||
const depthMatch = depth >= filters.depthMin && depth <= filters.depthMax;
|
||||
|
||||
let timeMatch = true;
|
||||
if (tg.infoGempa.time) {
|
||||
const eventTime = new Date(tg.infoGempa.time).getTime();
|
||||
const now = new Date().getTime();
|
||||
const diffHours = (now - eventTime) / (1000 * 60 * 60);
|
||||
timeMatch =
|
||||
diffHours >= filters.timeMinHours &&
|
||||
diffHours <= filters.timeMaxHours;
|
||||
}
|
||||
|
||||
return magMatch && depthMatch && timeMatch;
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Tracking reactive dependencies: filteredEvents, geoJsonTitikGempa
|
||||
events = filteredEvents;
|
||||
if (geoJsonTitikGempa) {
|
||||
updateMapFilter();
|
||||
}
|
||||
});
|
||||
|
||||
function updateMapFilter() {
|
||||
if (!map || !map.getSource("earthquakes") || !geoJsonTitikGempa) return;
|
||||
|
||||
console.log("Filtering map features with:", filters);
|
||||
const filteredFeatures = geoJsonTitikGempa.features.filter((f: any) => {
|
||||
const mag = parseFloat(String(f.properties.mag || 0));
|
||||
const depth = parseFloat(
|
||||
String(f.properties.depth || 0).replace(" Km", ""),
|
||||
);
|
||||
const time = f.properties.time;
|
||||
|
||||
const magMatch = mag >= filters.magMin && mag <= filters.magMax;
|
||||
const depthMatch = depth >= filters.depthMin && depth <= filters.depthMax;
|
||||
|
||||
let timeMatch = true;
|
||||
if (time) {
|
||||
const eventTime = new Date(time).getTime();
|
||||
const now = new Date().getTime();
|
||||
const diffHours = (now - eventTime) / (1000 * 60 * 60);
|
||||
timeMatch =
|
||||
diffHours >= filters.timeMinHours &&
|
||||
diffHours <= filters.timeMaxHours;
|
||||
}
|
||||
|
||||
return magMatch && depthMatch && timeMatch;
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Updating map source with ${filteredFeatures.length} / ${geoJsonTitikGempa.features.length} features`,
|
||||
);
|
||||
(map.getSource("earthquakes") as mapboxgl.GeoJSONSource).setData({
|
||||
type: "FeatureCollection",
|
||||
features: filteredFeatures,
|
||||
});
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.magMin = 0;
|
||||
filters.magMax = 10;
|
||||
filters.depthMin = 0;
|
||||
filters.depthMax = 1000;
|
||||
filters.timeMinHours = 0;
|
||||
filters.timeMaxHours = 168;
|
||||
}
|
||||
|
||||
// Snapshot Modal state
|
||||
let showSnapshotModal = $state(false);
|
||||
let snapshotsList: Snapshot[] = $state([]);
|
||||
|
|
@ -507,7 +604,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
// GET DATA GEMPA (Unified Initialization via Server-Side Proxy)
|
||||
// GET DATA EARTHQUAKE (Unified Initialization via Server-Side Proxy)
|
||||
function initializeMapData(customConfig?: any) {
|
||||
const fetchOptions = customConfig
|
||||
? {
|
||||
|
|
@ -546,7 +643,6 @@
|
|||
(info: any) => new TitikGempa(info.id, info),
|
||||
);
|
||||
tgs = ntg;
|
||||
events = [...tgs];
|
||||
|
||||
// Ensure loading screen is removed
|
||||
setTimeout(() => {
|
||||
|
|
@ -591,6 +687,8 @@
|
|||
},
|
||||
});
|
||||
|
||||
updateMapFilter();
|
||||
|
||||
map.on("click", "earthquakes-layer", (e: any) => {
|
||||
const coords = e.features[0].geometry.coordinates.slice();
|
||||
const d = e.features[0].properties;
|
||||
|
|
@ -703,16 +801,18 @@
|
|||
GempaTerakhir.removeMarker();
|
||||
if (tgs.length > 0) {
|
||||
const ig = tgs[0].infoGempa;
|
||||
geoJsonTitikGempa.features.push(
|
||||
earthquakeService.toGeoJsonFeature(ig),
|
||||
);
|
||||
(map.getSource("earthquakes") as mapboxgl.GeoJSONSource).setData(
|
||||
geoJsonTitikGempa,
|
||||
);
|
||||
const newFeature = earthquakeService.toGeoJsonFeature(ig);
|
||||
if (geoJsonTitikGempa) {
|
||||
geoJsonTitikGempa.features = [
|
||||
...geoJsonTitikGempa.features,
|
||||
newFeature,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tgs.push(
|
||||
tgs = [
|
||||
...tgs,
|
||||
new TitikGempa(nig.id, nig, {
|
||||
map,
|
||||
zoomToPosition: true,
|
||||
|
|
@ -721,7 +821,7 @@
|
|||
showPopUpInSecond: 1,
|
||||
description: nig.message,
|
||||
}),
|
||||
);
|
||||
];
|
||||
tgs.sort(
|
||||
(a, b) => new Date(b.time!).getTime() - new Date(a.time!).getTime(),
|
||||
);
|
||||
|
|
@ -911,10 +1011,15 @@
|
|||
class="hidden md: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"
|
||||
>
|
||||
<button
|
||||
class="ews-btn ews-btn-primary scale-75 md:scale-100 pointer-events-auto"
|
||||
onclick={() => (showFilterModal = true)}>FILTER</button
|
||||
>
|
||||
<button
|
||||
class="ews-btn ews-btn-primary scale-75 md:scale-100 pointer-events-auto"
|
||||
onclick={() => (showSettingsModal = true)}>SETTING</button
|
||||
>
|
||||
|
||||
<button
|
||||
class="ews-btn ews-btn-primary scale-75 md:scale-100 pointer-events-auto"
|
||||
onclick={() => (showSourceModal = true)}>SOURCE</button
|
||||
|
|
@ -932,9 +1037,16 @@
|
|||
>
|
||||
</div>
|
||||
|
||||
<!-- mobile menu button -->
|
||||
<div
|
||||
class="flex flex-col md:hidden justify-center fixed z-5 items-end left-auto right-0 top-0 bottom-0 m-auto"
|
||||
>
|
||||
<button
|
||||
class="ews-btn-primary p-1"
|
||||
onclick={() => (showFilterModal = true)}
|
||||
>
|
||||
<Icon icon="icon-park-solid:filter" width="20" height="20" />
|
||||
</button>
|
||||
<button
|
||||
class="ews-btn-primary p-1"
|
||||
onclick={() => (showSettingsModal = true)}
|
||||
|
|
@ -1030,26 +1142,19 @@
|
|||
<button
|
||||
onclick={testDemoGempa}
|
||||
class="cursor-pointer p-0 b-0 overflow-hidden flex items-center justify-center bordered p-1"
|
||||
><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} duration={20}></StripeBar>
|
||||
<span class="absolute bg-black ews-label px-2 py-1"
|
||||
>⚠ TEST GEMPA</span
|
||||
>⚠ TEST EARTHQUAKE</span
|
||||
></button
|
||||
>
|
||||
|
||||
<button
|
||||
onclick={testDemoTsunami}
|
||||
class="cursor-pointer p-0 b-0 overflow-hidden flex items-center justify-center bordered p-1"
|
||||
><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>
|
||||
>
|
||||
<StripeBar color="red" loop={true} reverse={true} duration={20}
|
||||
></StripeBar>
|
||||
<span class="absolute bg-black ews-label px-2 py-1"
|
||||
>⚠ TEST TSUNAMI</span
|
||||
></button
|
||||
|
|
@ -1058,6 +1163,82 @@
|
|||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- FILTER MODAL -->
|
||||
<Modal bind:show={showFilterModal} title="EVENT FILTER" variant="medium">
|
||||
<div class="flex flex-col gap-6 p-4 text-sm bg-[#050505]">
|
||||
<!-- Magnitude Filter -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
class="font-bold flex justify-between uppercase"
|
||||
style="color:var(--orange)"
|
||||
>
|
||||
<span>Magnitude</span>
|
||||
<span style="color:var(--red)"
|
||||
>{filters.magMin.toFixed(1)} - {filters.magMax.toFixed(1)} M</span
|
||||
>
|
||||
</label>
|
||||
<RangeSlider
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.1}
|
||||
bind:low={filters.magMin}
|
||||
bind:high={filters.magMax}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Depth Filter -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
class="font-bold flex justify-between uppercase"
|
||||
style="color:var(--orange)"
|
||||
>
|
||||
<span>Depth</span>
|
||||
<span style="color:var(--red)"
|
||||
>{filters.depthMin} - {filters.depthMax} KM</span
|
||||
>
|
||||
</label>
|
||||
<RangeSlider
|
||||
min={0}
|
||||
max={1000}
|
||||
step={10}
|
||||
bind:low={filters.depthMin}
|
||||
bind:high={filters.depthMax}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Time Filter -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
class="font-bold flex justify-between uppercase"
|
||||
style="color:var(--orange)"
|
||||
>
|
||||
<span>Time Offset (Last 7 Days)</span>
|
||||
<span style="color:var(--red)"
|
||||
>{formatHours(filters.timeMinHours)} - {formatHours(
|
||||
filters.timeMaxHours,
|
||||
)} ago</span
|
||||
>
|
||||
</label>
|
||||
<RangeSlider
|
||||
min={0}
|
||||
max={168}
|
||||
step={1}
|
||||
bind:low={filters.timeMinHours}
|
||||
bind:high={filters.timeMaxHours}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex justify-end mt-4 pt-3"
|
||||
style="border-top: 1px solid rgba(var(--danger-glow-rgb), 0.3)"
|
||||
>
|
||||
<button class="ews-btn ews-btn-primary" onclick={resetFilters}
|
||||
>RESET FILTER</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- SOURCE DATA MODAL -->
|
||||
<Modal
|
||||
bind:show={showSourceModal}
|
||||
|
|
@ -1253,14 +1434,8 @@
|
|||
>
|
||||
{#snippet title()}
|
||||
<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>
|
||||
<StripeBar color="red" loop={true} reverse={true} duration={20}
|
||||
></StripeBar>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center"
|
||||
>
|
||||
|
|
@ -1437,7 +1612,7 @@
|
|||
className="no-snapshot fixed top-1 left-2 right-2 md:left-auto md:right-3 md:top-3 md:w-1/3 lg:w-1/5 show-pop-up ews-card ews-card-red ews-card-float"
|
||||
>
|
||||
{#snippet title()}
|
||||
<StripeBar loop={true} color="red"
|
||||
<StripeBar color="red"
|
||||
><div
|
||||
class="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center"
|
||||
>
|
||||
|
|
@ -1652,7 +1827,7 @@
|
|||
<div
|
||||
class="hidden md:block right-0 bottom-0 left-0 md:left-auto md:bottom-6 md:right-3 fixed pointer-events-none flex gap-2 justify-end items-end"
|
||||
>
|
||||
<!-- DETAIL INFO GEMPA & SHAKEMAP -->
|
||||
<!-- DETAIL INFO EARTHQUAKE & SHAKEMAP -->
|
||||
{#if !loadingScreen && detailInfoGempa != undefined && detailInfoGempa != null && showDetailEvent}
|
||||
<Card
|
||||
className=" show-pop-up pointer-events-auto max-w-[100vw] md:max-w-100 "
|
||||
|
|
@ -1896,7 +2071,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- GEMPA ALERT -->
|
||||
<!-- EARTHQUAKE ALERT -->
|
||||
{#if !loadingScreen}
|
||||
{#each alertGempaBumis as v, i (v.id)}
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
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;
|
||||
|
|
@ -134,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`;
|
||||
|
||||
|
|
@ -241,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;
|
||||
}
|
||||
|
||||
|
|
@ -297,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">
|
||||
|
|
@ -463,12 +800,66 @@
|
|||
{/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>
|
||||
|
|
@ -560,6 +951,8 @@
|
|||
{selectedTimezone}
|
||||
{isDemoPsychoMode}
|
||||
{psychoPoints}
|
||||
{historicalStartMs}
|
||||
{historicalEndMs}
|
||||
backgroundColor="#000000"
|
||||
gridColor="#33cc55"
|
||||
axesColor="#fa0"
|
||||
|
|
@ -586,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;"
|
||||
|
|
@ -626,6 +1030,78 @@
|
|||
|
||||
{#snippet children()}
|
||||
<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
|
||||
|
|
@ -662,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
|
||||
>
|
||||
|
|
@ -683,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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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="ews-hex-hive">
|
||||
<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 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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue