Skip to content
This repository was archived by the owner on Mar 5, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 39 additions & 24 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use wasm_bindgen::JsCast;

use crate::event_utils::{EventOptions, wheel_event_options, window_event_listener_with_options};
Expand All @@ -33,32 +34,27 @@ use crate::{
infrastructure::{rendering::WebGpuRenderer, websocket::BinanceWebSocketClient},
time_utils::format_time_label,
};
use gloo_timers::future::sleep;

/// Maximum number of candles visible at 1x zoom
const MAX_VISIBLE_CANDLES: f64 = 32.0;
/// Minimum number of candles that must remain visible
const MIN_VISIBLE_CANDLES: f64 = 1.0;

/// Default canvas width
const CHART_WIDTH: f64 = 800.0;

/// Base factor for converting mouse movement to candle offset
pub const PAN_SENSITIVITY_BASE: f64 = MAX_VISIBLE_CANDLES / CHART_WIDTH;

/// Minimum allowed zoom level
const MIN_ZOOM_LEVEL: f64 = MAX_VISIBLE_CANDLES / 300.0;
/// Maximum allowed zoom level
const MAX_ZOOM_LEVEL: f64 = 32.0;

/// Pan offset required to trigger history loading
pub const HISTORY_FETCH_THRESHOLD: f64 = -50.0;
/// Index threshold to trigger history backfill
pub const HISTORY_PRELOAD_THRESHOLD: usize = 200;

/// Number of candles kept in memory beyond the visible range
const HISTORY_BUFFER_SIZE: usize = 150;
/// Maximum candles per backfill request
const HISTORY_FETCH_LIMIT: u32 = 1000;

