commit de876df2a3b08d284be056576c2905786e694326 Author: agungbima009 Date: Sun Nov 8 14:01:13 2026 +0700 first commit diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c9ebf2d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system" +} \ No newline at end of file diff --git a/Api/__pycache__/main.cpython-310.pyc b/Api/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..37e7944 Binary files /dev/null and b/Api/__pycache__/main.cpython-310.pyc differ diff --git a/Api/main.py b/Api/main.py new file mode 100644 index 0000000..67df6a0 --- /dev/null +++ b/Api/main.py @@ -0,0 +1,712 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +import threading +import time +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any, Literal + +import numpy as np +import pandas as pd +import torch +import torch.nn as nn +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field +from sklearn.preprocessing import MinMaxScaler +from torch.utils.data import DataLoader, TensorDataset +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait + + +ROOT = Path(__file__).resolve().parents[1] +DB_PATH = Path(os.getenv("NBEATS_DB_PATH", ROOT / "data deploy hasil.sqlite")) +MODEL_PATH = Path(os.getenv("NBEATS_MODEL_PATH", ROOT / "best_nbeats_model.pth")) +HYPERPARAMETERS_PATH = Path( + os.getenv("NBEATS_HYPERPARAMETERS_PATH", ROOT / "best_nbeats_hyperparameters.json") +) +TABLE_NAME = "data_deploy_hasil" +VALUE_COLUMNS = ("Harga Medium 1", "Harga Medium 2", "rata2") +DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") +MODEL_LOCK = threading.RLock() +UPDATE_LOCK = threading.Lock() +SCRAPING_URL = "https://www.bi.go.id/hargapangan/TabelHarga/PasarTradisionalDaerah" +SCRAPING_COMMODITIES = ("Beras Kualitas Medium I", "Beras Kualitas Medium II") +SCRAPING_PROVINCE = "Jawa Timur" +SCRAPING_REGENCY = "Kab. Banyuwangi" +FORECAST_HISTORY_DAYS = 90 + + +def trend_basis(theta: torch.Tensor, size: int) -> torch.Tensor: + time = torch.linspace(0, 1, steps=size, device=theta.device) + basis = torch.stack([time**i for i in range(theta.shape[1])], dim=0) + return theta @ basis + + +def seasonality_basis(theta: torch.Tensor, size: int) -> torch.Tensor: + harmonics = theta.shape[1] // 2 + time = torch.linspace(0, 2 * np.pi, steps=size, device=theta.device) + sine = torch.stack([torch.sin((i + 1) * time) for i in range(harmonics)], dim=0) + cosine = torch.stack([torch.cos((i + 1) * time) for i in range(harmonics)], dim=0) + return theta @ torch.cat([sine, cosine], dim=0) + + +class NBeatsBlock(nn.Module): + def __init__(self, input_size: int, output_size: int, hidden_size: int, + theta_size: int, basis_type: str) -> None: + super().__init__() + self.input_size = input_size + self.output_size = output_size + self.basis_type = basis_type + self.fc = nn.Sequential( + nn.Linear(input_size, hidden_size), nn.GELU(), + nn.Linear(hidden_size, hidden_size), nn.GELU(), + nn.Linear(hidden_size, hidden_size), nn.GELU(), + nn.Linear(hidden_size, hidden_size), nn.GELU(), + ) + self.theta_back = nn.Linear(hidden_size, theta_size) + self.theta_fore = nn.Linear(hidden_size, theta_size) + + def forward(self, values: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + hidden = self.fc(values) + theta_back = self.theta_back(hidden) + theta_fore = self.theta_fore(hidden) + if self.basis_type == "trend": + return trend_basis(theta_back, self.input_size), trend_basis(theta_fore, self.output_size) + return seasonality_basis(theta_back, self.input_size), seasonality_basis(theta_fore, self.output_size) + + +class NBeatsStack(nn.Module): + def __init__(self, input_size: int, output_size: int, hidden_size: int, + theta_size: int, blocks: int, basis_type: str) -> None: + super().__init__() + self.blocks = nn.ModuleList( + NBeatsBlock(input_size, output_size, hidden_size, theta_size, basis_type) + for _ in range(blocks) + ) + + def forward(self, values: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, list[torch.Tensor], list[torch.Tensor]]: + residual = values + forecast = torch.zeros((values.size(0), self.blocks[0].output_size), device=values.device) + backcasts, inputs = [], [] + for block in self.blocks: + backcast, block_forecast = block(residual) + inputs.append(residual.detach()) + backcasts.append(backcast) + residual = residual - backcast + forecast = forecast + block_forecast + return residual, forecast, backcasts, inputs + + +class NBeats(nn.Module): + def __init__(self, input_size: int, output_size: int, hidden_size: int, + n_stacks_trend: int, n_stacks_seasonal: int, + blocks_per_stack: int, theta_size_trend: int, + theta_size_season: int) -> None: + super().__init__() + self.output_size = output_size + self.trend_stacks = nn.ModuleList( + NBeatsStack(input_size, output_size, hidden_size, theta_size_trend, + blocks_per_stack, "trend") for _ in range(n_stacks_trend) + ) + self.season_stacks = nn.ModuleList( + NBeatsStack(input_size, output_size, hidden_size, theta_size_season * 2, + blocks_per_stack, "seasonality") for _ in range(n_stacks_seasonal) + ) + + def forward(self, values: torch.Tensor) -> tuple[torch.Tensor, list[torch.Tensor], list[torch.Tensor]]: + residual = values + forecast = torch.zeros((values.size(0), self.output_size), device=values.device) + backcasts, inputs = [], [] + for stack in [*self.trend_stacks, *self.season_stacks]: + residual, stack_forecast, stack_backcasts, stack_inputs = stack(residual) + forecast = forecast + stack_forecast + backcasts.extend(stack_backcasts) + inputs.extend(stack_inputs) + return forecast, backcasts, inputs + + +def load_hyperparameters() -> dict[str, Any]: + # Load tuned parameters when available; otherwise use the built-in defaults. + if HYPERPARAMETERS_PATH.exists(): + return json.loads(HYPERPARAMETERS_PATH.read_text(encoding="utf-8")) + return { + "input_size": 7, "output_size": 1, "hidden_size": 512, + "n_stacks_trend": 3, "n_stacks_seasonal": 4, + "blocks_per_stack": 3, "theta_size_trend": 2, "theta_size_season": 4, + } + + +def build_model(hyperparameters: dict[str, Any]) -> NBeats: + # Construct the model architecture from the selected hyperparameters. + model = NBeats( + input_size=int(hyperparameters["input_size"]), + output_size=int(hyperparameters["output_size"]), + hidden_size=int(hyperparameters["hidden_size"]), + n_stacks_trend=int(hyperparameters["n_stacks_trend"]), + n_stacks_seasonal=int(hyperparameters["n_stacks_seasonal"]), + blocks_per_stack=int(hyperparameters["blocks_per_stack"]), + theta_size_trend=int(hyperparameters["theta_size_trend"]), + theta_size_season=int(hyperparameters["theta_size_season"]), + ).to(DEVICE) + return model + + +def clean_data(data: pd.DataFrame, value_column: str) -> pd.DataFrame: + # Normalize dates and values before sorting and removing duplicate dates. + data["Tanggal"] = pd.to_datetime(data["Tanggal"], errors="coerce") + data[value_column] = pd.to_numeric(data[value_column], errors="coerce") + data = ( + data + .dropna(subset=["Tanggal"]) + .sort_values("Tanggal") + .drop_duplicates(subset=["Tanggal"], keep="last") + .reset_index(drop=True) + ) + + if data.empty: + return data + + window = 3 + previous_mean = ( + data[value_column] + .shift(1) + .rolling(window=window, min_periods=1) + .mean() + ) + next_mean = ( + data[value_column] + .shift(-1) + .rolling(window=window, min_periods=1) + .mean() + ) + data[value_column] = ( + # Fill missing values using neighboring trends, then interpolation as fallback. + data[value_column] + .fillna((previous_mean + next_mean) / 2) + .fillna(previous_mean) + .fillna(next_mean) + .interpolate(method="linear") + .bfill() + .ffill() + ) + + q1 = data[value_column].quantile(0.25) + q3 = data[value_column].quantile(0.75) + iqr = q3 - q1 + lower_bound = q1 - 1.5 * iqr + upper_bound = q3 + 1.5 * iqr + outlier_mask = ( + (data[value_column] < lower_bound) + | (data[value_column] > upper_bound) + ) + # Replace IQR outliers and interpolate them using surrounding observations. + data.loc[outlier_mask, value_column] = np.nan + data[value_column] = ( + data[value_column] + .interpolate(method="linear") + .bfill() + .ffill() + ) + return data + + +def read_data(value_column: str) -> pd.DataFrame: + # Read one selected series for forecasting or retraining. + if not DB_PATH.exists(): + raise HTTPException(status_code=404, detail=f"Database tidak ditemukan: {DB_PATH}") + try: + with sqlite3.connect(DB_PATH) as connection: + data = pd.read_sql_query( + f'SELECT "Tanggal", "{value_column}" FROM "{TABLE_NAME}" ORDER BY "Tanggal"', + connection, + ) + except (sqlite3.Error, pd.errors.DatabaseError) as error: + raise HTTPException(status_code=400, detail=f"Gagal membaca database: {error}") from error + data = clean_data(data, value_column) + if data.empty: + raise HTTPException(status_code=422, detail="Tidak ada data numerik yang dapat digunakan.") + if data[value_column].isna().any(): + raise HTTPException(status_code=422, detail="Data masih memiliki nilai kosong setelah preprocessing.") + return data + + +def read_all_data() -> pd.DataFrame: + """Read every column and row from SQLite without preprocessing.""" + # Keep the raw database response unchanged for GET /data. + if not DB_PATH.exists(): + raise HTTPException(status_code=404, detail=f"Database tidak ditemukan: {DB_PATH}") + try: + with sqlite3.connect(DB_PATH) as connection: + return pd.read_sql_query( + f'SELECT * FROM "{TABLE_NAME}"', + connection, + ) + except (sqlite3.Error, pd.errors.DatabaseError) as error: + raise HTTPException(status_code=400, detail=f"Gagal membaca database: {error}") from error + + +def preprocess_all_data(data: pd.DataFrame) -> pd.DataFrame: + """Clean dates and numeric columns, then add a sequential index.""" + # Validate the complete schema before applying transformations. + required_columns = {"Tanggal", *VALUE_COLUMNS} + missing_columns = required_columns.difference(data.columns) + if missing_columns: + raise HTTPException( + status_code=422, + detail=f"Kolom wajib tidak ditemukan: {', '.join(sorted(missing_columns))}", + ) + + data = data.copy() + data["Tanggal"] = pd.to_datetime(data["Tanggal"], errors="coerce") + for column in VALUE_COLUMNS: + data[column] = pd.to_numeric(data[column], errors="coerce") + data = ( + # Keep the latest record for duplicate dates after chronological sorting. + data + .dropna(subset=["Tanggal"]) + .sort_values("Tanggal") + .drop_duplicates(subset=["Tanggal"], keep="last") + .reset_index(drop=True) + ) + if data.empty: + raise HTTPException(status_code=422, detail="Tidak ada data dengan tanggal valid.") + + for column in VALUE_COLUMNS: + # Fill missing values first so IQR bounds are calculated from usable data. + data[column] = ( + data[column] + .interpolate(method="linear", limit_direction="both") + .bfill() + .ffill() + ) + q1 = data[column].quantile(0.25) + q3 = data[column].quantile(0.75) + iqr = q3 - q1 + lower_bound = q1 - 1.5 * iqr + upper_bound = q3 + 1.5 * iqr + data.loc[ + (data[column] < lower_bound) | (data[column] > upper_bound), + column, + ] = np.nan + # Fill values identified as outliers and verify that no missing values remain. + data[column] = data[column].interpolate( + method="linear", limit_direction="both" + ).bfill().ffill() + if data[column].isna().any(): + raise HTTPException( + status_code=422, + detail=f"Kolom {column} tidak memiliki nilai valid untuk mengisi missing value.", + ) + + data["Tanggal"] = data["Tanggal"].dt.date.astype(str) + data.insert(0, "index", range(len(data))) + return data + + +def records_from_dataframe(data: pd.DataFrame) -> list[dict[str, Any]]: + """Convert a dataframe to JSON-safe records, including null values.""" + records = data.astype(object).where(pd.notna(data), None).to_dict(orient="records") + for record in records: + for key, value in record.items(): + if isinstance(value, (np.integer, np.floating)): + record[key] = value.item() + return records + + +def _click_scraping_tree_item(driver: webdriver.Chrome, wait: WebDriverWait, + tree_id: str, item_name: str) -> None: + def find_checkbox(browser: webdriver.Chrome): + rows = browser.find_elements(By.CSS_SELECTOR, f"#{tree_id} tr.dx-data-row") + for row in rows: + if row.text.replace("\u2003", " ").strip() == item_name: + return row.find_element(By.CSS_SELECTOR, ".dx-select-checkbox") + return False + + checkbox = wait.until(find_checkbox) + if checkbox.get_attribute("aria-checked") != "true": + driver.execute_script( + "arguments[0].scrollIntoView({block: 'center'});", checkbox + ) + checkbox.click() + wait.until(lambda browser: checkbox.get_attribute("aria-checked") == "true") + + +def _set_scraping_date(driver: webdriver.Chrome, date_box_id: str, value: date) -> None: + driver.execute_script( + "window.jQuery(arguments[0]).dxDateBox('instance').option('value', arguments[1]);", + f"#{date_box_id}", + value.isoformat(), + ) + + +def _read_scraped_prices(driver: webdriver.Chrome) -> pd.DataFrame: + header_row = next( + row for row in driver.find_elements( + By.CSS_SELECTOR, ".dx-datagrid-headers tr.dx-header-row" + ) + if any( + "/" in (cell.get_attribute("aria-label") or "") + for cell in row.find_elements(By.CSS_SELECTOR, 'td[role="columnheader"]') + ) + ) + headers = [ + (cell.get_attribute("aria-label") or "").replace("Column ", "").strip() + for cell in header_row.find_elements(By.CSS_SELECTOR, 'td[role="columnheader"]') + if (cell.get_attribute("aria-label") or "").startswith("Column ") + ] + label_rows: list[list[str]] = [] + value_rows: list[list[str]] = [] + for table in driver.find_elements(By.CSS_SELECTOR, ".dx-datagrid-rowsview table"): + rows = table.find_elements(By.CSS_SELECTOR, "tbody tr.dx-data-row") + parsed_rows = [ + [cell.text.replace("\u2003", " ").strip() for cell in row.find_elements(By.CSS_SELECTOR, "td")] + for row in rows + ] + if any( + any(commodity in " ".join(row) for commodity in SCRAPING_COMMODITIES) + for row in parsed_rows + ): + label_rows = parsed_rows + elif parsed_rows: + value_rows = parsed_rows + + if not headers or not label_rows or not value_rows: + raise RuntimeError("Tabel harga belum menampilkan data.") + + records: list[dict[str, str]] = [] + for label_row, value_row in zip(label_rows, value_rows): + if len(label_row) < 2: + continue + commodity = label_row[1] + commodity = commodity.split(" ", 1)[1] if commodity[:1].isdigit() else commodity + if commodity not in SCRAPING_COMMODITIES: + continue + for header, value in zip(headers[2:], value_row[2:]): + if value and value != "-": + records.append({"komoditas": commodity, "tanggal": header, "harga": value}) + return pd.DataFrame(records) + + +def scrape_missing_prices(start_date: date, end_date: date) -> pd.DataFrame: + options = Options() + options.add_argument("--headless=new") + options.add_argument("--window-size=1920,1080") + driver = webdriver.Chrome(options=options) + wait = WebDriverWait(driver, 30) + try: + driver.get(SCRAPING_URL) + wait.until(lambda browser: browser.find_element(By.TAG_NAME, "body")) + for commodity in SCRAPING_COMMODITIES: + _click_scraping_tree_item(driver, wait, "CommodityTree", commodity) + _click_scraping_tree_item(driver, wait, "cboProvince", SCRAPING_PROVINCE) + wait.until( + lambda browser: browser.find_elements( + By.CSS_SELECTOR, "#cboRegency tr.dx-data-row" + ) + ) + _click_scraping_tree_item(driver, wait, "cboRegency", SCRAPING_REGENCY) + _set_scraping_date(driver, "dboDateMulai", start_date) + _set_scraping_date(driver, "dboDateSelesai", end_date) + driver.find_element(By.ID, "btnReport").click() + wait.until( + lambda browser: browser.find_elements( + By.CSS_SELECTOR, + ".dx-datagrid-rowsview table tbody tr.dx-data-row", + ) + ) + time.sleep(1) + return _read_scraped_prices(driver) + finally: + driver.quit() + + +def merge_scraped_prices(existing: pd.DataFrame, scraped: pd.DataFrame) -> pd.DataFrame: + expected_columns = ["Tanggal", "Harga Medium 1", "Harga Medium 2", "rata2"] + current = existing[expected_columns].copy() + current["Tanggal"] = pd.to_datetime(current["Tanggal"], errors="coerce") + current = current.dropna(subset=["Tanggal"]).set_index("Tanggal") + if not scraped.empty: + incoming = scraped.copy() + incoming["Tanggal"] = pd.to_datetime( + incoming["tanggal"].astype(str).str.replace(" ", "", regex=False), + format="%d/%m/%Y", + errors="coerce", + ) + incoming["harga"] = pd.to_numeric( + incoming["harga"].astype(str) + .str.replace(",", "", regex=False) + .str.replace("Rp", "", regex=False) + .str.strip(), + errors="coerce", + ) + incoming = incoming.dropna(subset=["Tanggal", "harga"]) + new_values = incoming.pivot_table( + index="Tanggal", + columns="komoditas", + values="harga", + aggfunc="last", + ).rename(columns={ + "Beras Kualitas Medium I": "Harga Medium 1", + "Beras Kualitas Medium II": "Harga Medium 2", + }) + new_values = new_values.reindex(columns=["Harga Medium 1", "Harga Medium 2"]) + current = pd.concat([current.drop(index=new_values.index, errors="ignore"), new_values]) + + if current.empty: + raise HTTPException(status_code=422, detail="Tidak ada data tanggal yang valid.") + current = current.sort_index() + full_dates = pd.date_range(current.index.min(), current.index.max(), freq="D") + current = current.reindex(full_dates) + current.index.name = "Tanggal" + price_columns = ["Harga Medium 1", "Harga Medium 2"] + current[price_columns] = current[price_columns].interpolate( + method="linear", limit_direction="both" + ) + current["rata2"] = current[price_columns].mean(axis=1) + return current.reset_index()[expected_columns] + + +def update_database_from_scraping() -> dict[str, Any]: + existing = read_all_data() + if existing.empty: + raise HTTPException(status_code=422, detail="Database tidak memiliki data awal.") + existing["Tanggal"] = pd.to_datetime(existing["Tanggal"], errors="coerce") + existing = existing.dropna(subset=["Tanggal"]) + last_date = existing["Tanggal"].max().date() + today = date.today() + allowed_date = today if datetime.now().hour >= 13 else today - timedelta(days=1) + if last_date >= allowed_date: + return { + "status": "up_to_date", + "message": "Data sudah diperbarui sampai batas waktu yang diizinkan.", + "last_date": last_date.isoformat(), + "allowed_date": allowed_date.isoformat(), + "updated_rows": 0, + } + + start_date = last_date + timedelta(days=1) + scraped = scrape_missing_prices(start_date, allowed_date) + if scraped.empty: + raise HTTPException(status_code=502, detail="Situs sumber tidak mengembalikan data.") + merged = merge_scraped_prices(existing, scraped) + with sqlite3.connect(DB_PATH) as connection: + merged.to_sql(TABLE_NAME, connection, if_exists="replace", index=False) + return { + "status": "updated", + "message": "Data berhasil diperbarui dari situs sumber.", + "start_date": start_date.isoformat(), + "last_date": last_date.isoformat(), + "allowed_date": allowed_date.isoformat(), + "updated_rows": len(scraped), + "total_rows": len(merged), + } + + +def dataset(values: np.ndarray, input_size: int) -> tuple[np.ndarray, np.ndarray]: + # Convert the time series into sliding windows and next-step targets. + if len(values) <= input_size: + raise ValueError(f"Minimal {input_size + 1} data diperlukan.") + inputs = np.array([values[i:i + input_size] for i in range(len(values) - input_size)]) + targets = np.array([values[i + input_size] for i in range(len(values) - input_size)])[:, None] + return inputs, targets + + +class ForecastRequest(BaseModel): + horizon: int = Field(default=7, ge=1, le=90) + value_column: Literal["rata2", "Harga Medium 1", "Harga Medium 2"] = "rata2" + + +class RetrainRequest(BaseModel): + train_ratio: float = Field(default=0.8, gt=0.5, lt=1.0) + epochs: int = Field(default=20, ge=1, le=1000) + batch_size: int = Field(default=32, ge=1, le=1024) + learning_rate: float = Field(default=0.0001, gt=0, le=1) + value_column: Literal["rata2", "Harga Medium 1", "Harga Medium 2"] = "rata2" + + +app = FastAPI(title="N-BEATS Forecast API", version="1.0.0") +HYPERPARAMETERS = load_hyperparameters() +MODEL = build_model(HYPERPARAMETERS) +CHECKPOINT: dict[str, Any] | None = None + + +def load_saved_model() -> None: + global CHECKPOINT + if not MODEL_PATH.exists(): + return + # Restore model weights and preprocessing scaler from the saved checkpoint. + checkpoint = torch.load(MODEL_PATH, map_location=DEVICE, weights_only=False) + MODEL.load_state_dict(checkpoint["model_state_dict"]) + MODEL.eval() + CHECKPOINT = checkpoint + + +try: + load_saved_model() +except Exception as error: + raise RuntimeError(f"Checkpoint tidak kompatibel dengan arsitektur model: {error}") from error + + +def scaler_from_checkpoint(data: pd.DataFrame, value_column: str) -> MinMaxScaler: + # Reuse the training scaler when it matches the requested value column. + scaler = MinMaxScaler() + saved = CHECKPOINT.get("scaler") if CHECKPOINT else None + if saved and saved.get("value_column") == value_column: + scaler.min_ = np.asarray(saved["min_"], dtype=float) + scaler.scale_ = np.asarray(saved["scale_"], dtype=float) + scaler.data_min_ = np.asarray(saved["data_min_"], dtype=float) + scaler.data_max_ = np.asarray(saved["data_max_"], dtype=float) + scaler.data_range_ = np.asarray(saved["data_range_"], dtype=float) + scaler.n_features_in_ = 1 + scaler.n_samples_seen_ = int(saved.get("n_samples_seen_", len(data))) + return scaler + scaler.fit(data[[value_column]].to_numpy()) + return scaler + + +# Endpoint GET /data: mengambil seluruh data mentah dari SQLite. +@app.get("/data") +def all_data() -> dict[str, Any]: + """Return all rows and columns exactly as stored in SQLite.""" + # Serialize the raw dataframe into JSON-safe records for the website. + data = read_all_data() + return {"total_rows": len(data), "columns": list(data.columns), "rows": records_from_dataframe(data)} + + +# Endpoint GET /data/preprocessed: mengambil seluruh data setelah preprocessing. +@app.get("/data/preprocessed") +def preprocess_data() -> dict[str, Any]: + """Return the complete dataset after date, missing-value, and outlier handling.""" + # Apply the complete cleaning pipeline before returning website data. + data = preprocess_all_data(read_all_data()) + return { + "total_rows": len(data), + "columns": list(data.columns), + "rows": records_from_dataframe(data), + } + + +# Endpoint POST /data/update: mengambil data terbaru dan memperbarui SQLite. +@app.post("/data/update") +def update_data() -> dict[str, Any]: + # Serialize updates so concurrent requests cannot overwrite each other. + if not DB_PATH.exists(): + raise HTTPException(status_code=404, detail=f"Database tidak ditemukan: {DB_PATH}") + if not UPDATE_LOCK.acquire(blocking=False): + raise HTTPException(status_code=409, detail="Proses update data sedang berjalan.") + try: + try: + return update_database_from_scraping() + except HTTPException: + raise + except Exception as error: + raise HTTPException( + status_code=502, + detail=f"Gagal mengambil atau menyimpan data scraping: {error}", + ) from error + finally: + UPDATE_LOCK.release() + + +# Endpoint POST /forecast: menghasilkan prediksi harga menggunakan model N-BEATS. +@app.post("/forecast") +def forecast(request: ForecastRequest) -> dict[str, Any]: + # Normalize the latest 90 calendar days, predict one step repeatedly, and inverse-transform results. + data = read_data(request.value_column) + last_available_date = data["Tanggal"].iloc[-1] + first_forecast_date = last_available_date - timedelta(days=FORECAST_HISTORY_DAYS - 1) + data = data[data["Tanggal"] >= first_forecast_date].reset_index(drop=True) + input_size = int(HYPERPARAMETERS["input_size"]) + if len(data) < input_size: + raise HTTPException( + status_code=422, + detail=( + f"Minimal {input_size} data valid dari {FORECAST_HISTORY_DAYS} hari terakhir " + "diperlukan." + ), + ) + scaler = scaler_from_checkpoint(data, request.value_column) + history = scaler.transform(data[[request.value_column]].to_numpy()).ravel().tolist() + predictions: list[float] = [] + with MODEL_LOCK, torch.no_grad(): + MODEL.eval() + for _ in range(request.horizon): + # Use the latest input window so each prediction feeds the next one. + values = torch.tensor([history[-input_size:]], dtype=torch.float32, device=DEVICE) + predicted = float(MODEL(values)[0][0, 0].cpu()) + history.append(predicted) + predictions.append(float(scaler.inverse_transform([[predicted]])[0, 0])) + last_date = data["Tanggal"].iloc[-1].date() + return {"value_column": request.value_column, "horizon": request.horizon, + "forecast": [{"date": (last_date + timedelta(days=i)).isoformat(), "value": value} + for i, value in enumerate(predictions, start=1)]} + + +# Endpoint POST /retrain: melatih ulang model dan menyimpan checkpoint terbaru. +@app.post("/retrain") +def retrain(request: RetrainRequest) -> dict[str, Any]: + # Prepare sliding-window training and validation data from the selected series. + data = read_data(request.value_column) + input_size = int(HYPERPARAMETERS["input_size"]) + scaler = MinMaxScaler().fit(data[[request.value_column]].iloc[: int(len(data) * request.train_ratio)]) + scaled = scaler.transform(data[[request.value_column]]).ravel() + inputs, targets = dataset(scaled, input_size) + split_index = max(1, int(len(inputs) * request.train_ratio)) + if split_index >= len(inputs): + raise HTTPException(status_code=422, detail="Split menghasilkan data validasi kosong.") + train_inputs, val_inputs = inputs[:split_index], inputs[split_index:] + train_targets, val_targets = targets[:split_index], targets[split_index:] + train_loader = DataLoader(TensorDataset(torch.tensor(train_inputs, dtype=torch.float32), + torch.tensor(train_targets, dtype=torch.float32)), + batch_size=request.batch_size, shuffle=False) + model = build_model(HYPERPARAMETERS) + optimizer = torch.optim.AdamW(model.parameters(), lr=request.learning_rate, weight_decay=1e-4) + loss_fn = nn.MSELoss() + history: list[dict[str, float]] = [] + for epoch in range(request.epochs): + # Optimize model weights on training batches, then measure validation loss. + model.train() + train_loss = 0.0 + for batch_inputs, batch_targets in train_loader: + batch_inputs, batch_targets = batch_inputs.to(DEVICE), batch_targets.to(DEVICE) + prediction, backcasts, block_inputs = model(batch_inputs) + loss = loss_fn(prediction, batch_targets) + loss = loss + float(HYPERPARAMETERS.get("alpha", 0.1)) * sum( + loss_fn(backcast, block_input) for backcast, block_input in zip(backcasts, block_inputs) + ) / len(backcasts) + optimizer.zero_grad() + loss.backward() + optimizer.step() + train_loss += loss.item() * len(batch_inputs) + model.eval() + with torch.no_grad(): + validation_prediction = model(torch.tensor(val_inputs, dtype=torch.float32, device=DEVICE))[0] + validation_loss = loss_fn(validation_prediction, torch.tensor(val_targets, dtype=torch.float32, device=DEVICE)).item() + history.append({"epoch": epoch + 1, "train_loss": train_loss / len(train_inputs), "validation_loss": validation_loss}) + checkpoint = {"model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), + "epoch": request.epochs, "best_val_loss": history[-1]["validation_loss"], + "hyperparameters": HYPERPARAMETERS, "scaler": {"value_column": request.value_column, + **{key: getattr(scaler, key).tolist() for key in ("min_", "scale_", "data_min_", "data_max_", "data_range_")}, + "n_samples_seen_": int(scaler.n_samples_seen_)}} + temporary_path = MODEL_PATH.with_suffix(".tmp") + with MODEL_LOCK: + # Atomically replace the checkpoint and activate the newly trained model. + torch.save(checkpoint, temporary_path) + temporary_path.replace(MODEL_PATH) + MODEL.load_state_dict(model.state_dict()) + MODEL.eval() + globals()["CHECKPOINT"] = checkpoint + return {"message": "Model berhasil diretrain.", "train_rows": len(train_inputs), + "validation_rows": len(val_inputs), "train_ratio": request.train_ratio, + "epochs": request.epochs, "final_validation_loss": history[-1]["validation_loss"]} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000, reload=False) diff --git a/Web/.gitignore b/Web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/Web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/Web/.oxlintrc.json b/Web/.oxlintrc.json new file mode 100644 index 0000000..1255078 --- /dev/null +++ b/Web/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/Web/README.md b/Web/README.md new file mode 100644 index 0000000..d937833 --- /dev/null +++ b/Web/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project. diff --git a/Web/index.html b/Web/index.html new file mode 100644 index 0000000..8766781 --- /dev/null +++ b/Web/index.html @@ -0,0 +1,13 @@ + + + + + + + web + + +
+ + + diff --git a/Web/package-lock.json b/Web/package-lock.json new file mode 100644 index 0000000..35dbe10 --- /dev/null +++ b/Web/package-lock.json @@ -0,0 +1,1677 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "lucide-react": "^1.50.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "recharts": "^3.10.1" + }, + "devDependencies": { + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "vite": "^8.3.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.86.0.tgz", + "integrity": "sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.86.0.tgz", + "integrity": "sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.86.0.tgz", + "integrity": "sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.86.0.tgz", + "integrity": "sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.86.0.tgz", + "integrity": "sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.86.0.tgz", + "integrity": "sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.86.0.tgz", + "integrity": "sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.86.0.tgz", + "integrity": "sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.86.0.tgz", + "integrity": "sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.86.0.tgz", + "integrity": "sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.86.0.tgz", + "integrity": "sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.86.0.tgz", + "integrity": "sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.86.0.tgz", + "integrity": "sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.86.0.tgz", + "integrity": "sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.86.0.tgz", + "integrity": "sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.86.0.tgz", + "integrity": "sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.86.0.tgz", + "integrity": "sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.86.0.tgz", + "integrity": "sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.86.0.tgz", + "integrity": "sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz", + "integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.50.0.tgz", + "integrity": "sha512-RqHPQtKX6S9IE1xOL4oTHHsVQbcwJWTnmlxI6AdI9IpOwKiy7q+COPCQyMepI/Qu1x3h7sunpyEMvqtgFbgNiQ==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.86.0.tgz", + "integrity": "sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.86.0", + "@oxlint/binding-android-arm64": "1.86.0", + "@oxlint/binding-darwin-arm64": "1.86.0", + "@oxlint/binding-darwin-x64": "1.86.0", + "@oxlint/binding-freebsd-x64": "1.86.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.86.0", + "@oxlint/binding-linux-arm-musleabihf": "1.86.0", + "@oxlint/binding-linux-arm64-gnu": "1.86.0", + "@oxlint/binding-linux-arm64-musl": "1.86.0", + "@oxlint/binding-linux-ppc64-gnu": "1.86.0", + "@oxlint/binding-linux-riscv64-gnu": "1.86.0", + "@oxlint/binding-linux-riscv64-musl": "1.86.0", + "@oxlint/binding-linux-s390x-gnu": "1.86.0", + "@oxlint/binding-linux-x64-gnu": "1.86.0", + "@oxlint/binding-linux-x64-musl": "1.86.0", + "@oxlint/binding-openharmony-arm64": "1.86.0", + "@oxlint/binding-win32-arm64-msvc": "1.86.0", + "@oxlint/binding-win32-ia32-msvc": "1.86.0", + "@oxlint/binding-win32-x64-msvc": "1.86.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/Web/package.json b/Web/package.json new file mode 100644 index 0000000..d1a9faf --- /dev/null +++ b/Web/package.json @@ -0,0 +1,25 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^1.50.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "recharts": "^3.10.1" + }, + "devDependencies": { + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "vite": "^8.3.0" + } +} diff --git a/Web/public/favicon.svg b/Web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/Web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Web/public/icons.svg b/Web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/Web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Web/src/App.css b/Web/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/Web/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/Web/src/App.jsx b/Web/src/App.jsx new file mode 100644 index 0000000..d94d87b --- /dev/null +++ b/Web/src/App.jsx @@ -0,0 +1,695 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, + Legend, ResponsiveContainer, ReferenceLine +} from 'recharts'; +import { + AlertCircle, TrendingUp, RefreshCw, CheckCircle2, XCircle, WifiOff, + LineChart as LineChartIcon, Table2 +} from 'lucide-react'; + +// ─── Toast Notification Component ───────────────────────────────────────── +function Toast({ toasts, onDismiss }) { + if (!toasts.length) return null; + return ( +
+ {toasts.map(t => ( +
+ + {t.type === 'success' && } + {t.type === 'error' && } + {t.type === 'warning' && } + +
+ {t.title} + {t.message && {t.message}} +
+ +
+ ))} +
+ ); +} + +const API_BASE_URL = '/api'; + +// ─── Helpers ─────────────────────────────────────────────────────────────── +const formatRp = (v) => + v != null ? `Rp ${Number(v).toLocaleString('id-ID', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}` : '-'; + +const normalizeForecastValue = (value) => { + const numericValue = Number(value); + if (!Number.isFinite(numericValue) || numericValue < 1) return null; + return Math.trunc(numericValue); +}; + +const subDays = (dateStr, n) => { + const d = new Date(dateStr); + d.setDate(d.getDate() - n); + return d.toISOString().split('T')[0]; +}; + +// ─── Custom Tooltip ──────────────────────────────────────────────────────── +const CustomTooltip = ({ active, payload, label }) => { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+ {payload.map((entry, i) => ( +

