Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class ChartCalculationData {
ChartCalculationData? chartCalculationData(
Ref ref, {
required List<ChartCandle> candles,
required ChartTimeRange selectedRange,
}) {
if (candles.isEmpty) {
return null;
Expand Down Expand Up @@ -63,16 +64,31 @@ ChartCalculationData? chartCalculationData(
.map((entry) => FlSpot(entry.key.toDouble(), entry.value.close))
.toList();

// Build bottom labels from candle times (max 8 evenly spaced)
final bottomLabelsCount = math.min(8, candles.length);
// Build bottom labels with consistent spacing
// Calculate labels based on scale: 8 labels per screen (35 candles)
// When scale = 1.0 (< 35 candles), limit to actual candle count
// When scale > 1.0, use formula: (totalCandles / 35) * 8
const maxPointsPerScreen = 35;
const labelsPerScreen = 8;

final scale = candles.length < maxPointsPerScreen ? 1.0 : candles.length / maxPointsPerScreen;
final calculatedLabels = (scale * labelsPerScreen).round();
final bottomLabelsCount = candles.length <= 1
? candles.length
: scale == 1.0
? math.min(candles.length, labelsPerScreen) // Limit to actual candles when scale = 1
: math.max(1, calculatedLabels); // Use calculated value when scale > 1

final indexToLabel = <int, String>{};
double xAxisStep = 1;
if (bottomLabelsCount > 0 && candles.length > 1) {
xAxisStep = (candles.length - 1) / (bottomLabelsCount - 1);
// Map labels proportionally to actual candles
for (var i = 0; i < bottomLabelsCount; i++) {
final idx = (i * xAxisStep).round().clamp(0, candles.length - 1);
indexToLabel[idx] = formatChartTime(candles[idx].date);
final progress = i / (bottomLabelsCount - 1);
final idx = (progress * (candles.length - 1)).round().clamp(0, candles.length - 1);
indexToLabel[idx] = formatChartAxisLabel(candles[idx].date, selectedRange);
}
xAxisStep = (candles.length - 1) / (bottomLabelsCount - 1);
}

final maxX = (candles.length - 1).toDouble();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import 'package:decimal/decimal.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:ion/app/extensions/num.dart';
import 'package:ion/app/features/tokenized_communities/utils/chart_candles_normalizer.dart';
import 'package:ion/app/features/tokenized_communities/utils/price_change_calculator.dart';
import 'package:ion/app/features/tokenized_communities/views/components/chart.dart';
import 'package:ion_token_analytics/ion_token_analytics.dart';
Expand Down Expand Up @@ -32,11 +34,14 @@ ChartProcessedData chartProcessedData(
final chartCandles = _mapOhlcvToChartCandles(candles);
final isEmpty = chartCandles.isEmpty;

final normalizedCandles =
chartCandles.length > 1 ? normalizeCandles(chartCandles, selectedRange) : chartCandles;

final candlesToShow = isEmpty
? _buildFlatCandles(price)
: chartCandles.length == 1
? _expandSingleCandleToFlatLine(chartCandles.first, selectedRange)
: chartCandles;
? _buildFlatCandles(price, selectedRange)
: normalizedCandles.length == 1
? _expandSingleCandleToFlatLine(normalizedCandles.first, selectedRange)
: normalizedCandles;

final changePercent =
isEmpty ? 0.0 : calculatePriceChangePercent(candles, selectedRange.duration);
Expand All @@ -58,23 +63,21 @@ List<ChartCandle> _mapOhlcvToChartCandles(List<OhlcvCandle> source) {
low: candle.low,
close: candle.close,
price: Decimal.parse(candle.close.toString()),
date: DateTime.fromMillisecondsSinceEpoch(
candle.timestamp ~/ 1000, // timestamp is in microseconds
isUtc: true,
),
date: candle.timestamp.toDateTime,
),
)
.toList();
}

// Builds flat candles for empty state (all candles at same price).
List<ChartCandle> _buildFlatCandles(Decimal price) {
List<ChartCandle> _buildFlatCandles(Decimal price, ChartTimeRange selectedRange) {
final now = DateTime.now();
const count = 20;
final interval = selectedRange.duration;
const count = 35;
final value = double.tryParse(price.toString()) ?? 0;

return List<ChartCandle>.generate(count, (index) {
final date = now.subtract(Duration(minutes: (count - index) * 15));
final date = now.subtract(interval * (count - index));
return ChartCandle(
open: value,
high: value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,27 @@ Stream<List<OhlcvCandle>> tokenOhlcvCandles(
String interval,
) async* {
final client = await ref.watch(ionTokenAnalyticsClientProvider.future);

// 1. Load initial candles
final initialCandles = await client.communityTokens.loadOhlcvCandles(
externalAddress: externalAddress,
interval: interval,
);

yield initialCandles;

// 2. Subscribe to realtime updates
final subscription = await client.communityTokens.subscribeToOhlcvCandles(
ionConnectAddress: externalAddress,
interval: interval,
);

ref.onDispose(subscription.close);

final currentCandles = <OhlcvCandle>[];

// Maximum candles to keep: 50 is sufficient for chart display
final currentCandles = List<OhlcvCandle>.from(initialCandles);
const maxCandles = 50;

// 3. Process realtime updates
await for (final batch in subscription.stream) {
if (batch.isEmpty) {
yield currentCandles;
Expand All @@ -44,6 +53,9 @@ Stream<List<OhlcvCandle>> tokenOhlcvCandles(
}
}

// Defensive: Sort before removing to ensure we remove actual oldest
currentCandles.sort((a, b) => a.timestamp.compareTo(b.timestamp));

// Remove oldest candles if exceeding max
if (currentCandles.length > maxCandles) {
final amountToRemove = currentCandles.length - maxCandles;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: ice License 1.0

import 'package:decimal/decimal.dart';
import 'package:ion/app/features/tokenized_communities/views/components/chart.dart';

// Normalizes candles by filling gaps based on selectedRange interval.
// Example: [10:00, 15:00] with 1h interval → [10:00, 11:00, 12:00, 13:00, 14:00, 15:00]
// Also fills gap from last candle to "now" (works for both single and multiple candles)
List<ChartCandle> normalizeCandles(
List<ChartCandle> candles,
ChartTimeRange selectedRange,
) {
if (candles.isEmpty) return candles;

// Sort by date to ensure correct order
final sorted = List<ChartCandle>.from(candles)..sort((a, b) => a.date.compareTo(b.date));

final interval = selectedRange.duration;
final normalized = <ChartCandle>[];

for (var i = 0; i < sorted.length; i++) {
normalized.add(sorted[i]);

// Check gap before next candle
if (i < sorted.length - 1) {
final current = sorted[i];
final next = sorted[i + 1];
final gap = next.date.difference(current.date);

// Fill gap if it's larger than interval
if (gap > interval) {
var fillDate = current.date.add(interval);
while (fillDate.isBefore(next.date)) {
// Forward-fill: use previous candle's close price
final fillPrice = Decimal.parse(current.close.toStringAsFixed(4));
normalized.add(
ChartCandle(
open: current.close,
high: current.close,
low: current.close,
close: current.close,
price: fillPrice,
date: fillDate,
),
);
fillDate = fillDate.add(interval);
}
}
}
}

// Fill gap from last candle to "now"
if (normalized.isNotEmpty) {
final lastCandle = normalized.last;
final now = DateTime.now();

var fillDate = lastCandle.date.add(interval);
while (!fillDate.isAfter(now)) {
normalized.add(
ChartCandle(
open: lastCandle.close,
high: lastCandle.close,
low: lastCandle.close,
close: lastCandle.close,
price: Decimal.parse(lastCandle.close.toStringAsFixed(4)),
date: fillDate,
),
);
fillDate = fillDate.add(interval);
}
}

return normalized;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: ice License 1.0

import 'package:intl/intl.dart';
import 'package:ion/app/features/tokenized_communities/views/components/chart.dart';
import 'package:ion/app/utils/num.dart';

String formatPercent(double p) {
Expand Down Expand Up @@ -124,3 +125,9 @@ String formatChartDate(DateTime date) {
String formatChartTime(DateTime date) {
return DateFormat('H:mm').format(date);
}

// Formats a DateTime for chart axis labels based on time range.
// Uses dd/MM for 1d interval, H:mm for all others.
String formatChartAxisLabel(DateTime date, ChartTimeRange range) {
return range == ChartTimeRange.d1 ? formatChartDate(date) : formatChartTime(date);
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ class _ChartContent extends StatelessWidget {
aspectRatio: 1.7,
child: TokenAreaLineChart(
candles: candles,
selectedRange: selectedRange,
isLoading: isLoading,
),
),
Expand Down
Loading