/// Check if more historical data should be fetched
pub fn should_fetch_history(pan: f64) -> bool {
pan <= HISTORY_FETCH_THRESHOLD
pub fn should_fetch_history(left_index: usize) -> bool {
left_index < HISTORY_PRELOAD_THRESHOLD
}

/// Calculate visible range based on zoom level and pan offset
Expand Down Expand Up @@ -179,21 +175,17 @@ fn fetch_more_history(set_status: WriteSignal<String>) {
let interval = current_interval().get_untracked();
let client_arc =
Arc::new(Mutex::new(BinanceWebSocketClient::new(symbol.clone(), interval)));
let visible = chart.with(|c| {
let interval = current_interval().get_untracked();
let series = c.get_series(interval).unwrap();
let (zoom, pan) = viewport_zoom_pan(series.get_candles(), &c.viewport);
let len = c.get_candle_count();
visible_range(len, zoom, pan).1
});
let limit = (visible + HISTORY_BUFFER_SIZE) as u32;
let result = {
let client = client_arc.lock().await;
client.fetch_historical_data_before(end_time, limit).await
match client.fetch_historical_ui_klines_before(end_time, HISTORY_FETCH_LIMIT).await {
Ok(c) => Ok(c),
Err(_) => client.fetch_historical_data_before(end_time, HISTORY_FETCH_LIMIT).await,
}
};
match result {
Ok(mut new_candles) => {
new_candles.sort_by(|a, b| a.timestamp.value().cmp(&b.timestamp.value()));
new_candles.sort_by_key(|c| c.timestamp.value());
new_candles.dedup_by_key(|c| c.timestamp.value());
chart.update(|ch| {
for candle in new_candles.iter() {
ch.add_candle(candle.clone());
Expand Down Expand Up @@ -232,6 +224,7 @@ fn fetch_more_history(set_status: WriteSignal<String>) {
Err(e) => set_status.set(format!("❌ Failed to load more data: {e}")),
}

sleep(Duration::from_millis(500)).await;
loading_more().set(false);
});
}
Expand Down Expand Up @@ -670,6 +663,7 @@ fn ChartContainer() -> impl IntoView {
// 🎯 Mouse events for the tooltip
let handle_mouse_move = {
let chart_signal = chart;
let status_clone = set_status;
move |event: web_sys::MouseEvent| {
let mouse_x = event.offset_x() as f64;
let mouse_y = event.offset_y() as f64;
Expand Down Expand Up @@ -699,6 +693,13 @@ fn ChartContainer() -> impl IntoView {
});
}
}));
let need_history = chart_signal().with_untracked(|ch| {
let len = ch.get_candle_count();
view_state().with(|v| v.visible_range(len, 800.0).0)
});
if should_fetch_history(need_history) {
fetch_more_history(status_clone);
}
} else {
// Convert to NDC coordinates (assuming an 800x500 canvas)
let canvas_width = 800.0;
Expand Down Expand Up @@ -753,6 +754,7 @@ fn ChartContainer() -> impl IntoView {
// 🔍 Mouse wheel zoom - simplified without effects
let handle_wheel = {
let chart_signal = chart;
let status_clone = set_status;
move |event: web_sys::WheelEvent| {
if chart_signal().try_get_untracked().is_none() {
return;
Expand All @@ -779,6 +781,13 @@ fn ChartContainer() -> impl IntoView {
});
}
});
let need_history = chart_signal().with_untracked(|ch| {
let len = ch.get_candle_count();
view_state().with(|v| v.visible_range(len, 800.0).0)
});
if should_fetch_history(need_history) {
fetch_more_history(status_clone);
}
get_logger().info(LogComponent::Presentation("ChartZoom"), "🔍 Zoom applied");
}
};
Expand Down Expand Up @@ -872,8 +881,11 @@ fn ChartContainer() -> impl IntoView {
let need_history = chart_signal().with_untracked(|c| {
let interval = current_interval().get_untracked();
let series = c.get_series(interval).unwrap();
let (_, pan) = viewport_zoom_pan(series.get_candles(), &c.viewport);
should_fetch_history(pan)
view_state().with(|v| {
let len = series.get_candles().len();
let (start, _) = v.visible_range(len, 800.0);
should_fetch_history(start)
})
});
if need_history {
fetch_more_history(status_clone);
Expand Down Expand Up @@ -911,6 +923,9 @@ fn ChartContainer() -> impl IntoView {
<div style="display: flex; flex-direction: row; align-items: flex-start;">
<PriceAxisLeft chart=chart() />
<div style="position: relative;">
<Show when=move || loading_more().get()>
<div style="position:absolute;top:4px;left:4px;font-size:12px;color:#888;">Loading...</div>
</Show>
<canvas
id="chart-canvas"
node_ref=canvas_ref
Expand Down
60 changes: 60 additions & 0 deletions src/infrastructure/websocket/binance_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,66 @@ impl BinanceWebSocketClient {

Ok(candles)
}

/// 📈 Load uiKlines up to the specified time
pub async fn fetch_historical_ui_klines_before(
&self,
end_time: u64,
limit: u32,
) -> Result<Vec<Candle>, String> {
let symbol_upper = self.symbol.value().to_uppercase();
let interval_str = self.interval.to_binance_str();

let url = format!(
"https://api.binance.com/api/v3/uiKlines?symbol={symbol_upper}&interval={interval_str}&endTime={end_time}&limit={limit}"
);

get_logger().info(
LogComponent::Infrastructure("BinanceAPI"),
&format!("📈 Fetching {limit} uiKlines before {end_time} from: {url}"),
);

let response = Request::get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch historical data: {e:?}"))?;

if !response.ok() {
return Err(format!("HTTP error: {}", response.status()));
}

let klines: Vec<BinanceHistoricalKline> =
response.json().await.map_err(|e| format!("Failed to parse JSON: {e:?}"))?;

let mut candles = Vec::new();

for kline in klines {
let open = kline.1.parse::<f64>().map_err(|_| "Invalid open price")?;
let high = kline.2.parse::<f64>().map_err(|_| "Invalid high price")?;
let low = kline.3.parse::<f64>().map_err(|_| "Invalid low price")?;
let close = kline.4.parse::<f64>().map_err(|_| "Invalid close price")?;
let volume = kline.5.parse::<f64>().map_err(|_| "Invalid volume")?;

let ohlcv = OHLCV::new(
Price::new(open),
Price::new(high),
Price::new(low),
Price::new(close),
Volume::new(volume),
);

let candle = Candle::new(Timestamp::new(kline.0), ohlcv);

candles.push(candle);
}

get_logger().info(
LogComponent::Infrastructure("BinanceAPI"),
&format!("✅ Loaded {} uiKlines", candles.len()),
);

Ok(candles)
}
}

/// Simple helper to create a WebSocket connection
Expand Down
44 changes: 44 additions & 0 deletions tests/history_backfill.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
use price_chart_wasm::domain::{
chart::{Chart, value_objects::ChartType},
market_data::{Candle, OHLCV, Price, TimeInterval, Timestamp, Volume},
};

fn make_candle(ts: u64) -> Candle {
Candle::new(
Timestamp::new(ts),
OHLCV::new(
Price::new(1.0),
Price::new(1.0),
Price::new(1.0),
Price::new(1.0),
Volume::new(1.0),
),
)
}

fn merge_batch(chart: &mut Chart, mut batch: Vec<Candle>) {
batch.sort_by_key(|c| c.timestamp.value());
batch.dedup_by_key(|c| c.timestamp.value());
for candle in batch {
chart.add_candle(candle);
}
}

#[test]
fn backfill_three_batches() {
let mut chart = Chart::new("TST".to_string(), ChartType::Candlestick, 100);
for ts in 1000..=1002 {
chart.add_candle(make_candle(ts));
}

merge_batch(&mut chart, (997..=999).map(make_candle).collect());
merge_batch(&mut chart, (994..=996).map(make_candle).collect());
merge_batch(&mut chart, (991..=993).map(make_candle).collect());

let series = chart.get_series(TimeInterval::TwoSeconds).unwrap();
let candles = series.get_candles();
assert_eq!(candles.len(), 12);
for (i, c) in candles.iter().enumerate() {
assert_eq!(c.timestamp.value(), 991 + i as u64);
}
}
6 changes: 3 additions & 3 deletions tests/history_fetch.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use price_chart_wasm::app::should_fetch_history;
use price_chart_wasm::app::{HISTORY_PRELOAD_THRESHOLD, should_fetch_history};
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
#[test]
fn history_threshold_check() {
assert!(should_fetch_history(-60.0));
assert!(!should_fetch_history(-10.0));
assert!(should_fetch_history(HISTORY_PRELOAD_THRESHOLD - 1));
assert!(!should_fetch_history(HISTORY_PRELOAD_THRESHOLD + 1));
}
21 changes: 4 additions & 17 deletions tests/pan_offset.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,7 @@
use price_chart_wasm::app::{HISTORY_FETCH_THRESHOLD, PAN_SENSITIVITY_BASE, should_fetch_history};
use price_chart_wasm::app::{HISTORY_PRELOAD_THRESHOLD, should_fetch_history};

#[test]
fn pan_direction_and_history_activation() {
let mut offset = 0.0;
let delta_x = 10.0;
let zoom_level = 1.0;
let pan_sensitivity = PAN_SENSITIVITY_BASE / zoom_level;

// Simulate dragging to the right
offset -= delta_x * pan_sensitivity;
assert!(offset < 0.0);
assert!(!should_fetch_history(offset));

// Drag far enough to cross the history threshold
let mut offset_big = 0.0;
let delta_x_big = (HISTORY_FETCH_THRESHOLD.abs() + 1.0) / pan_sensitivity;
offset_big -= delta_x_big * pan_sensitivity;
assert!(should_fetch_history(offset_big));
fn start_index_triggers_history() {
assert!(should_fetch_history(HISTORY_PRELOAD_THRESHOLD - 5));
assert!(!should_fetch_history(HISTORY_PRELOAD_THRESHOLD + 5));
}
Loading