+ {entry.name}: + {formatRp(entry.value)} +

+ ))} +
+ ); +}; + +// ─── Quick-filter pill buttons ───────────────────────────────────────────── +const QUICK_FILTERS = [ + { label: '1 Minggu', days: 7 }, + { label: '1 Bulan', days: 30 }, + { label: '6 Bulan', days: 180 }, + { label: '1 Tahun', days: 365 }, + { label: 'Semua', days: null }, +]; + +// ─── Raw Data Chart Section ──────────────────────────────────────────────── +function RawDataChart({ allRawData, onUpdateData, isUpdating, updateResult, onDismissUpdate }) { + const [activeFilter, setActiveFilter] = useState('1 Tahun'); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [useCustomRange, setUseCustomRange] = useState(false); + const [viewMode, setViewMode] = useState('chart'); // 'chart' | 'table' + const [trendFilter, setTrendFilter] = useState('all'); // 'all' | 'up' | 'down' + const [tablePage, setTablePage] = useState(1); + const TABLE_PAGE_SIZE = 20; + + const latestDate = allRawData.length > 0 ? allRawData[allRawData.length - 1].date : null; + + const applyQuickFilter = (filter) => { + setActiveFilter(filter.label); + setUseCustomRange(false); + setStartDate(''); + setEndDate(''); + setTablePage(1); + }; + + const applyCustomRange = () => { + if (startDate && endDate) { setUseCustomRange(true); setTablePage(1); } + }; + + const clearCustomRange = () => { + setUseCustomRange(false); + setStartDate(''); + setEndDate(''); + setActiveFilter('1 Tahun'); + setTablePage(1); + }; + + const applyTrendFilter = (filter) => { + setTrendFilter(filter); + setTablePage(1); + }; + + const filteredData = useMemo(() => { + if (allRawData.length === 0) return []; + if (useCustomRange && startDate && endDate) { + return allRawData.filter(d => d.date >= startDate && d.date <= endDate); + } + const filter = QUICK_FILTERS.find(f => f.label === activeFilter); + if (!filter || filter.days === null) return allRawData; + const cutoff = subDays(latestDate, filter.days); + return allRawData.filter(d => d.date >= cutoff); + }, [allRawData, activeFilter, useCustomRange, startDate, endDate, latestDate]); + + // Table pagination – show newest first + const sortedDesc = useMemo(() => [...filteredData].reverse(), [filteredData]); + const tableRows = useMemo(() => sortedDesc.map((row, index) => { + const curr = row['Rata-rata']; + const prev = sortedDesc[index + 1]?.['Rata-rata']; + return { + ...row, + delta: curr == null || prev == null ? null : curr - prev, + previousAverage: prev, + }; + }), [sortedDesc]); + const visibleRows = useMemo(() => { + if (trendFilter === 'up') return tableRows.filter(row => row.delta > 0); + if (trendFilter === 'down') return tableRows.filter(row => row.delta < 0); + return tableRows; + }, [tableRows, trendFilter]); + const totalPages = Math.max(1, Math.ceil(visibleRows.length / TABLE_PAGE_SIZE)); + const pagedRows = visibleRows.slice((tablePage - 1) * TABLE_PAGE_SIZE, tablePage * TABLE_PAGE_SIZE); + const globalStart = visibleRows.length - (tablePage - 1) * TABLE_PAGE_SIZE; // row number (desc) + + return ( +
+
+
+
+

Data Historis Harga

+

Harga Medium 1 · Harga Medium 2 · Rata-rata

+
+
+ +
+ {/* ── View Toggle ── */} +
+ + +
+ + {/* ── Update Data Button ── */} + +
+
+ + {/* ── Progress Bar ── */} + {isUpdating && ( +
+
+ + + Mengambil data dari situs sumber, harap tunggu... + +
+ )} + + {/* ── Update Result Banner ── */} + {updateResult && ( +
+ + {updateResult.status === 'updated' ? '✅' : 'ℹ️'} + +
+ {updateResult.message} + + {updateResult.status === 'updated' + ? `+${updateResult.updated_rows} baris baru · Total: ${updateResult.total_rows} baris · s/d ${updateResult.last_date}` + : `Data sudah terkini s/d ${updateResult.last_date}` + } + +
+ +
+ )} + + {/* ── Filter Bar (only relevant for chart view) ── */} +
+
+ {QUICK_FILTERS.map(f => ( + + ))} +
+ +
+
+ + setStartDate(e.target.value)} /> +
+
+ + setEndDate(e.target.value)} /> +
+ + {useCustomRange && ( + + )} +
+
+ + {/* ══ CHART VIEW ══ */} + {viewMode === 'chart' && ( + <> +
+ {filteredData.length > 0 ? ( + + + + + `Rp ${(v / 1000).toFixed(0)}k`} domain={['auto', 'auto']} /> + } /> + + + + + + + ) : ( +
+ Tidak ada data pada rentang yang dipilih. +
+ )} +
+ {filteredData.length > 0 && ( +

+ Menampilkan {filteredData.length} hari data + {filteredData[0]?.date && ` · ${filteredData[0].date}`} + {filteredData[filteredData.length - 1]?.date && ` — ${filteredData[filteredData.length - 1].date}`} +

+ )} + + )} + + {/* ══ TABLE VIEW ══ */} + {viewMode === 'table' && ( +
+
+
+ Perubahan harian +
+ {[ + { value: 'all', label: 'Semua' }, + { value: 'up', label: '▲ Naik' }, + { value: 'down', label: '▼ Turun' }, + ].map(filter => ( + + ))} +
+
+
+ {visibleRows.length > 0 ? ( + <> +
+ + + + + + + + + + + + + {pagedRows.map((row, i) => { + const delta = row.delta; + const isUp = delta > 0; + const isDown = delta < 0; + return ( + + + + + + + + + ); + })} + +
#TanggalHarga Medium 1Harga Medium 2Rata-rataΔ Harian
{globalStart - i}{row.date}{formatRp(row['Harga Medium 1'])}{formatRp(row['Harga Medium 2'])}{formatRp(row['Rata-rata'])} + {delta == null ? ( + — + ) : !isUp && !isDown ? ( + Tetap + ) : ( + + {isUp ? '▲' : '▼'} + {' '}{formatRp(Math.abs(delta))} + {' '}({delta >= 0 ? '+' : ''}{((delta / (row.previousAverage || 1)) * 100).toFixed(2)}%) + + )} +
+
+ + {/* Pagination */} +
+ + {sortedDesc.length} baris · halaman {tablePage} dari {totalPages} + +
+ + + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const offset = Math.min(Math.max(tablePage - 3, 1), Math.max(totalPages - 4, 1)); + const pg = offset + i; + return pg <= totalPages ? ( + + ) : null; + })} + + +
+
+ + ) : ( +
+ Tidak ada data pada rentang yang dipilih. +
+ )} +
+ )} +
+ ); +} + +// ─── Main App ────────────────────────────────────────────────────────────── +function App() { + const [rawData, setRawData] = useState([]); // all rows for raw chart + const [data, setData] = useState([]); // mapped { date, actual } + const [forecast, setForecast] = useState([]); + const [loading, setLoading] = useState(true); + const [isForecasting, setIsForecasting] = useState(false); + const [error, setError] = useState(null); + const [horizon, setHorizon] = useState(7); + const [targetColumn, setTargetColumn] = useState('rata2'); + const [isUpdating, setIsUpdating] = useState(false); + const [updateResult, setUpdateResult] = useState(null); + const [toasts, setToasts] = useState([]); + const toastTimers = useRef({}); + + const showToast = useCallback((type, title, message, duration = 6000) => { + const id = Date.now(); + setToasts(prev => [...prev, { id, type, title, message }]); + if (duration > 0) { + toastTimers.current[id] = setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)); + delete toastTimers.current[id]; + }, duration); + } + return id; + }, []); + + const dismissToast = useCallback((id) => { + clearTimeout(toastTimers.current[id]); + delete toastTimers.current[id]; + setToasts(prev => prev.filter(t => t.id !== id)); + }, []); + + + const fetchAllData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const res = await fetch(`${API_BASE_URL}/data/preprocessed`); + if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); + const result = await res.json(); + + // raw chart rows – keep all three price columns + const raw = result.rows.map(row => ({ + date: row.Tanggal.split('T')[0], + 'Harga Medium 1': row['Harga Medium 1'], + 'Harga Medium 2': row['Harga Medium 2'], + 'Rata-rata': row['rata2'], + })); + setRawData(raw); + + // forecast chart rows + const mapped = result.rows.map(row => ({ + date: row.Tanggal.split('T')[0], + actual: row[targetColumn], + })); + setData(mapped); + } catch (err) { + console.error('Failed to fetch data:', err); + setError('Gagal memuat data dari server API. Pastikan server API aktif di http://127.0.0.1:8000.'); + } finally { + setLoading(false); + } + }, [targetColumn]); + + useEffect(() => { + fetchAllData(); + }, [fetchAllData]); + + const handleUpdateData = async () => { + try { + setIsUpdating(true); + setUpdateResult(null); + + const res = await fetch(`${API_BASE_URL}/data/update`, { method: 'POST' }); + const body = await res.json().catch(() => ({})); + + if (!res.ok) { + // 502 = situs sumber tidak mengembalikan data + if (res.status === 502) { + showToast( + 'warning', + 'Data Belum Tersedia', + 'Situs sumber (BI Harga Pangan) belum menyediakan data untuk tanggal yang diminta. Coba lagi nanti.', + 0 // tidak auto-dismiss + ); + } else { + showToast( + 'error', + 'Gagal Memperbarui Data', + body.detail || `HTTP ${res.status} – terjadi kesalahan pada server.`, + 0 + ); + } + return; + } + + setUpdateResult(body); + + if (body.status === 'updated' && body.updated_rows > 0) { + // Refresh grafik lalu tampilkan toast sukses + await fetchAllData(); + showToast( + 'success', + 'Data Berhasil Diperbarui', + `+${body.updated_rows} baris baru ditambahkan · Total: ${body.total_rows} baris · s/d ${body.last_date}`, + 7000 + ); + } else { + // up_to_date – tidak perlu refresh + showToast( + 'success', + 'Data Sudah Terkini', + `Data sudah tersedia s/d ${body.last_date}. Tidak ada baris baru.`, + 5000 + ); + } + } catch (err) { + console.error('Update error:', err); + showToast( + 'error', + 'Koneksi Gagal', + `Tidak dapat menghubungi server: ${err.message}`, + 0 + ); + } finally { + setIsUpdating(false); + } + }; + + const handleDismissUpdate = () => setUpdateResult(null); + + const handleForecast = async () => { + try { + setIsForecasting(true); + setError(null); + + const res = await fetch(`${API_BASE_URL}/forecast`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ horizon: parseInt(horizon), value_column: targetColumn }), + }); + if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); + const result = await res.json(); + setForecast(result.forecast); + } catch (err) { + console.error('Forecast error:', err); + setError('Gagal memuat prediksi. Pastikan server API berjalan dan dapat diakses.'); + } finally { + setIsForecasting(false); + } + }; + + // Combine last 90 days + forecast for the top chart + const chartData = useMemo(() => { + if (data.length === 0) return []; + const recent = data.slice(-90).map(d => ({ date: d.date, Actual: d.actual, Forecast: null })); + const validForecast = forecast + .map(f => ({ ...f, value: normalizeForecastValue(f.value) })) + .filter(f => f.value !== null); + + if (validForecast.length > 0) { + const last = recent[recent.length - 1]; + if (last) last.Forecast = last.Actual; + const fData = validForecast.map(f => ({ + date: f.date.split('T')[0], Actual: null, Forecast: f.value, + })); + return [...recent, ...fData]; + } + return recent; + }, [data, forecast]); + + const hasValidForecast = forecast.some(f => normalizeForecastValue(f.value) !== null); + const lastPrice = data.length > 0 ? data[data.length - 1].actual : 0; + const lastDate = data.length > 0 ? data[data.length - 1].date : '-'; + + return ( +
+ {/* ── Toast Notifications ── */} + + {loading && ( +
+
+

Memuat Data...

+

Mengambil data historis dari API

+
+ )} + +
+
+

Prediksi Harga Beras Medium Di Banyuwangi

+

Real-time time series prediction monitoring

+
+
+ + {error && ( +
+ + {error} +
+ )} + + {/* ── Stat Cards ── */} +
+
+
+

Total Data Historis

+

{data.length} hari

+
+
+
+
+

Harga Terakhir ({targetColumn})

+

{formatRp(lastPrice)}

+
+
+
+
+

Update Terakhir

+

{lastDate}

+
+
+
+ + {/* ── Forecast Chart ── */} +
+
+
+

Prediksi Harga

+

90 hari terakhir + forecast ke depan

+
+
+ +
+
+ + +
+
+ + setHorizon(e.target.value)} + disabled={isForecasting} + style={{ width: '120px' }} + /> +
+ +
+ +
+ {chartData.length > 0 ? ( + + + + + `Rp ${(v/1000).toFixed(0)}k`} domain={['auto','auto']} /> + } /> + + {hasValidForecast && ( + + )} + + + + + ) : ( +
+ Klik Generate Forecast untuk memulai prediksi. +
+ )} +
+
+ + {/* ── Raw Data Historical Chart ── */} + +
+ ); +} + +export default App; diff --git a/Web/src/assets/hero.png b/Web/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/Web/src/assets/hero.png differ diff --git a/Web/src/assets/react.svg b/Web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/Web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Web/src/assets/vite.svg b/Web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/Web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/Web/src/index.css b/Web/src/index.css new file mode 100644 index 0000000..9c9afe2 --- /dev/null +++ b/Web/src/index.css @@ -0,0 +1,1160 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +:root { + --bg-dark: #0f172a; + --bg-panel: rgba(30, 41, 59, 0.7); + --text-main: #f8fafc; + --text-muted: #94a3b8; + --accent: #3b82f6; + --accent-hover: #2563eb; + --border: rgba(255, 255, 255, 0.1); + --glow: 0 0 20px rgba(59, 130, 246, 0.5); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Inter', sans-serif; + background-color: var(--bg-dark); + color: var(--text-main); + min-height: 100vh; + background-image: + radial-gradient(circle at 15% 50%, rgba(59, 130, 246, 0.15), transparent 25%), + radial-gradient(circle at 85% 30%, rgba(139, 92, 246, 0.15), transparent 25%); + background-attachment: fixed; +} + +.dashboard-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +header { + margin-bottom: 2rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +h1 { + font-size: 2rem; + font-weight: 700; + background: linear-gradient(to right, #60a5fa, #a78bfa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.subtitle { + color: var(--text-muted); + font-size: 0.9rem; + margin-top: 0.25rem; +} + +.glass-panel { + background: var(--bg-panel); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: 1rem; + padding: 1.5rem; + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.glass-panel:hover { + transform: translateY(-2px); + box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.4); +} + +.controls { + display: flex; + gap: 1rem; + align-items: flex-end; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +label { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-muted); +} + +select, input { + background: rgba(15, 23, 42, 0.6); + border: 1px solid var(--border); + color: var(--text-main); + padding: 0.75rem 1rem; + border-radius: 0.5rem; + font-family: inherit; + font-size: 0.95rem; + outline: none; + transition: all 0.2s ease; +} + +select:focus, input:focus { + border-color: var(--accent); + box-shadow: var(--glow); +} + +button { + background: var(--accent); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + font-family: inherit; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + transition: all 0.2s ease; + height: 42px; /* Match input height */ +} + +button:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: var(--glow); +} + +button:disabled { + background: rgba(59, 130, 246, 0.5); + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.chart-container { + height: 400px; + width: 100%; + margin-top: 1rem; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-top: 2rem; +} + +.stat-card { + display: flex; + align-items: center; + gap: 1rem; +} + +.stat-icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: rgba(59, 130, 246, 0.1); + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; +} + +.stat-info h3 { + color: var(--text-muted); + font-size: 0.85rem; + font-weight: 500; +} + +.stat-info p { + font-size: 1.5rem; + font-weight: 700; + margin-top: 0.25rem; +} + +/* Custom Recharts Tooltip */ +.custom-tooltip { + background: rgba(15, 23, 42, 0.9); + border: 1px solid var(--border); + backdrop-filter: blur(4px); + padding: 1rem; + border-radius: 0.5rem; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); +} + +.tooltip-date { + color: var(--text-muted); + font-size: 0.85rem; + margin-bottom: 0.5rem; + font-weight: 600; +} + +.tooltip-value { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.95rem; +} + +.loading-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.8); + backdrop-filter: blur(4px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.spinner { + width: 50px; + height: 50px; + border: 4px solid rgba(255, 255, 255, 0.1); + border-left-color: var(--accent); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.error-message { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #fca5a5; + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* ── Raw chart panel ──────────────────────────────── */ +.raw-chart-panel .chart-container { + height: 380px; +} + +.raw-chart-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 0.25rem; +} + +.section-title { + font-size: 1.15rem; + font-weight: 700; + background: linear-gradient(to right, #60a5fa, #34d399); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +/* ── Filter bar ────────────────────────────────────── */ +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 1rem; + margin-top: 1.25rem; +} + +.filter-pills { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +.filter-label { + display: block; + margin-bottom: 0.45rem; + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 600; +} + +.pill-btn { + background: rgba(30, 41, 59, 0.8); + border: 1px solid var(--border); + color: var(--text-muted); + padding: 0.4rem 1rem; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + height: auto; + transition: all 0.2s ease; +} + +.pill-btn:hover { + border-color: var(--accent); + color: var(--text-main); + box-shadow: none; + transform: none; +} + +.pill-btn.active { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +/* ── Date range group ──────────────────────────────── */ +.date-range-group { + display: flex; + gap: 0.75rem; + align-items: flex-end; + flex-wrap: wrap; +} + +.date-range-group input[type="date"] { + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + color-scheme: dark; +} + +/* ── Data range info ───────────────────────────────── */ +.data-range-info { + margin-top: 0.75rem; + font-size: 0.82rem; + color: var(--text-muted); + text-align: right; +} + +/* ── View Toggle (Grafik / Tabel) ──────────────────── */ +.view-toggle { + display: flex; + background: rgba(15, 23, 42, 0.6); + border: 1px solid var(--border); + border-radius: 0.55rem; + padding: 3px; + gap: 3px; +} + +.view-toggle-btn { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.85rem; + border-radius: 0.4rem; + border: none; + background: transparent; + color: var(--text-muted); + font-size: 0.83rem; + font-weight: 500; + cursor: pointer; + height: auto; + transition: all 0.18s ease; + white-space: nowrap; +} + +.view-toggle-btn:hover:not(.active) { + background: rgba(255, 255, 255, 0.05); + color: var(--text-main); + transform: none; + box-shadow: none; +} + +/* ── Light dashboard theme ─────────────────────────── */ +:root { + --bg-dark: #f7f9fc; + --bg-panel: #ffffff; + --text-main: #252a34; + --text-muted: #7f8794; + --accent: #1683f8; + --accent-hover: #0875e1; + --border: #e4e9f0; + --glow: 0 0 0 3px rgba(22, 131, 248, 0.12); +} + +body { + background: var(--bg-dark); + color: var(--text-main); + background-image: none; +} + +.dashboard-container { + max-width: 1280px; + padding: 2.25rem 2.5rem 3.5rem; +} + +header { + margin-bottom: 1.75rem; + padding-bottom: 1.25rem; + border-bottom: 1px solid var(--border); +} + +h1 { + color: var(--text-main); + background: none; + -webkit-text-fill-color: initial; + font-size: 1.75rem; + letter-spacing: -0.03em; +} + +.subtitle { + color: #8b929c; +} + +.glass-panel { + background: var(--bg-panel); + backdrop-filter: none; + -webkit-backdrop-filter: none; + border: 1px solid var(--border); + border-radius: 0.55rem; + padding: 1.35rem; + box-shadow: 0 2px 8px rgba(35, 48, 67, 0.04); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.glass-panel:hover { + transform: none; + border-color: #d5dde8; + box-shadow: 0 4px 14px rgba(35, 48, 67, 0.07); +} + +.stats-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 0; +} + +.stat-card { + display: block; + min-height: 116px; +} + +.stat-info h3 { + color: #8b929c; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.045em; +} + +.stat-info p { + color: var(--text-main); + font-size: 1.55rem; + margin-top: 0.65rem; +} + +.controls { + gap: 0.8rem; + margin-bottom: 1.1rem; +} + +label, +.filter-label { + color: #747d89; +} + +select, +input { + background: #fff; + border: 1px solid #d8dfe8; + color: var(--text-main); + border-radius: 0.4rem; + padding: 0.65rem 0.8rem; +} + +select:focus, +input:focus { + border-color: var(--accent); + box-shadow: var(--glow); +} + +button { + background: var(--accent); + border-radius: 0.4rem; + box-shadow: none; +} + +button:hover { + background: var(--accent-hover); + transform: none; + box-shadow: none; +} + +.section-title { + color: var(--text-main); + font-size: 1.1rem; +} + +.raw-chart-panel { + margin-top: 1.25rem !important; +} + +.raw-chart-header { + margin-bottom: 0.5rem; +} + +.chart-container { + margin-top: 0.75rem; +} + +.custom-tooltip { + background: #fff; + border: 1px solid var(--border); + backdrop-filter: none; + box-shadow: 0 5px 16px rgba(35, 48, 67, 0.12); +} + +.tooltip-date { + color: #7f8794; +} + +.view-toggle { + background: #f4f6f9; + border: 1px solid var(--border); +} + +.view-toggle-btn { + color: #7f8794; +} + +.view-toggle-btn.active { + background: #fff; + color: var(--accent); + border-color: #b9d9fb; +} + +.pill-btn { + background: #fff; + border: 1px solid #d8dfe8; + color: #747d89; +} + +.pill-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.hist-table-scroll { + border-color: var(--border); +} + +.hist-table thead tr { + background: #f7f9fc; + border-bottom-color: var(--border); +} + +.hist-table tbody tr { + border-bottom-color: #eef1f5; +} + +.hist-table tbody tr:nth-child(even), +.hist-table tbody tr:nth-child(even):hover { + background: #fbfcfd; +} + +.hist-table tbody tr:hover { + background: #f3f8fe; +} + +.hist-table td { + color: #3e4652; +} + +.hist-table td.date-cell { + color: var(--accent); +} + +.hist-table td.avg-cell { + color: #168b78; +} + +.loading-overlay { + background: rgba(247, 249, 252, 0.92); + backdrop-filter: blur(3px); +} + +.error-message { + background: #fff5f5; + border-color: #f4c7c7; + color: #c44343; +} + +@media (max-width: 760px) { + .dashboard-container { + padding: 1.25rem 1rem 2rem; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + h1 { + font-size: 1.4rem; + } +} + +.view-toggle-btn.active { + background: rgba(59, 130, 246, 0.2); + color: #93c5fd; + border: 1px solid rgba(59, 130, 246, 0.35); + transform: none; + box-shadow: none; +} + +/* ── Historical Data Table ─────────────────────────── */ +.hist-table-wrap { + overflow: hidden; +} + +.hist-table-scroll { + overflow-x: auto; + border-radius: 0.65rem; + border: 1px solid var(--border); +} + +.hist-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.hist-table thead tr { + background: rgba(30, 41, 59, 0.9); + border-bottom: 1px solid var(--border); +} + +.hist-table th { + padding: 0.7rem 1rem; + text-align: right; + font-size: 0.78rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.hist-table th:first-child, +.hist-table th:nth-child(2) { + text-align: left; +} + +.hist-table tbody tr { + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + transition: background 0.15s; +} + +.hist-table tbody tr:last-child { + border-bottom: none; +} + +.hist-table tbody tr:hover { + background: rgba(59, 130, 246, 0.06); +} + +.hist-table tbody tr:nth-child(even) { + background: rgba(255, 255, 255, 0.02); +} + +.hist-table tbody tr:nth-child(even):hover { + background: rgba(59, 130, 246, 0.06); +} + +.hist-table td { + padding: 0.6rem 1rem; + color: var(--text-main); + text-align: right; + white-space: nowrap; +} + +.hist-table td.row-num { + text-align: left; + color: var(--text-muted); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + width: 3rem; +} + +.hist-table td.date-cell { + text-align: left; + font-variant-numeric: tabular-nums; + color: #93c5fd; + font-weight: 500; +} + +.hist-table td.avg-cell { + color: #34d399; + font-weight: 600; +} + +/* ── Delta Column ──────────────────────────────────── */ +.hist-table td.delta-cell { + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; + font-size: 0.82rem; +} + +.delta-badge { + display: inline-flex; + align-items: center; + gap: 0.2rem; + padding: 0.18rem 0.55rem; + border-radius: 999px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.delta-up .delta-badge { + background: rgba(52, 211, 153, 0.12); + color: #34d399; + border: 1px solid rgba(52, 211, 153, 0.25); +} + +.delta-down .delta-badge { + background: rgba(248, 113, 113, 0.12); + color: #f87171; + border: 1px solid rgba(248, 113, 113, 0.25); +} + +.delta-flat .delta-badge { + background: rgba(148, 163, 184, 0.08); + color: var(--text-muted); + border: 1px solid rgba(148, 163, 184, 0.15); +} + +.delta-null { + color: var(--text-muted); + opacity: 0.4; +} + +/* ── Table Pagination ──────────────────────────────── */ +.table-pagination { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.9rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +.table-info { + font-size: 0.8rem; + color: var(--text-muted); +} + +.table-pager { + display: flex; + gap: 0.3rem; +} + +.pager-btn { + background: rgba(30, 41, 59, 0.7); + border: 1px solid var(--border); + color: var(--text-muted); + padding: 0.3rem 0.65rem; + border-radius: 0.4rem; + font-size: 0.82rem; + font-weight: 500; + cursor: pointer; + height: auto; + min-width: 2rem; + transition: all 0.15s ease; +} + +.pager-btn:hover:not(:disabled):not(.active) { + border-color: var(--accent); + color: var(--text-main); + transform: none; + box-shadow: none; +} + +.pager-btn.active { + background: var(--accent); + border-color: var(--accent); + color: white; + transform: none; + box-shadow: none; +} + +.pager-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + transform: none; + box-shadow: none; + background: rgba(30, 41, 59, 0.7); +} + +/* ── Update Data Button ────────────────────────────── */ +.update-btn { + background: linear-gradient(135deg, rgba(52, 211, 153, 0.15), rgba(59, 130, 246, 0.15)); + border: 1px solid rgba(52, 211, 153, 0.4); + color: #34d399; + padding: 0.55rem 1.1rem; + border-radius: 0.6rem; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.45rem; + height: auto; + transition: all 0.25s ease; + white-space: nowrap; + flex-shrink: 0; +} + +.update-btn:hover:not(:disabled) { + background: linear-gradient(135deg, rgba(52, 211, 153, 0.25), rgba(59, 130, 246, 0.25)); + border-color: #34d399; + box-shadow: 0 0 14px rgba(52, 211, 153, 0.35); + transform: translateY(-1px); +} + +.update-btn:disabled, +.update-btn.updating { + opacity: 0.65; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Spinning icon for the refresh button */ +.spin-icon { + animation: spin 1s linear infinite; +} + +/* ── Update Result Banner ──────────────────────────── */ +.update-banner { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.9rem; + padding: 0.75rem 1rem; + border-radius: 0.6rem; + font-size: 0.88rem; + animation: bannerIn 0.3s ease; +} + +@keyframes bannerIn { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.update-banner--updated { + background: rgba(52, 211, 153, 0.1); + border: 1px solid rgba(52, 211, 153, 0.35); + color: #6ee7b7; +} + +.update-banner--up_to_date { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + color: #93c5fd; +} + +.update-banner-emoji { + font-size: 1.1rem; + flex-shrink: 0; +} + +.update-banner-body { + display: flex; + flex-direction: column; + gap: 0.15rem; + flex: 1; +} + +.update-banner-meta { + font-size: 0.8rem; + opacity: 0.75; +} + +.update-banner-close { + background: transparent; + border: none; + color: inherit; + font-size: 1.2rem; + line-height: 1; + cursor: pointer; + padding: 0 0.25rem; + height: auto; + opacity: 0.6; + transition: opacity 0.2s; + flex-shrink: 0; +} + +.update-banner-close:hover { + background: transparent; + opacity: 1; + transform: none; + box-shadow: none; +} + +/* ── Update Progress Bar ───────────────────────────── */ +.update-progress-wrap { + margin-top: 0.75rem; + overflow: hidden; + border-radius: 99px; + background: rgba(255, 255, 255, 0.06); + position: relative; +} + +.update-progress-bar { + height: 4px; + width: 40%; + background: linear-gradient(90deg, #34d399, #3b82f6, #a78bfa); + background-size: 200% 100%; + border-radius: 99px; + animation: progressSlide 1.6s ease-in-out infinite; +} + +@keyframes progressSlide { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(350%); } +} + +.update-progress-label { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.78rem; + color: var(--text-muted); + padding: 0.35rem 0.1rem 0; +} + +/* ── Toast Notifications ───────────────────────────── */ +.toast-container { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 0.65rem; + pointer-events: none; + max-width: 380px; + width: calc(100vw - 3rem); +} + +.toast { + display: flex; + align-items: flex-start; + gap: 0.65rem; + padding: 0.85rem 1rem; + border-radius: 0.75rem; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); + pointer-events: all; + animation: toastIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes toastIn { + from { opacity: 0; transform: translateY(20px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.toast--success { + background: rgba(16, 44, 34, 0.92); + border: 1px solid rgba(52, 211, 153, 0.45); + color: #6ee7b7; +} + +.toast--error { + background: rgba(50, 10, 18, 0.92); + border: 1px solid rgba(248, 113, 113, 0.45); + color: #fca5a5; +} + +.toast--warning { + background: rgba(44, 36, 6, 0.92); + border: 1px solid rgba(251, 191, 36, 0.45); + color: #fcd34d; +} + +.toast-icon { + flex-shrink: 0; + margin-top: 1px; +} + +.toast-body { + display: flex; + flex-direction: column; + gap: 0.2rem; + flex: 1; + font-size: 0.875rem; + line-height: 1.4; +} + +.toast-body strong { + font-size: 0.9rem; +} + +.toast-body span { + opacity: 0.8; + font-size: 0.82rem; +} + +.toast-close { + background: transparent; + border: none; + color: inherit; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + padding: 0; + height: auto; + opacity: 0.55; + transition: opacity 0.2s; + flex-shrink: 0; +} + +.toast-close:hover { + background: transparent; + opacity: 1; + transform: none; + box-shadow: none; +} + +/* ── Header and table navigation refinements ───────── */ +header { + margin: -0.75rem -1rem 1.5rem; + padding: 1rem 1rem 1.1rem; + background: #fff; + border: 1px solid var(--border); + border-radius: 0.55rem; + box-shadow: 0 2px 8px rgba(35, 48, 67, 0.035); +} + +header h1 { + font-size: 1.45rem; + font-weight: 650; + letter-spacing: -0.025em; +} + +header .subtitle { + margin-top: 0.4rem; + font-size: 0.82rem; +} + +.view-toggle { + background: #f4f7fb; + border: 1px solid #dce4ee; + border-radius: 0.45rem; + padding: 0.2rem; + gap: 0.15rem; +} + +.view-toggle-btn { + min-height: 32px; + padding: 0.4rem 0.8rem; + color: #707b89; + border-radius: 0.3rem; +} + +.view-toggle-btn:hover:not(.active) { + background: #eaf2fb; + color: var(--accent); +} + +.view-toggle-btn.active { + background: #fff; + border: 1px solid #c9ddf3; + color: var(--accent); + box-shadow: 0 1px 3px rgba(35, 48, 67, 0.08); +} + +.table-pagination { + margin-top: 1.1rem; + padding-top: 0.9rem; + border-top: 1px solid #edf0f4; +} + +.table-info { + color: #8a929e; +} + +.table-pager { + gap: 0.35rem; +} + +.pager-btn { + min-width: 32px; + height: 32px; + padding: 0.25rem 0.55rem; + background: #fff; + border: 1px solid #dce3ec; + border-radius: 0.35rem; + color: #697482; + font-size: 0.8rem; + box-shadow: none; +} + +.pager-btn:hover:not(:disabled):not(.active) { + background: #f0f7ff; + border-color: #a9ccef; + color: var(--accent); +} + +.pager-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; + box-shadow: 0 2px 5px rgba(22, 131, 248, 0.2); +} + +.pager-btn:disabled { + background: #f7f9fb; + border-color: #e9edf2; + color: #b9c0c9; + opacity: 1; +} + +@media (max-width: 600px) { + header { + margin: -0.25rem 0 1.25rem; + } + + .table-pagination { + align-items: flex-start; + flex-direction: column; + } +} + +/* ── Historical table column header ───────────────── */ +.hist-table thead { + background: #f5f8fc; +} + +.hist-table thead tr { + background: #f5f8fc; + border-bottom: 1px solid #dfe6ef; +} + +.hist-table th { + padding: 0.85rem 1rem; + color: #5c6878; + font-size: 0.73rem; + font-weight: 700; + letter-spacing: 0.055em; + line-height: 1.35; + vertical-align: middle; +} + +.hist-table th:first-child { + color: #8a95a3; +} + +.hist-table th:nth-child(n + 3) { + color: #687587; +} diff --git a/Web/src/main.jsx b/Web/src/main.jsx new file mode 100644 index 0000000..b9a1a6d --- /dev/null +++ b/Web/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/Web/vite.config.js b/Web/vite.config.js new file mode 100644 index 0000000..88435ee --- /dev/null +++ b/Web/vite.config.js @@ -0,0 +1,16 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + } +}) diff --git a/__pycache__/E2E_pipeline.cpython-310.pyc b/__pycache__/E2E_pipeline.cpython-310.pyc new file mode 100644 index 0000000..bbe7ca8 Binary files /dev/null and b/__pycache__/E2E_pipeline.cpython-310.pyc differ diff --git a/__pycache__/data_utils.cpython-310.pyc b/__pycache__/data_utils.cpython-310.pyc new file mode 100644 index 0000000..b8af5d0 Binary files /dev/null and b/__pycache__/data_utils.cpython-310.pyc differ diff --git a/__pycache__/nbeats_model.cpython-310.pyc b/__pycache__/nbeats_model.cpython-310.pyc new file mode 100644 index 0000000..1d4dcd3 Binary files /dev/null and b/__pycache__/nbeats_model.cpython-310.pyc differ diff --git a/__pycache__/pipeline_runner.cpython-310.pyc b/__pycache__/pipeline_runner.cpython-310.pyc new file mode 100644 index 0000000..ada5c0e Binary files /dev/null and b/__pycache__/pipeline_runner.cpython-310.pyc differ diff --git a/__pycache__/train_utils.cpython-310.pyc b/__pycache__/train_utils.cpython-310.pyc new file mode 100644 index 0000000..baf0be0 Binary files /dev/null and b/__pycache__/train_utils.cpython-310.pyc differ diff --git a/best_nbeats_hyperparameters.json b/best_nbeats_hyperparameters.json new file mode 100644 index 0000000..75840ef --- /dev/null +++ b/best_nbeats_hyperparameters.json @@ -0,0 +1,37 @@ +{ + "input_size": 7, + "output_size": 1, + "hidden_size": 512, + "n_stacks_trend": 3, + "n_stacks_seasonal": 4, + "blocks_per_stack": 3, + "theta_size_trend": 2, + "theta_size_season": 4, + "epochs": 300, + "batch_size": 32, + "learning_rate": 0.0001, + "loss_type": "mse", + "alpha": 0.1, + "neuron_function": "GELU(approximate='none')", + "device": "cuda", + "train_samples": 1971, + "validation_samples": 494, + "train_input_shape": [ + 1971, + 7 + ], + "train_target_shape": [ + 1971, + 1 + ], + "validation_input_shape": [ + 494, + 7 + ], + "validation_target_shape": [ + 494, + 1 + ], + "best_epoch": 300, + "best_validation_loss": 4.962215825798921e-05 +} \ No newline at end of file diff --git a/best_nbeats_model.pth b/best_nbeats_model.pth new file mode 100644 index 0000000..d4e10d1 Binary files /dev/null and b/best_nbeats_model.pth differ diff --git a/best_nbeats_modeljos.pth b/best_nbeats_modeljos.pth new file mode 100644 index 0000000..a3a12a8 Binary files /dev/null and b/best_nbeats_modeljos.pth differ diff --git a/data deploy hasil.sqlite b/data deploy hasil.sqlite new file mode 100644 index 0000000..c8ec4a2 Binary files /dev/null and b/data deploy hasil.sqlite differ diff --git a/manipulasi_deploy_data.ipynb b/manipulasi_deploy_data.ipynb new file mode 100644 index 0000000..3c18499 --- /dev/null +++ b/manipulasi_deploy_data.ipynb @@ -0,0 +1,500 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5d345c6c", + "metadata": {}, + "source": [ + "# Manipulasi `deploy_data.xlsx`\n", + "\n", + "Notebook ini mengubah data deploy berbentuk wide menjadi format long yang sama dengan `data testing.xlsx`: `Tanggal`, `Harga Medium 1`, `Harga Medium 2`, dan `rata2`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ffcf2b96", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "WindowsPath('c:/Users/LENOVO/Documents/TA/Nbeats/deploy_data.xlsx')" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from pathlib import Path\n", + "import re\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "ROOT = Path.cwd()\n", + "if ROOT.name == 'Streamlit':\n", + " ROOT = ROOT.parent\n", + "INPUT_FILE = ROOT / 'deploy_data.xlsx'\n", + "REFERENCE_FILE = ROOT / 'data testing.xlsx'\n", + "OUTPUT_XLSX = ROOT / 'data deploy hasil.xlsx'\n", + "OUTPUT_CSV = ROOT / 'data deploy hasil.csv'\n", + "MEDIUM_1_LABEL = 'Beras Kualitas Medium I'\n", + "MEDIUM_2_LABEL = 'Beras Kualitas Medium II'\n", + "USE_DAILY_CALENDAR = True\n", + "INPUT_FILE" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "15b6be12", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Ukuran deploy: (3, 194)\n", + "Kolom deploy: ['No', 'Komoditas (Rp)', '01/ 01/ 2026', '02/ 01/ 2026', '05/ 01/ 2026'] ... ['23/ 09/ 2026', '24/ 09/ 2026', '25/ 09/ 2026']\n", + "Baris komoditas:\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
NoKomoditas (Rp)
0IBeras
11Beras Kualitas Medium I
22Beras Kualitas Medium II
\n", + "
" + ], + "text/plain": [ + " No Komoditas (Rp)\n", + "0 I Beras\n", + "1 1 Beras Kualitas Medium I\n", + "2 2 Beras Kualitas Medium II" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Schema referensi: ['Tanggal', 'Harga Medium 1', 'Harga Medium 2', 'rata2']\n" + ] + } + ], + "source": [ + "deploy_raw = pd.read_excel(INPUT_FILE)\n", + "reference = pd.read_excel(REFERENCE_FILE)\n", + "print('Ukuran deploy:', deploy_raw.shape)\n", + "print('Kolom deploy:', list(deploy_raw.columns[:5]), '...', list(deploy_raw.columns[-3:]))\n", + "print('Baris komoditas:')\n", + "display(deploy_raw.iloc[:, :2])\n", + "print('Schema referensi:', reference.columns.tolist())" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "09ac66e7", + "metadata": {}, + "outputs": [], + "source": [ + "def parse_number(value):\n", + " if pd.isna(value):\n", + " return np.nan\n", + " text = str(value).strip().replace('Rp', '').replace(' ', '')\n", + " if not text:\n", + " return np.nan\n", + " # Sumber memakai koma sebagai pemisah ribuan, misalnya 13,850.\n", + " text = re.sub(r'[^0-9,.-]', '', text)\n", + " if text.count(',') > 0 and '.' not in text:\n", + " text = text.replace(',', '')\n", + " else:\n", + " text = text.replace(',', '')\n", + " return pd.to_numeric(text, errors='coerce')\n", + "\n", + "def parse_deploy_date(value):\n", + " text = str(value).strip().replace(' ', '')\n", + " return pd.to_datetime(text, format='%d/%m/%Y', errors='coerce')\n", + "\n", + "def find_row(label):\n", + " labels = deploy_raw['Komoditas (Rp)'].astype(str).str.strip()\n", + " matches = deploy_raw.loc[labels.eq(label)]\n", + " if matches.empty:\n", + " raise ValueError(f'Baris komoditas tidak ditemukan: {label}')\n", + " return matches.iloc[0]" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0c6bdf3e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
TanggalHarga Medium 1Harga Medium 2rata2
02026-01-0113500.012250.012875.0
12026-01-0213500.012250.012875.0
22026-01-0313500.012250.012875.0
32026-01-0413500.012250.012875.0
42026-01-0513500.012250.012875.0
\n", + "
" + ], + "text/plain": [ + " Tanggal Harga Medium 1 Harga Medium 2 rata2\n", + "0 2026-01-01 13500.0 12250.0 12875.0\n", + "1 2026-01-02 13500.0 12250.0 12875.0\n", + "2 2026-01-03 13500.0 12250.0 12875.0\n", + "3 2026-01-04 13500.0 12250.0 12875.0\n", + "4 2026-01-05 13500.0 12250.0 12875.0" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "medium_1 = find_row(MEDIUM_1_LABEL)\n", + "medium_2 = find_row(MEDIUM_2_LABEL)\n", + "date_columns = [column for column in deploy_raw.columns if parse_deploy_date(column) is not pd.NaT]\n", + "dates = pd.Series([parse_deploy_date(column) for column in date_columns], name='Tanggal')\n", + "result = pd.DataFrame({\n", + " 'Tanggal': dates,\n", + " 'Harga Medium 1': [parse_number(medium_1[column]) for column in date_columns],\n", + " 'Harga Medium 2': [parse_number(medium_2[column]) for column in date_columns],\n", + "})\n", + "result = result.sort_values('Tanggal').drop_duplicates('Tanggal').set_index('Tanggal')\n", + "\n", + "if USE_DAILY_CALENDAR:\n", + " full_dates = pd.date_range(result.index.min(), result.index.max(), freq='D')\n", + " result = result.reindex(full_dates)\n", + " result.index.name = 'Tanggal'\n", + " result[['Harga Medium 1', 'Harga Medium 2']] = result[['Harga Medium 1', 'Harga Medium 2']].interpolate(\n", + " method='linear', limit_direction='both'\n", + " )\n", + "\n", + "result['rata2'] = result[['Harga Medium 1', 'Harga Medium 2']].mean(axis=1)\n", + "result = result.reset_index()\n", + "result = result[['Tanggal', 'Harga Medium 1', 'Harga Medium 2', 'rata2']]\n", + "result.head()" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "4a0c5328", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Valid: 268 baris\n", + "Rentang: 2026-01-01 sampai 2026-09-25\n", + "Kolom: ['Tanggal', 'Harga Medium 1', 'Harga Medium 2', 'rata2']\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
TanggalHarga Medium 1Harga Medium 2rata2
02026-01-0113500.012250.012875.0
12026-01-0213500.012250.012875.0
22026-01-0313500.012250.012875.0
\n", + "
" + ], + "text/plain": [ + " Tanggal Harga Medium 1 Harga Medium 2 rata2\n", + "0 2026-01-01 13500.0 12250.0 12875.0\n", + "1 2026-01-02 13500.0 12250.0 12875.0\n", + "2 2026-01-03 13500.0 12250.0 12875.0" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
TanggalHarga Medium 1Harga Medium 2rata2
2652026-09-2314500.013150.013825.0
2662026-09-2414500.013150.013825.0
2672026-09-2514500.013150.013825.0
\n", + "
" + ], + "text/plain": [ + " Tanggal Harga Medium 1 Harga Medium 2 rata2\n", + "265 2026-09-23 14500.0 13150.0 13825.0\n", + "266 2026-09-24 14500.0 13150.0 13825.0\n", + "267 2026-09-25 14500.0 13150.0 13825.0" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "expected_columns = ['Tanggal', 'Harga Medium 1', 'Harga Medium 2', 'rata2']\n", + "assert result.columns.tolist() == expected_columns\n", + "assert pd.api.types.is_datetime64_any_dtype(result['Tanggal'])\n", + "assert result['Tanggal'].is_monotonic_increasing\n", + "assert result[expected_columns[1:]].notna().all().all()\n", + "assert result['Tanggal'].duplicated().sum() == 0\n", + "print(f'Valid: {len(result):,} baris')\n", + "print('Rentang:', result['Tanggal'].min().date(), 'sampai', result['Tanggal'].max().date())\n", + "print('Kolom:', result.columns.tolist())\n", + "display(result.head(3))\n", + "display(result.tail(3))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7d9e6fef", + "metadata": {}, + "outputs": [], + "source": [ + "ax = result.set_index('Tanggal')[['Harga Medium 1', 'Harga Medium 2', 'rata2']].plot(figsize=(14, 5))\n", + "ax.set_title('Data deploy setelah manipulasi')\n", + "ax.set_ylabel('Harga')\n", + "ax.grid(alpha=0.25)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ba97555c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Tersimpan:\n", + "c:\\Users\\LENOVO\\Documents\\TA\\Nbeats\\data deploy hasil.xlsx\n", + "c:\\Users\\LENOVO\\Documents\\TA\\Nbeats\\data deploy hasil.csv\n" + ] + } + ], + "source": [ + "result.to_excel(OUTPUT_XLSX, index=False)\n", + "# result.to_csv(OUTPUT_CSV, index=False, date_format='%Y-%m-%d')\n", + "print('Tersimpan:')\n", + "print(OUTPUT_XLSX)\n", + "print(OUTPUT_CSV)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/nbeats_hyperparameters.json b/nbeats_hyperparameters.json new file mode 100644 index 0000000..4ef8e83 --- /dev/null +++ b/nbeats_hyperparameters.json @@ -0,0 +1,181 @@ +{ + "input_size": 7, + "output_size": 1, + "hidden_size": 512, + "n_stacks_trend": 3, + "n_stacks_seasonal": 4, + "blocks_per_stack": 3, + "theta_size_trend": 2, + "theta_size_season": 4, + "epochs": 300, + "batch_size": 32, + "learning_rate": 0.0001, + "loss_type": "mse", + "alpha": 0.1, + "neuron_function": "GELU(approximate='none')", + "device": "cuda", + "train_samples": 1971, + "validation_samples": 494, + "train_input_shape": [ + 1971, + 7 + ], + "train_target_shape": [ + 1971, + 1 + ], + "validation_input_shape": [ + 494, + 7 + ], + "validation_target_shape": [ + 494, + 1 + ], + "best_epoch": 300, + "best_validation_loss": 4.962215825798921e-05, + "time_complexity": { + "forward": "O(N * B * H^2)", + "training_step_approx": "O(N * B * H^2)", + "gradient_norm_calculation": "O(P)", + "N_batch_size": 32, + "B_total_blocks": 21, + "H_hidden_size": 512, + "I_input_size": 7, + "O_output_size": 1, + "total_parameters": 16750308 + }, + "big_o_empirical_test": { + "benchmark_batch_size": 8, + "device": "cuda", + "hidden_size_scaling": { + "expected_complexity": "O(H^2)", + "results": [ + { + "hidden_size": 128, + "total_blocks": 21, + "batch_size": 8, + "time_seconds_median": 0.01987130008637905, + "time_milliseconds_median": 19.87130008637905, + "theoretical_relative_cost_vs_first": 1.0 + }, + { + "hidden_size": 256, + "total_blocks": 21, + "batch_size": 8, + "time_seconds_median": 0.01879219990223646, + "time_milliseconds_median": 18.79219990223646, + "theoretical_relative_cost_vs_first": 4.0 + }, + { + "hidden_size": 512, + "total_blocks": 21, + "batch_size": 8, + "time_seconds_median": 0.019390400033444166, + "time_milliseconds_median": 19.390400033444166, + "theoretical_relative_cost_vs_first": 16.0 + } + ], + "log_log_fit": { + "exponent": -0.017671848609463185, + "intercept": -3.847262919698698, + "r2": 0.1915588923022309 + } + }, + "block_scaling": { + "expected_complexity": "O(B)", + "results": [ + { + "blocks_per_stack": 10, + "total_blocks": 70, + "hidden_size": 512, + "batch_size": 8, + "time_seconds_median": 0.06300389999523759, + "time_milliseconds_median": 63.00389999523759, + "theoretical_relative_cost_vs_first": 1.0 + }, + { + "blocks_per_stack": 20, + "total_blocks": 140, + "hidden_size": 512, + "batch_size": 8, + "time_seconds_median": 0.12464780011214316, + "time_milliseconds_median": 124.64780011214316, + "theoretical_relative_cost_vs_first": 2.0 + }, + { + "blocks_per_stack": 30, + "total_blocks": 210, + "hidden_size": 512, + "batch_size": 8, + "time_seconds_median": 0.18604329996742308, + "time_milliseconds_median": 186.04329996742308, + "theoretical_relative_cost_vs_first": 3.0 + } + ], + "log_log_fit": { + "exponent": 0.9854571097543345, + "intercept": -6.951476412790202, + "r2": 0.9999991876654754 + } + }, + "input_batch_scaling": { + "expected_complexity": "O(N)", + "results": [ + { + "batch_size": 1, + "hidden_size": 512, + "total_blocks": 21, + "time_seconds_median": 0.01942260004580021, + "time_milliseconds_median": 19.42260004580021, + "theoretical_relative_cost_vs_first": 1.0 + }, + { + "batch_size": 2, + "hidden_size": 512, + "total_blocks": 21, + "time_seconds_median": 0.019125699996948242, + "time_milliseconds_median": 19.125699996948242, + "theoretical_relative_cost_vs_first": 2.0 + }, + { + "batch_size": 4, + "hidden_size": 512, + "total_blocks": 21, + "time_seconds_median": 0.018762200139462948, + "time_milliseconds_median": 18.762200139462948, + "theoretical_relative_cost_vs_first": 4.0 + }, + { + "batch_size": 8, + "hidden_size": 512, + "total_blocks": 21, + "time_seconds_median": 0.01835409994237125, + "time_milliseconds_median": 18.35409994237125, + "theoretical_relative_cost_vs_first": 8.0 + }, + { + "batch_size": 16, + "hidden_size": 512, + "total_blocks": 21, + "time_seconds_median": 0.019410799955949187, + "time_milliseconds_median": 19.410799955949187, + "theoretical_relative_cost_vs_first": 16.0 + }, + { + "batch_size": 32, + "hidden_size": 512, + "total_blocks": 21, + "time_seconds_median": 0.01993660000152886, + "time_milliseconds_median": 19.93660000152886, + "theoretical_relative_cost_vs_first": 32.0 + } + ], + "log_log_fit": { + "exponent": 0.006306562161759777, + "intercept": -3.965757991421921, + "r2": 0.07918679416134755 + } + } + } +} \ No newline at end of file diff --git a/nbeats_hyperparameters_retrained_db.json b/nbeats_hyperparameters_retrained_db.json new file mode 100644 index 0000000..d9e03b5 --- /dev/null +++ b/nbeats_hyperparameters_retrained_db.json @@ -0,0 +1,13 @@ +{ + "source": "data deploy hasil.sqlite", + "table": "data_deploy_hasil", + "model_path": "best_nbeats_model_retrained_db.pth", + "input_size": 7, + "output_size": 1, + "hidden_size": 512, + "stacks": 7, + "blocks_per_stack": 3, + "learning_rate": 0.0001, + "epochs": 700, + "best_val_loss": 0.0005434106569737196 +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..132a7f3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +streamlit>=1.35 +pandas>=2.0 +numpy>=1.24 +scikit-learn>=1.3 +openpyxl>=3.1 +selenium>=4.20 +torch>=2.1 +fastapi>=0.115 +uvicorn[standard]>=0.30 +requests>=2.31 \ No newline at end of file diff --git a/scraping.ipynb b/scraping.ipynb new file mode 100644 index 0000000..bb6d4b8 --- /dev/null +++ b/scraping.ipynb @@ -0,0 +1,350 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a8479860", + "metadata": {}, + "source": [ + "# Scraping Harga Beras Medium BI\n", + "\n", + "Mengambil harga terbaru untuk **Beras Kualitas Medium I dan II** pada **Jawa Timur - Kab. Banyuwangi** dari situs PIHPS Bank Indonesia. ID komoditas dan wilayah diambil dari endpoint referensi agar tetap mengikuti perubahan data di situs." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c3db673e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from datetime import date, datetime, timedelta\n", + "from pathlib import Path\n", + "import sqlite3\n", + "import time\n", + "\n", + "import pandas as pd\n", + "from IPython.display import display\n", + "from selenium import webdriver\n", + "from selenium.common.exceptions import TimeoutException\n", + "from selenium.webdriver.common.by import By\n", + "from selenium.webdriver.chrome.options import Options\n", + "from selenium.webdriver.support.ui import WebDriverWait\n", + "\n", + "BASE_URL = 'https://www.bi.go.id/hargapangan'\n", + "HALAMAN_URL = f'{BASE_URL}/TabelHarga/PasarTradisionalDaerah'\n", + "OUTPUT_FILE = Path('deploy_data.xlsx')\n", + "RESULT_DB_FILE = Path('data deploy hasil.sqlite')\n", + "RESULT_TABLE = 'data_deploy_hasil'\n", + "LEGACY_RESULT_FILE = Path('data deploy hasil.xlsx')\n", + "COMMODITIES = ('Beras Kualitas Medium I', 'Beras Kualitas Medium II')\n", + "PROVINCE = 'Jawa Timur'\n", + "REGENCY = 'Kab. Banyuwangi'\n", + "\n", + "options = Options()\n", + "options.add_argument('--headless=new')\n", + "options.add_argument('--window-size=1920,1080')\n", + "driver = webdriver.Chrome(options=options)\n", + "driver.get(HALAMAN_URL)\n", + "wait = WebDriverWait(driver, 30)\n", + "wait.until(lambda browser: browser.find_element(By.TAG_NAME, 'body'))" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "adabf437", + "metadata": {}, + "outputs": [], + "source": [ + "def click_tree_item(tree_id, item_name):\n", + " def find_checkbox(browser):\n", + " rows = browser.find_elements(By.CSS_SELECTOR, f'#{tree_id} tr.dx-data-row')\n", + " for row in rows:\n", + " if row.text.replace('\\u2003', ' ').strip() == item_name:\n", + " return row.find_element(By.CSS_SELECTOR, '.dx-select-checkbox')\n", + " return False\n", + "\n", + " checkbox = wait.until(find_checkbox)\n", + " if checkbox.get_attribute('aria-checked') != 'true':\n", + " driver.execute_script(\"arguments[0].scrollIntoView({block: 'center'});\", checkbox)\n", + " checkbox.click()\n", + " wait.until(lambda browser: checkbox.get_attribute('aria-checked') == 'true')\n", + "\n", + "\n", + "def set_date(date_box_id, value):\n", + " driver.execute_script(\n", + " \"window.jQuery(arguments[0]).dxDateBox('instance').option('value', arguments[1]);\",\n", + " f'#{date_box_id}',\n", + " value.isoformat(),\n", + " )\n", + "\n", + "\n", + "def configure_filters():\n", + " for commodity in COMMODITIES:\n", + " click_tree_item('CommodityTree', commodity)\n", + " click_tree_item('cboProvince', PROVINCE)\n", + " wait.until(lambda browser: browser.find_elements(By.CSS_SELECTOR, '#cboRegency tr.dx-data-row'))\n", + " click_tree_item('cboRegency', REGENCY)\n", + "\n", + "\n", + "def read_visible_prices():\n", + " header_row = next(\n", + " row for row in driver.find_elements(By.CSS_SELECTOR, '.dx-datagrid-headers tr.dx-header-row')\n", + " if any('/' in (cell.get_attribute('aria-label') or '') for cell in row.find_elements(By.CSS_SELECTOR, 'td[role=\"columnheader\"]'))\n", + " )\n", + " headers = [\n", + " (cell.get_attribute('aria-label') or '').replace('Column ', '').strip()\n", + " for cell in header_row.find_elements(By.CSS_SELECTOR, 'td[role=\"columnheader\"]')\n", + " if (cell.get_attribute('aria-label') or '').startswith('Column ')\n", + " ]\n", + "\n", + " tables = driver.find_elements(By.CSS_SELECTOR, '.dx-datagrid-rowsview table')\n", + " label_rows = []\n", + " value_rows = []\n", + " for table in tables:\n", + " rows = table.find_elements(By.CSS_SELECTOR, 'tbody tr.dx-data-row')\n", + " parsed_rows = [\n", + " [cell.text.replace('\\u2003', ' ').strip() for cell in row.find_elements(By.CSS_SELECTOR, 'td')]\n", + " for row in rows\n", + " ]\n", + " if any(any(commodity in ' '.join(row) for commodity in COMMODITIES) for row in parsed_rows):\n", + " label_rows = parsed_rows\n", + " elif parsed_rows:\n", + " value_rows = parsed_rows\n", + "\n", + " if not headers or not label_rows or not value_rows:\n", + " raise RuntimeError('Tabel harga belum menampilkan data.')\n", + "\n", + " records = []\n", + " for label_row, value_row in zip(label_rows, value_rows):\n", + " if len(label_row) < 2:\n", + " continue\n", + " commodity = label_row[1]\n", + " commodity = commodity.split(' ', 1)[1] if commodity[:1].isdigit() else commodity\n", + " if commodity not in COMMODITIES:\n", + " continue\n", + " for header, value in zip(headers[2:], value_row[2:]):\n", + " if value and value != '-':\n", + " records.append({'komoditas': commodity, 'tanggal': header, 'harga': value})\n", + " return pd.DataFrame(records)\n", + "\n", + "\n", + "def scrape_harga(tanggal_mulai, tanggal_selesai):\n", + " set_date('dboDateMulai', tanggal_mulai)\n", + " set_date('dboDateSelesai', tanggal_selesai)\n", + " driver.find_element(By.ID, 'btnReport').click()\n", + " wait.until(\n", + " lambda browser: browser.find_elements(\n", + " By.CSS_SELECTOR,\n", + " '.dx-datagrid-rowsview table tbody tr.dx-data-row',\n", + " )\n", + " )\n", + " time.sleep(1)\n", + " return read_visible_prices()" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "651fb356", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Filter Selenium siap: Beras Kualitas Medium I, Beras Kualitas Medium II | Jawa Timur | Kab. Banyuwangi\n" + ] + } + ], + "source": [ + "configure_filters()\n", + "print('Filter Selenium siap:', ', '.join(COMMODITIES), '|', PROVINCE, '|', REGENCY)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "f31d1817", + "metadata": {}, + "outputs": [ + { + "ename": "RuntimeError", + "evalue": "Tabel harga belum menampilkan data.", + "output_type": "error", + "traceback": [ + "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[1;31mRuntimeError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[1;32mIn[4], line 112\u001b[0m\n\u001b[0;32m 110\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m last_date \u001b[38;5;241m<\u001b[39m tanggal_terakhir_diizinkan:\n\u001b[0;32m 111\u001b[0m tanggal_mulai \u001b[38;5;241m=\u001b[39m last_date \u001b[38;5;241m+\u001b[39m timedelta(days\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m1\u001b[39m)\n\u001b[1;32m--> 112\u001b[0m hasil \u001b[38;5;241m=\u001b[39m \u001b[43mscrape_harga\u001b[49m\u001b[43m(\u001b[49m\u001b[43mtanggal_mulai\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mtanggal_terakhir_diizinkan\u001b[49m\u001b[43m)\u001b[49m\n\u001b[0;32m 113\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m hasil\u001b[38;5;241m.\u001b[39mempty:\n\u001b[0;32m 114\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mTidak ada harga pada rentang tanggal tersebut.\u001b[39m\u001b[38;5;124m'\u001b[39m)\n", + "Cell \u001b[1;32mIn[2], line 85\u001b[0m, in \u001b[0;36mscrape_harga\u001b[1;34m(tanggal_mulai, tanggal_selesai)\u001b[0m\n\u001b[0;32m 78\u001b[0m wait\u001b[38;5;241m.\u001b[39muntil(\n\u001b[0;32m 79\u001b[0m \u001b[38;5;28;01mlambda\u001b[39;00m browser: browser\u001b[38;5;241m.\u001b[39mfind_elements(\n\u001b[0;32m 80\u001b[0m By\u001b[38;5;241m.\u001b[39mCSS_SELECTOR,\n\u001b[0;32m 81\u001b[0m \u001b[38;5;124m'\u001b[39m\u001b[38;5;124m.dx-datagrid-rowsview table tbody tr.dx-data-row\u001b[39m\u001b[38;5;124m'\u001b[39m,\n\u001b[0;32m 82\u001b[0m )\n\u001b[0;32m 83\u001b[0m )\n\u001b[0;32m 84\u001b[0m time\u001b[38;5;241m.\u001b[39msleep(\u001b[38;5;241m1\u001b[39m)\n\u001b[1;32m---> 85\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mread_visible_prices\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n", + "Cell \u001b[1;32mIn[2], line 58\u001b[0m, in \u001b[0;36mread_visible_prices\u001b[1;34m()\u001b[0m\n\u001b[0;32m 55\u001b[0m value_rows \u001b[38;5;241m=\u001b[39m parsed_rows\n\u001b[0;32m 57\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m headers \u001b[38;5;129;01mor\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m label_rows \u001b[38;5;129;01mor\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m value_rows:\n\u001b[1;32m---> 58\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mTabel harga belum menampilkan data.\u001b[39m\u001b[38;5;124m'\u001b[39m)\n\u001b[0;32m 60\u001b[0m records \u001b[38;5;241m=\u001b[39m []\n\u001b[0;32m 61\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m label_row, value_row \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28mzip\u001b[39m(label_rows, value_rows):\n", + "\u001b[1;31mRuntimeError\u001b[0m: Tabel harga belum menampilkan data." + ] + } + ], + "source": [ + "def parse_deploy_date(value):\n", + " return pd.to_datetime(str(value).replace(' ', ''), format='%d/%m/%Y', errors='coerce')\n", + "\n", + "\n", + "def format_price(value):\n", + " number = pd.to_numeric(str(value).replace(',', '').replace('Rp', '').strip(), errors='coerce')\n", + " if pd.isna(number):\n", + " return value\n", + " return f'{number:,.0f}'\n", + "\n", + "\n", + "def update_deploy_workbook(deploy, data):\n", + " deploy_labels = deploy['Komoditas (Rp)'].astype(str).str.strip()\n", + " updated_columns = []\n", + " for record in data.to_dict('records'):\n", + " commodity = record['komoditas'].strip()\n", + " date_column = record['tanggal'].strip()\n", + " row_index = deploy.index[deploy_labels.eq(commodity)]\n", + " if row_index.empty:\n", + " raise ValueError(f'Baris {commodity!r} tidak ditemukan di {OUTPUT_FILE}.')\n", + " deploy.loc[row_index[0], date_column] = format_price(record['harga'])\n", + " if date_column not in updated_columns:\n", + " updated_columns.append(date_column)\n", + " return updated_columns\n", + "\n", + "\n", + "def load_result_store():\n", + " if RESULT_DB_FILE.exists():\n", + " with sqlite3.connect(RESULT_DB_FILE) as connection:\n", + " result = pd.read_sql_query(\n", + " f'SELECT \"Tanggal\", \"Harga Medium 1\", \"Harga Medium 2\", \"rata2\" FROM {RESULT_TABLE}',\n", + " connection,\n", + " )\n", + " else:\n", + " if not LEGACY_RESULT_FILE.exists():\n", + " raise FileNotFoundError(f'Sumber migrasi tidak ditemukan: {LEGACY_RESULT_FILE}')\n", + " result = pd.read_excel(LEGACY_RESULT_FILE)\n", + " result['Tanggal'] = pd.to_datetime(result['Tanggal'], errors='coerce')\n", + " return result.dropna(subset=['Tanggal'])\n", + "\n", + "\n", + "def save_result_store(result):\n", + " stored = result.copy()\n", + " stored['Tanggal'] = pd.to_datetime(stored['Tanggal']).dt.strftime('%Y-%m-%d')\n", + " with sqlite3.connect(RESULT_DB_FILE) as connection:\n", + " stored.to_sql(RESULT_TABLE, connection, if_exists='replace', index=False)\n", + "\n", + "\n", + "def reconstruct_deploy_result(scraped_data):\n", + " expected_columns = ['Tanggal', 'Harga Medium 1', 'Harga Medium 2', 'rata2']\n", + " existing = load_result_store()[expected_columns].set_index('Tanggal')\n", + "\n", + " if scraped_data.empty:\n", + " combined = existing\n", + " else:\n", + " scraped = scraped_data.copy()\n", + " scraped['Tanggal'] = pd.to_datetime(\n", + " scraped['tanggal'].astype(str).str.replace(' ', '', regex=False),\n", + " format='%d/%m/%Y',\n", + " errors='coerce',\n", + " )\n", + " scraped['harga'] = pd.to_numeric(\n", + " scraped['harga'].astype(str).str.replace(',', '', regex=False).str.replace('Rp', '', regex=False),\n", + " errors='coerce',\n", + " )\n", + " scraped = scraped.dropna(subset=['Tanggal', 'harga'])\n", + " new_values = scraped.pivot_table(\n", + " index='Tanggal',\n", + " columns='komoditas',\n", + " values='harga',\n", + " aggfunc='last',\n", + " ).rename(columns={\n", + " 'Beras Kualitas Medium I': 'Harga Medium 1',\n", + " 'Beras Kualitas Medium II': 'Harga Medium 2',\n", + " })\n", + " new_values = new_values.reindex(columns=['Harga Medium 1', 'Harga Medium 2'])\n", + " combined = pd.concat([\n", + " existing.drop(index=new_values.index, errors='ignore'),\n", + " new_values,\n", + " ], axis=0)\n", + "\n", + " combined = combined.sort_index()\n", + " full_dates = pd.date_range(combined.index.min(), combined.index.max(), freq='D')\n", + " combined = combined.reindex(full_dates)\n", + " combined.index.name = 'Tanggal'\n", + " price_columns = ['Harga Medium 1', 'Harga Medium 2']\n", + " combined[price_columns] = combined[price_columns].interpolate(method='linear', limit_direction='both')\n", + " combined['rata2'] = combined[price_columns].mean(axis=1)\n", + " result = combined.reset_index()[expected_columns]\n", + " save_result_store(result)\n", + " return result\n", + "\n", + "\n", + "deploy = pd.read_excel(OUTPUT_FILE)\n", + "date_columns = [column for column in deploy.columns if pd.notna(parse_deploy_date(column))]\n", + "if not date_columns:\n", + " raise ValueError(f'Tidak ada kolom tanggal yang ditemukan di {OUTPUT_FILE}.')\n", + "\n", + "last_date = max(parse_deploy_date(column).date() for column in date_columns)\n", + "tanggal_sekarang = date.today()\n", + "tanggal_terakhir_diizinkan = (\n", + " tanggal_sekarang\n", + " if datetime.now().hour >= 13\n", + " else tanggal_sekarang - timedelta(days=1)\n", + ")\n", + "hasil = pd.DataFrame(columns=['komoditas', 'tanggal', 'harga'])\n", + "tanggal_diisi = []\n", + "\n", + "try:\n", + " if last_date < tanggal_terakhir_diizinkan:\n", + " tanggal_mulai = last_date + timedelta(days=1)\n", + " hasil = scrape_harga(tanggal_mulai, tanggal_terakhir_diizinkan)\n", + " if hasil.empty:\n", + " raise RuntimeError('Tidak ada harga pada rentang tanggal tersebut.')\n", + " tanggal_diisi = update_deploy_workbook(deploy, hasil)\n", + " print(f'{tanggal_mulai} sampai {tanggal_terakhir_diizinkan}: {len(hasil)} harga diterima.')\n", + " else:\n", + " print(f'Excel sudah terisi sampai batas data: {tanggal_terakhir_diizinkan}.')\n", + "finally:\n", + " driver.quit()\n", + "\n", + "deploy.to_excel(OUTPUT_FILE, index=False)\n", + "result = reconstruct_deploy_result(hasil)\n", + "print(f'Workbook wide diperbarui: {OUTPUT_FILE.resolve()}')\n", + "print(f'Data hasil scraping disimpan ke SQLite: {RESULT_DB_FILE.resolve()}')\n", + "print(f'Tabel SQLite: {RESULT_TABLE}')\n", + "print(f'Tanggal baru/diperbarui: {len(tanggal_diisi)}')\n", + "print(f'Ukuran data deploy hasil: {result.shape[0]} baris')\n", + "display(result.tail())" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}