diff --git a/android/src/main/java/com/github/fujianlian/klinechart/BaseKLineChartView.java b/android/src/main/java/com/github/fujianlian/klinechart/BaseKLineChartView.java index 5344f7e..7ebe8fe 100755 --- a/android/src/main/java/com/github/fujianlian/klinechart/BaseKLineChartView.java +++ b/android/src/main/java/com/github/fujianlian/klinechart/BaseKLineChartView.java @@ -557,6 +557,9 @@ private void drawK(Canvas canvas) { if (mChildDraw != null) { mChildDraw.drawTranslated(lastPoint, currentPoint, lastX, currentPointX, canvas, this, i); } + + // Draw buy/sell marks for this candlestick if they exist (O(1) lookup) + drawBuySellMarksForCandlestick((KLineEntity) currentPoint, i, currentPointX, canvas); } //还原 平移缩放 @@ -1062,6 +1065,99 @@ private void drawOrderLines(Canvas canvas) { } } + private void drawBuySellMarksForCandlestick(KLineEntity candlestick, int index, float currentPointX, Canvas canvas) { + // Get parent container view to access buy/sell marks + if (getParent() instanceof HTKLineContainerView) { + HTKLineContainerView containerView = (HTKLineContainerView) getParent(); + + // Use the preserved timestamp to avoid precision loss + long candleTime = candlestick.timestamp; + + // Check for both marks to handle collision detection + java.util.Map buyMark = containerView.getBuyMarkForTime(candleTime); + java.util.Map sellMark = containerView.getSellMarkForTime(candleTime); + + // Check if both marks exist to handle collision avoidance + boolean hasBothMarks = buyMark != null && sellMark != null; + + // Draw buy mark + if (buyMark != null) { + drawBuySellMark(buyMark, candlestick, index, currentPointX, "buy", canvas, hasBothMarks); + } + + // Draw sell mark + if (sellMark != null) { + drawBuySellMark(sellMark, candlestick, index, currentPointX, "sell", canvas, hasBothMarks); + } + } + } + + private void drawBuySellMark(java.util.Map markData, KLineEntity candlestick, int index, float currentPointX, String type, Canvas canvas, boolean hasBothMarks) { + // Use the same X coordinate as candlesticks (same as mMainDraw.drawTranslated) + float candleX = currentPointX; + + // Use the same Y coordinate calculation as MainDraw.drawCandle + float candleHigh = candlestick.getHighPrice(); + float highY = yFromValue(candleHigh); // Same method used by MainDraw.drawCandle + + // Circle properties - diameter should match candlestick width + float circleRadius = mPointWidth * 0.4f; // Use 80% of candlestick width for diameter + + // Position both marks above the candlestick, with collision avoidance + float markCenterY; + if ("buy".equals(type)) { + // Buy mark directly above the candlestick + markCenterY = highY - circleRadius - dp2px(2); + } else { // sell + if (hasBothMarks) { + // If both marks exist, position sell mark one diameter higher + markCenterY = highY - circleRadius - dp2px(2) - (circleRadius * 2) - dp2px(2); + } else { + // If only sell mark exists, position it directly above + markCenterY = highY - circleRadius - dp2px(2); + } + } + + android.util.Log.d("BuySellDebug", "Drawing " + type + " mark - visible at (" + candleX + ", " + markCenterY + ") radius: " + circleRadius); + + // Determine colors based on type, using same colors as candlesticks + int circleColor; + if ("buy".equals(type)) { + circleColor = configManager.increaseColor; // Use same color as increasing candlesticks + } else { // sell + circleColor = configManager.decreaseColor; // Use same color as decreasing candlesticks + } + + // Create paint for circle + Paint circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + circlePaint.setColor(circleColor); + circlePaint.setStyle(Paint.Style.FILL); + + // Draw circle + canvas.drawCircle(candleX, markCenterY, circleRadius, circlePaint); + + // Draw border + Paint borderPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + borderPaint.setColor(circleColor); + borderPaint.setStyle(Paint.Style.STROKE); + borderPaint.setStrokeWidth(2.0f); + canvas.drawCircle(candleX, markCenterY, circleRadius, borderPaint); + + // Draw text inside circle + String markText = "buy".equals(type) ? "B" : "S"; + Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + textPaint.setColor(android.graphics.Color.WHITE); + textPaint.setTextSize(circleRadius * 1.2f); // Text size proportional to circle + textPaint.setTypeface(configManager.font); + textPaint.setTextAlign(Paint.Align.CENTER); + + // Calculate text position (center of circle) + Paint.FontMetrics fontMetrics = textPaint.getFontMetrics(); + float textY = markCenterY - (fontMetrics.ascent + fontMetrics.descent) / 2; + + canvas.drawText(markText, candleX, textY, textPaint); + } + public int dp2px(float dp) { final float scale = getContext().getResources().getDisplayMetrics().density; return (int) (dp * scale + 0.5f); diff --git a/android/src/main/java/com/github/fujianlian/klinechart/HTKLineConfigManager.java b/android/src/main/java/com/github/fujianlian/klinechart/HTKLineConfigManager.java index 4148528..29895f6 100644 --- a/android/src/main/java/com/github/fujianlian/klinechart/HTKLineConfigManager.java +++ b/android/src/main/java/com/github/fujianlian/klinechart/HTKLineConfigManager.java @@ -223,6 +223,8 @@ public KLineEntity packModel(Map keyValue) { idValue = keyValue.get("time"); } entity.id = idValue != null ? ((Number)idValue).intValue() : 0; + // Store original timestamp as long to preserve precision for buy/sell marks + entity.timestamp = idValue != null ? ((Number)idValue).longValue() : 0; // Handle dateString with fallback Object dateValue = keyValue.get("dateString"); diff --git a/android/src/main/java/com/github/fujianlian/klinechart/KLineEntity.java b/android/src/main/java/com/github/fujianlian/klinechart/KLineEntity.java index ab4e6b9..b7d8348 100755 --- a/android/src/main/java/com/github/fujianlian/klinechart/KLineEntity.java +++ b/android/src/main/java/com/github/fujianlian/klinechart/KLineEntity.java @@ -135,6 +135,7 @@ public float getMA10Volume() { public List> selectedItemList = new ArrayList<>(); public float id; + public long timestamp; // Store original timestamp as long to avoid precision loss public String Date; public float Open; public float High; diff --git a/android/src/main/java/com/github/fujianlian/klinechart/RNKLineView.java b/android/src/main/java/com/github/fujianlian/klinechart/RNKLineView.java index 4b03e37..8df761c 100644 --- a/android/src/main/java/com/github/fujianlian/klinechart/RNKLineView.java +++ b/android/src/main/java/com/github/fujianlian/klinechart/RNKLineView.java @@ -86,7 +86,7 @@ public void run() { @Override public Map getCommandsMap() { - return MapBuilder.of( + Map commands = MapBuilder.of( "updateLastCandlestick", 1, "addCandlesticksAtTheEnd", 2, "addCandlesticksAtTheStart", 3, @@ -95,6 +95,14 @@ public Map getCommandsMap() { "updateOrderLine", 6, "getOrderLines", 7 ); + + // Add the new buy/sell mark commands + commands.put("addBuySellMark", 8); + commands.put("removeBuySellMark", 9); + commands.put("updateBuySellMark", 10); + commands.put("getBuySellMarks", 11); + + return commands; } @Override @@ -204,6 +212,52 @@ public void receiveCommand(@Nonnull HTKLineContainerView containerView, String c e.printStackTrace(); } break; + case "addBuySellMark": + if (args != null && args.size() > 0) { + try { + ReadableMap buySellMarkData = args.getMap(0); + Map dataMap = buySellMarkData.toHashMap(); + containerView.addBuySellMark(dataMap); + } catch (Exception e) { + android.util.Log.e("RNKLineView", "Error in addBuySellMark command", e); + e.printStackTrace(); + } + } else { + } + break; + case "removeBuySellMark": + if (args != null && args.size() > 0) { + try { + String buySellMarkId = args.getString(0); + containerView.removeBuySellMark(buySellMarkId); + } catch (Exception e) { + android.util.Log.e("RNKLineView", "Error in removeBuySellMark command", e); + e.printStackTrace(); + } + } else { + } + break; + case "updateBuySellMark": + if (args != null && args.size() > 0) { + try { + ReadableMap buySellMarkData = args.getMap(0); + Map dataMap = buySellMarkData.toHashMap(); + containerView.updateBuySellMark(dataMap); + } catch (Exception e) { + android.util.Log.e("RNKLineView", "Error in updateBuySellMark command", e); + e.printStackTrace(); + } + } else { + } + break; + case "getBuySellMarks": + try { + containerView.getBuySellMarks(); + } catch (Exception e) { + android.util.Log.e("RNKLineView", "Error in getBuySellMarks command", e); + e.printStackTrace(); + } + break; default: android.util.Log.w("RNKLineView", "Unknown command: " + commandId); break; diff --git a/android/src/main/java/com/github/fujianlian/klinechart/container/HTKLineContainerView.java b/android/src/main/java/com/github/fujianlian/klinechart/container/HTKLineContainerView.java index 2482a50..3ce63bb 100644 --- a/android/src/main/java/com/github/fujianlian/klinechart/container/HTKLineContainerView.java +++ b/android/src/main/java/com/github/fujianlian/klinechart/container/HTKLineContainerView.java @@ -33,6 +33,11 @@ public class HTKLineContainerView extends RelativeLayout { // Order line management private Map> orderLines = new HashMap<>(); + // Buy/sell mark management - indexed by timestamp for O(1) lookup + private Map> buyMarks = new HashMap<>(); + private Map> sellMarks = new HashMap<>(); + private Map> buySellMarks = new HashMap<>(); // Keep for compatibility + public HTKLineContainerView(ThemedReactContext context) { super(context); this.reactContext = context; @@ -649,4 +654,168 @@ public Map> getAllOrderLines() { } } + public void addBuySellMark(Map buySellMarkData) { + + if (buySellMarkData == null || !buySellMarkData.containsKey("id") || + !buySellMarkData.containsKey("time") || !buySellMarkData.containsKey("type")) { + return; + } + + String id = (String) buySellMarkData.get("id"); + long time = ((Number) buySellMarkData.get("time")).longValue(); + String type = (String) buySellMarkData.get("type"); + + // Store in compatibility map + synchronized (buySellMarks) { + buySellMarks.put(id, buySellMarkData); + } + + // Store in efficient lookup maps by timestamp + if ("buy".equals(type)) { + synchronized (buyMarks) { + buyMarks.put(time, buySellMarkData); + } + } else if ("sell".equals(type)) { + synchronized (sellMarks) { + sellMarks.put(time, buySellMarkData); + } + } + + android.util.Log.d("HTKLineContainerView", "Added " + type + " mark with id: " + id + " at time: " + time); + + // Trigger redraw to show the buy/sell mark + post(new Runnable() { + @Override + public void run() { + klineView.invalidate(); + } + }); + } + + public void removeBuySellMark(String buySellMarkId) { + + if (buySellMarkId == null || buySellMarkId.trim().isEmpty()) { + return; + } + + // Find and remove from efficient lookup maps + Map markData; + synchronized (buySellMarks) { + markData = buySellMarks.get(buySellMarkId); + if (markData != null) { + buySellMarks.remove(buySellMarkId); + } + } + + if (markData != null && markData.containsKey("time") && markData.containsKey("type")) { + long time = ((Number) markData.get("time")).longValue(); + String type = (String) markData.get("type"); + + if ("buy".equals(type)) { + synchronized (buyMarks) { + buyMarks.remove(time); + } + } else if ("sell".equals(type)) { + synchronized (sellMarks) { + sellMarks.remove(time); + } + } + } + + android.util.Log.d("HTKLineContainerView", "Removed buy/sell mark with id: " + buySellMarkId); + + // Trigger redraw to remove the buy/sell mark + post(new Runnable() { + @Override + public void run() { + klineView.invalidate(); + } + }); + } + + public void updateBuySellMark(Map buySellMarkData) { + + if (buySellMarkData == null || !buySellMarkData.containsKey("id") || + !buySellMarkData.containsKey("time") || !buySellMarkData.containsKey("type")) { + return; + } + + String id = (String) buySellMarkData.get("id"); + long time = ((Number) buySellMarkData.get("time")).longValue(); + String type = (String) buySellMarkData.get("type"); + + // Remove old entry from efficient lookup maps if it exists + Map oldMarkData; + synchronized (buySellMarks) { + oldMarkData = buySellMarks.get(id); + buySellMarks.put(id, buySellMarkData); + } + + if (oldMarkData != null && oldMarkData.containsKey("time") && oldMarkData.containsKey("type")) { + long oldTime = ((Number) oldMarkData.get("time")).longValue(); + String oldType = (String) oldMarkData.get("type"); + + if ("buy".equals(oldType)) { + synchronized (buyMarks) { + buyMarks.remove(oldTime); + } + } else if ("sell".equals(oldType)) { + synchronized (sellMarks) { + sellMarks.remove(oldTime); + } + } + } + + // Add to efficient lookup maps + if ("buy".equals(type)) { + synchronized (buyMarks) { + buyMarks.put(time, buySellMarkData); + } + } else if ("sell".equals(type)) { + synchronized (sellMarks) { + sellMarks.put(time, buySellMarkData); + } + } + + android.util.Log.d("HTKLineContainerView", "Updated " + type + " mark with id: " + id + " at time: " + time); + + // Trigger redraw to update the buy/sell mark + post(new Runnable() { + @Override + public void run() { + klineView.invalidate(); + } + }); + } + + public List> getBuySellMarks() { + + // Return all buy/sell marks as a list + synchronized (buySellMarks) { + List> buySellMarksList = new ArrayList<>(buySellMarks.values()); + android.util.Log.d("HTKLineContainerView", "Returning " + buySellMarksList.size() + " buy/sell marks"); + return buySellMarksList; + } + } + + // Method to allow KLineChartView to access buy/sell marks for drawing + public Map> getAllBuySellMarks() { + synchronized (buySellMarks) { + return new HashMap<>(buySellMarks); + } + } + + // Efficient O(1) lookup methods for buy/sell marks by timestamp + public Map getBuyMarkForTime(long time) { + synchronized (buyMarks) { + return buyMarks.get(time); + } + } + + public Map getSellMarkForTime(long time) { + synchronized (sellMarks) { + return sellMarks.get(time); + } + } + } diff --git a/android/src/main/java/com/github/fujianlian/klinechart/draw/MainDraw.java b/android/src/main/java/com/github/fujianlian/klinechart/draw/MainDraw.java index 88a645a..1e07ad0 100755 --- a/android/src/main/java/com/github/fujianlian/klinechart/draw/MainDraw.java +++ b/android/src/main/java/com/github/fujianlian/klinechart/draw/MainDraw.java @@ -14,6 +14,7 @@ import com.github.fujianlian.klinechart.utils.ViewUtil; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -338,8 +339,34 @@ public void drawSelector(final BaseKLineChartView view, Canvas canvas) { float top = margin + view.getTopPadding(); final KLineEntity point = (KLineEntity) view.getItem(index); + // Get the basic selectedItemList + List> itemList = new ArrayList<>(point.selectedItemList); - List> itemList = point.selectedItemList; + // Check if there are buy/sell marks for this candlestick and add them to tooltip + if (view.getParent() instanceof com.github.fujianlian.klinechart.container.HTKLineContainerView) { + com.github.fujianlian.klinechart.container.HTKLineContainerView containerView = + (com.github.fujianlian.klinechart.container.HTKLineContainerView) view.getParent(); + + long candleTime = point.timestamp; + + // Add buy mark tooltip if exists + java.util.Map buyMark = containerView.getBuyMarkForTime(candleTime); + if (buyMark != null && buyMark.containsKey("tooltipText")) { + Map buyTooltipItem = new HashMap<>(); + buyTooltipItem.put("title", ""); + buyTooltipItem.put("detail", buyMark.get("tooltipText")); + itemList.add(buyTooltipItem); + } + + // Add sell mark tooltip if exists + java.util.Map sellMark = containerView.getSellMarkForTime(candleTime); + if (sellMark != null && sellMark.containsKey("tooltipText")) { + Map sellTooltipItem = new HashMap<>(); + sellTooltipItem.put("title", ""); + sellTooltipItem.put("detail", sellMark.get("tooltipText")); + itemList.add(sellTooltipItem); + } + } float height = padding * 2 + (textHeight + lineHeight) * itemList.size() - lineHeight; diff --git a/example/App.js b/example/App.js index 0dbc7c6..c5b8c2f 100644 --- a/example/App.js +++ b/example/App.js @@ -9,7 +9,8 @@ import { StyleSheet, StatusBar, Platform, - PixelRatio + PixelRatio, + ScrollView } from 'react-native' import RNKLineView from 'react-native-kline-view' import { ThemeManager } from './utils/themes' @@ -26,6 +27,7 @@ import { import Toolbar from './components/Toolbar' import ControlBar from './components/ControlBar' import OrderInput from './components/OrderInput' +import BuySellMarkInput from './components/BuySellMarkInput' import Selectors from './components/Selectors' import { processKLineData, @@ -319,6 +321,10 @@ const App = () => { const [orderIdCounter, setOrderIdCounter] = useState(1) const [orderLines, setOrderLines] = useState({}) + // Buy/sell mark management + const [buySellMarkIdCounter, setBuySellMarkIdCounter] = useState(1) + const [buySellMarks, setBuySellMarks] = useState({}) + const handleAddLimitOrder = useCallback((price, label) => { if (!kLineViewRef.current) return @@ -373,6 +379,68 @@ const App = () => { return null }, [klineData]) + // Buy/sell mark handlers + const handleAddBuySellMark = useCallback((type, time, price, amount, orderCount) => { + if (!kLineViewRef.current) return + + const finalPrice = price || getCurrentPrice() || 0 + const finalAmount = amount || '1.0' + + const buySellMark = { + id: `buysell-mark-${buySellMarkIdCounter}`, + time: time, + type: type, // 'buy' or 'sell' + amount: finalAmount, + price: finalPrice.toString(), + orderCount: orderCount || 1, + tooltipText: `${type.toUpperCase()} ${finalAmount} at ${finalPrice.toFixed(2)}` + } + + console.log('Adding buy/sell mark:', buySellMark) + kLineViewRef.current.addBuySellMark(buySellMark) + setBuySellMarks(prev => ({ ...prev, [buySellMark.id]: buySellMark })) + setBuySellMarkIdCounter(prev => prev + 1) + }, [kLineViewRef.current, buySellMarkIdCounter, getCurrentPrice]) + + const handleRemoveBuySellMark = useCallback((markId) => { + if (!kLineViewRef.current) return + + console.log('Removing buy/sell mark:', markId) + kLineViewRef.current.removeBuySellMark(markId) + setBuySellMarks(prev => { + const newMarks = { ...prev } + delete newMarks[markId] + return newMarks + }) + }, [kLineViewRef.current]) + + const handleUpdateBuySellMark = useCallback((markId, newType, newPrice, newAmount, newOrderCount) => { + if (!kLineViewRef.current) return + + const existingMark = buySellMarks[markId] + if (!existingMark) { + console.warn(`Buy/sell mark with ID ${markId} not found`) + return + } + + const finalType = newType || existingMark.type + const finalPrice = newPrice || parseFloat(existingMark.price) + const finalAmount = newAmount || existingMark.amount + + const updatedMark = { + ...existingMark, + type: finalType, + price: finalPrice.toString(), + amount: finalAmount, + orderCount: newOrderCount || existingMark.orderCount, + tooltipText: `${finalType.toUpperCase()} ${finalAmount} at ${finalPrice.toFixed(2)}` + } + + console.log('Updating buy/sell mark:', updatedMark) + kLineViewRef.current.updateBuySellMark(updatedMark) + setBuySellMarks(prev => ({ ...prev, [markId]: updatedMark })) + }, [kLineViewRef.current, buySellMarks]) + const renderKLineChart = useCallback((styles) => { @@ -442,62 +510,78 @@ const App = () => { /> {/* K-line chart */} - {renderKLineChart(styles)} - - {/* Order input */} - + + {renderKLineChart(styles)} + - {/* Bottom control bar */} - setShowTimeSelector(true)} - onShowIndicatorSelector={() => setShowIndicatorSelector(true)} - onToggleDrawToolSelector={() => { - setShowDrawToolSelector(!showDrawToolSelector) - setShowIndicatorSelector(false) - setShowTimeSelector(false) - }} - onClearDrawings={clearDrawings} - onToggleVolume={() => { - setShowVolumeChart(!showVolumeChart) - setTimeout(() => reloadKLineData(), 0) - }} - onToggleRounded={() => { - setCandleCornerRadius(candleCornerRadius > 0 ? 0 : 1) - setTimeout(() => reloadKLineData(), 0) - }} - /> - {/* Selector popup */} - setShowTimeSelector(false)} - onCloseIndicatorSelector={() => setShowIndicatorSelector(false)} - onToggleDrawShouldContinue={(value) => setDrawShouldContinue(value)} - /> + + {/* Order input */} + + + {/* Buy/Sell mark input */} + + + {/* Bottom control bar */} + setShowTimeSelector(true)} + onShowIndicatorSelector={() => setShowIndicatorSelector(true)} + onToggleDrawToolSelector={() => { + setShowDrawToolSelector(!showDrawToolSelector) + setShowIndicatorSelector(false) + setShowTimeSelector(false) + }} + onClearDrawings={clearDrawings} + onToggleVolume={() => { + setShowVolumeChart(!showVolumeChart) + setTimeout(() => reloadKLineData(), 0) + }} + onToggleRounded={() => { + setCandleCornerRadius(candleCornerRadius > 0 ? 0 : 1) + setTimeout(() => reloadKLineData(), 0) + }} + /> + + {/* Selector popup */} + setShowTimeSelector(false)} + onCloseIndicatorSelector={() => setShowIndicatorSelector(false)} + onToggleDrawShouldContinue={(value) => setDrawShouldContinue(value)} + /> + ) } diff --git a/example/components/BuySellMarkInput.js b/example/components/BuySellMarkInput.js new file mode 100644 index 0000000..89555f8 --- /dev/null +++ b/example/components/BuySellMarkInput.js @@ -0,0 +1,259 @@ +import React, { useState } from 'react' +import { View, Text, TextInput, TouchableOpacity, StyleSheet } from 'react-native' + +const BuySellMarkInput = ({ theme, onAddBuySellMark, onRemoveBuySellMark, onUpdateBuySellMark, currentPrice, buySellMarks, klineData }) => { + const [markType, setMarkType] = useState('buy') + const [priceInput, setPriceInput] = useState('') + const [amountInput, setAmountInput] = useState('1.0') + const [updateMarkId, setUpdateMarkId] = useState('') + const [updateType, setUpdateType] = useState('buy') + const [updatePriceInput, setUpdatePriceInput] = useState('') + + const handleAddMark = () => { + if (!klineData || klineData.length === 0) return + + // Use the last candlestick's time for demonstration + const lastCandlestick = klineData[klineData.length - 10] + const time = lastCandlestick.time + + const price = priceInput ? parseFloat(priceInput) : currentPrice + const amount = amountInput || '1.0' + + if (price > 0) { + onAddBuySellMark(markType, time, price, amount, 1) + setPriceInput('') + setAmountInput('1.0') + } + } + + const handleRemoveMark = () => { + if (updateMarkId.trim()) { + onRemoveBuySellMark(updateMarkId.trim()) + setUpdateMarkId('') + } + } + + const handleUpdateMark = () => { + const price = parseFloat(updatePriceInput) + if (!isNaN(price) && price > 0 && updateMarkId.trim()) { + onUpdateBuySellMark(updateMarkId.trim(), updateType, price) + setUpdateMarkId('') + setUpdatePriceInput('') + } + } + + const styles = StyleSheet.create({ + container: { + padding: 10, + backgroundColor: theme.backgroundColor, + borderTopWidth: 1, + borderTopColor: theme.gridColor, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + label: { + color: theme.textColor, + fontSize: 14, + marginRight: 8, + fontWeight: '600', + }, + input: { + flex: 1, + height: 36, + borderWidth: 1, + borderColor: theme.gridColor, + borderRadius: 4, + paddingHorizontal: 8, + backgroundColor: theme.panelBackgroundColor, + color: theme.textColor, + fontSize: 14, + marginRight: 8, + }, + shortInput: { + width: 80, + height: 36, + borderWidth: 1, + borderColor: theme.gridColor, + borderRadius: 4, + paddingHorizontal: 8, + backgroundColor: theme.panelBackgroundColor, + color: theme.textColor, + fontSize: 14, + marginRight: 8, + }, + typeButton: { + backgroundColor: theme.gridColor, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 4, + marginRight: 8, + }, + typeButtonActive: { + backgroundColor: theme.buttonColor, + }, + typeButtonText: { + color: theme.textColor, + fontSize: 14, + fontWeight: '600', + }, + typeButtonTextActive: { + color: theme.backgroundColor, + }, + addButton: { + backgroundColor: theme.buttonColor, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 4, + }, + addButtonText: { + color: theme.backgroundColor, + fontSize: 14, + fontWeight: '600', + }, + removeButton: { + backgroundColor: theme.decreaseColor, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 4, + }, + removeButtonText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + }, + updateButton: { + backgroundColor: theme.increaseColor, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 4, + }, + updateButtonText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + }, + markInfo: { + color: theme.gridColor, + fontSize: 10, + marginTop: 4, + }, + currentPriceButton: { + backgroundColor: theme.gridColor, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + marginRight: 8, + }, + currentPriceButtonText: { + color: theme.textColor, + fontSize: 12, + }, + }) + + return ( + + {/* Add Buy/Sell Mark Row */} + + Add Mark: + setMarkType('buy')} + > + + Buy + + + setMarkType('sell')} + > + + Sell + + + + + {currentPrice && ( + setPriceInput(currentPrice.toString())} + > + + Current: {currentPrice.toFixed(2)} + + + )} + + Add + + + + {/* Update/Remove Mark Row */} + + Manage Mark: + setUpdateType('buy')} + > + + Buy + + + setUpdateType('sell')} + > + + Sell + + + + + + Update + + + Remove + + + + {/* Show available mark IDs */} + {buySellMarks && Object.keys(buySellMarks).length > 0 && ( + + Available Mark IDs: {Object.keys(buySellMarks).join(', ')} + + )} + + ) +} + +export default BuySellMarkInput \ No newline at end of file diff --git a/index.d.ts b/index.d.ts index 99759d8..8d56e4b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -16,6 +16,16 @@ declare module 'react-native-kline-view' { labelDescriptionColor?: string; // Optional color for the description text (defaults to labelColor if not specified) } + export interface BuySellMark { + id: string; + time: number; + type: 'buy' | 'sell'; + amount: string; + price: string; + orderCount?: number; + tooltipText?: string; + } + export interface RNKLineViewRef { updateLastCandlestick: (candlestick: any) => void; addCandlesticksAtTheEnd: (candlesticks: any[]) => void; @@ -24,6 +34,10 @@ declare module 'react-native-kline-view' { removeOrderLine: (orderLineId: string) => void; updateOrderLine: (orderLine: OrderLine) => void; getOrderLines: () => OrderLine[]; + addBuySellMark: (buySellMark: BuySellMark) => void; + removeBuySellMark: (buySellMarkId: string) => void; + updateBuySellMark: (buySellMark: BuySellMark) => void; + getBuySellMarks: () => BuySellMark[]; } export interface RNKLineViewProps extends ViewProps { diff --git a/index.js b/index.js index acef87d..42a5976 100644 --- a/index.js +++ b/index.js @@ -147,6 +147,87 @@ const RNKLineView = forwardRef((props, ref) => { console.warn('No nodeHandle found for RNKLineView'); return []; } + }, + addBuySellMark: (buySellMark) => { + const nodeHandle = findNodeHandle(nativeRef.current); + if (nodeHandle) { + if (Platform.OS === 'ios') { + UIManager.dispatchViewManagerCommand( + nodeHandle, + UIManager.getViewManagerConfig('RNKLineView').Commands.addBuySellMark, + [buySellMark] + ); + } else { + UIManager.dispatchViewManagerCommand( + nodeHandle, + 'addBuySellMark', + [buySellMark] + ); + } + } else { + console.warn('No nodeHandle found for RNKLineView'); + } + }, + removeBuySellMark: (buySellMarkId) => { + const nodeHandle = findNodeHandle(nativeRef.current); + if (nodeHandle) { + if (Platform.OS === 'ios') { + UIManager.dispatchViewManagerCommand( + nodeHandle, + UIManager.getViewManagerConfig('RNKLineView').Commands.removeBuySellMark, + [buySellMarkId] + ); + } else { + UIManager.dispatchViewManagerCommand( + nodeHandle, + 'removeBuySellMark', + [buySellMarkId] + ); + } + } else { + console.warn('No nodeHandle found for RNKLineView'); + } + }, + updateBuySellMark: (buySellMark) => { + const nodeHandle = findNodeHandle(nativeRef.current); + if (nodeHandle) { + if (Platform.OS === 'ios') { + UIManager.dispatchViewManagerCommand( + nodeHandle, + UIManager.getViewManagerConfig('RNKLineView').Commands.updateBuySellMark, + [buySellMark] + ); + } else { + UIManager.dispatchViewManagerCommand( + nodeHandle, + 'updateBuySellMark', + [buySellMark] + ); + } + } else { + console.warn('No nodeHandle found for RNKLineView'); + } + }, + getBuySellMarks: () => { + const nodeHandle = findNodeHandle(nativeRef.current); + if (nodeHandle) { + if (Platform.OS === 'ios') { + return UIManager.dispatchViewManagerCommand( + nodeHandle, + UIManager.getViewManagerConfig('RNKLineView').Commands.getBuySellMarks, + [] + ); + } else { + return UIManager.dispatchViewManagerCommand( + nodeHandle, + 'getBuySellMarks', + [] + ); + } + } else { + console.warn('No nodeHandle found for RNKLineView'); + return []; + } } })); diff --git a/ios/Classes/HTKLineContainerView.swift b/ios/Classes/HTKLineContainerView.swift index 930907d..885ed43 100644 --- a/ios/Classes/HTKLineContainerView.swift +++ b/ios/Classes/HTKLineContainerView.swift @@ -18,6 +18,23 @@ class HTKLineContainerView: UIView { return orderLines } + // Buy/sell mark management - indexed by timestamp for O(1) lookup + private var buyMarks: [Int64: [String: Any]] = [:] + private var sellMarks: [Int64: [String: Any]] = [:] + private var buySellMarks: [String: [String: Any]] = [:] // Keep for compatibility + + func getAllBuySellMarks() -> [String: [String: Any]] { + return buySellMarks + } + + func getBuyMarkForTime(_ time: Int64) -> [String: Any]? { + return buyMarks[time] + } + + func getSellMarkForTime(_ time: Int64) -> [String: Any]? { + return sellMarks[time] + } + @objc var onDrawItemDidTouch: RCTBubblingEventBlock? @objc var onScrollLeft: RCTBubblingEventBlock? @@ -488,5 +505,103 @@ class HTKLineContainerView: UIView { return NSArray(array: orderLinesArray) } + @objc func addBuySellMark(_ buySellMark: NSDictionary) { + + guard let buySellMarkDict = buySellMark as? [String: Any], + let id = buySellMarkDict["id"] as? String, + let time = buySellMarkDict["time"] as? Int64, + let type = buySellMarkDict["type"] as? String else { + return + } + + // Store in compatibility map + buySellMarks[id] = buySellMarkDict + + // Store in efficient lookup maps by timestamp + if type == "buy" { + buyMarks[time] = buySellMarkDict + } else if type == "sell" { + sellMarks[time] = buySellMarkDict + } + + print("HTKLineContainerView: Added \(type) mark with id: \(id) at time: \(time)") + + // Trigger redraw to show the buy/sell mark + DispatchQueue.main.async { [weak self] in + self?.klineView.setNeedsDisplay() + } + } + + @objc func removeBuySellMark(_ buySellMarkId: String) { + + // Find and remove from efficient lookup maps + if let markData = buySellMarks[buySellMarkId], + let time = markData["time"] as? Int64, + let type = markData["type"] as? String { + + if type == "buy" { + buyMarks.removeValue(forKey: time) + } else if type == "sell" { + sellMarks.removeValue(forKey: time) + } + } + + // Remove from compatibility map + buySellMarks.removeValue(forKey: buySellMarkId) + print("HTKLineContainerView: Removed buy/sell mark with id: \(buySellMarkId)") + + // Trigger redraw to remove the buy/sell mark + DispatchQueue.main.async { [weak self] in + self?.klineView.setNeedsDisplay() + } + } + + @objc func updateBuySellMark(_ buySellMark: NSDictionary) { + + guard let buySellMarkDict = buySellMark as? [String: Any], + let id = buySellMarkDict["id"] as? String, + let time = buySellMarkDict["time"] as? Int64, + let type = buySellMarkDict["type"] as? String else { + return + } + + // Remove old entry from efficient lookup maps if it exists + if let oldMarkData = buySellMarks[id], + let oldTime = oldMarkData["time"] as? Int64, + let oldType = oldMarkData["type"] as? String { + + if oldType == "buy" { + buyMarks.removeValue(forKey: oldTime) + } else if oldType == "sell" { + sellMarks.removeValue(forKey: oldTime) + } + } + + // Update compatibility map + buySellMarks[id] = buySellMarkDict + + // Add to efficient lookup maps + if type == "buy" { + buyMarks[time] = buySellMarkDict + } else if type == "sell" { + sellMarks[time] = buySellMarkDict + } + + print("HTKLineContainerView: Updated \(type) mark with id: \(id) at time: \(time)") + + // Trigger redraw to update the buy/sell mark + DispatchQueue.main.async { [weak self] in + self?.klineView.setNeedsDisplay() + } + } + + @objc func getBuySellMarks() -> NSArray { + + // Return all buy/sell marks as an array + let buySellMarksArray = Array(buySellMarks.values) + print("HTKLineContainerView: Returning \(buySellMarksArray.count) buy/sell marks") + return NSArray(array: buySellMarksArray) + } + } diff --git a/ios/Classes/HTKLineView.swift b/ios/Classes/HTKLineView.swift index 5672f65..8ee26d6 100644 --- a/ios/Classes/HTKLineView.swift +++ b/ios/Classes/HTKLineView.swift @@ -238,9 +238,19 @@ class HTKLineView: UIScrollView { } func calculateBaseHeight() { - self.visibleModelArray = - configManager.modelArray.count > 0 - ? Array(configManager.modelArray[visibleRange]) : configManager.modelArray + // Safely handle visible model array calculation with bounds checking + if configManager.modelArray.count > 0 { + let startIndex = max(0, min(visibleRange.lowerBound, configManager.modelArray.count - 1)) + let endIndex = max(0, min(visibleRange.upperBound, configManager.modelArray.count - 1)) + + if startIndex >= 0 && endIndex >= startIndex && endIndex < configManager.modelArray.count { + self.visibleModelArray = Array(configManager.modelArray[startIndex...endIndex]) + } else { + self.visibleModelArray = configManager.modelArray + } + } else { + self.visibleModelArray = configManager.modelArray + } self.volumeRange = configManager.mainFlex...configManager.mainFlex + configManager.volumeFlex @@ -330,6 +340,9 @@ class HTKLineView: UIScrollView { } childDraw?.drawCandle(model, i, childMinMaxRange.upperBound, childMinMaxRange.lowerBound, childBaseY, childHeight, context, configManager) + // Draw buy/sell marks for this candlestick if they exist (O(1) lookup) + drawBuySellMarksForCandlestick(model, i, context) + let lastIndex = i == 0 ? i : i - 1 let lastModel = visibleModelArray[lastIndex] mainDraw.drawLine(model, lastModel, mainMinMaxRange.upperBound, mainMinMaxRange.lowerBound, mainBaseY, mainHeight, i, lastIndex, context, configManager) @@ -646,7 +659,29 @@ class HTKLineView: UIScrollView { guard !configManager.isMinute else { return } - let itemList = visibleModelArray[selectedIndex - visibleRange.lowerBound].selectedItemList + + // Get the selected model and its basic selectedItemList + let selectedModel = visibleModelArray[selectedIndex - visibleRange.lowerBound] + var itemList = selectedModel.selectedItemList + + // Check if there are buy/sell marks for this candlestick and add them to tooltip + if let containerView = superview as? HTKLineContainerView { + let candleTime = Int64(selectedModel.id) + + // Add buy mark tooltip if exists + if let buyMark = containerView.getBuyMarkForTime(candleTime), + let tooltipText = buyMark["tooltipText"] as? String { + let buyTooltipItem = ["title": "", "detail": tooltipText] + itemList.append(buyTooltipItem) + } + + // Add sell mark tooltip if exists + if let sellMark = containerView.getSellMarkForTime(candleTime), + let tooltipText = sellMark["tooltipText"] as? String { + let sellTooltipItem = ["title": "", "detail": tooltipText] + itemList.append(sellTooltipItem) + } + } let font = configManager.createFont(configManager.panelTextFontSize) let color = configManager.candleTextColor @@ -885,6 +920,107 @@ class HTKLineView: UIScrollView { } } + func drawBuySellMarksForCandlestick(_ model: HTKLineModel, _ index: Int, _ context: CGContext) { + // Access buy/sell marks from parent container view + guard let containerView = superview as? HTKLineContainerView else { + return + } + + let candleTime = Int64(model.id) // Use model.id which contains the timestamp + + // Check for both marks to handle collision detection + let buyMark = containerView.getBuyMarkForTime(candleTime) + let sellMark = containerView.getSellMarkForTime(candleTime) + + // Check if both marks exist to handle collision avoidance + let hasBothMarks = buyMark != nil && sellMark != nil + + // Draw buy mark + if let buyMark = buyMark { + drawBuySellMark(buyMark, model, index, "buy", context, hasBothMarks: hasBothMarks) + } + + // Draw sell mark + if let sellMark = sellMark { + drawBuySellMark(sellMark, model, index, "sell", context, hasBothMarks: hasBothMarks) + } + } + + private func drawBuySellMark(_ markData: [String: Any], _ model: HTKLineModel, _ index: Int, _ type: String, _ context: CGContext, hasBothMarks: Bool = false) { + // Calculate X position for the candlestick + let candleX = CGFloat(index) * configManager.itemWidth + configManager.itemWidth / 2 + + // Get the candlestick high price for positioning above it + let markPrice = model.high + + // Convert price to Y coordinate + let priceRange = mainMinMaxRange.upperBound - mainMinMaxRange.lowerBound + let normalizedPrice = (markPrice - mainMinMaxRange.lowerBound) / priceRange + let markY = mainBaseY + mainHeight - CGFloat(normalizedPrice) * mainHeight + + // Make circle radius match candlestick width (same as Android logic) + let circleRadius: CGFloat = configManager.itemWidth * 0.4 // 80% of candlestick width for diameter + + // Position both marks above the candlestick, with collision avoidance + let markCenterY: CGFloat + if type == "buy" { + // Buy mark directly above the candlestick + markCenterY = markY - circleRadius - 2 + } else { // sell + if hasBothMarks { + // If both marks exist, position sell mark one diameter higher + markCenterY = markY - circleRadius - 2 - (circleRadius * 2) - 2 + } else { + // If only sell mark exists, position it directly above + markCenterY = markY - circleRadius - 2 + } + } + + // Determine colors and text based on type + let circleColor: UIColor + let markText: String + + if type == "buy" { + circleColor = configManager.increaseColor + markText = "B" + } else { // sell + circleColor = configManager.decreaseColor + markText = "S" + } + + // Draw circle + let circleRect = CGRect(x: candleX - circleRadius, y: markCenterY - circleRadius, + width: circleRadius * 2, height: circleRadius * 2) + let circlePath = UIBezierPath(ovalIn: circleRect) + + context.setFillColor(circleColor.cgColor) + context.addPath(circlePath.cgPath) + context.fillPath() + + // Draw border + context.setStrokeColor(circleColor.cgColor) + context.setLineWidth(1.0) + context.setLineDash(phase: 0, lengths: []) // Solid line + context.addPath(circlePath.cgPath) + context.strokePath() + + // Draw text inside circle + let fontSize: CGFloat = circleRadius * 1.2 // Text size proportional to circle (same as Android) + let font = configManager.createFont(fontSize) + let textAttributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor.white + ] + + let textSize = markText.size(withAttributes: textAttributes) + let textRect = CGRect(x: candleX - textSize.width / 2, + y: markCenterY - textSize.height / 2, + width: textSize.width, + height: textSize.height) + + markText.draw(in: textRect, withAttributes: textAttributes) + } + func valuePointFromViewPoint(_ point: CGPoint) -> CGPoint { return CGPoint.init(x: valueFromX(point.x), y: valueFromY(point.y)) } diff --git a/ios/Classes/RNKLineView.m b/ios/Classes/RNKLineView.m index 0fe0562..5081619 100644 --- a/ios/Classes/RNKLineView.m +++ b/ios/Classes/RNKLineView.m @@ -36,5 +36,16 @@ @interface RCT_EXTERN_MODULE(RNKLineView, RCTViewManager) RCT_EXTERN_METHOD(getOrderLines:(nonnull NSNumber *)node) +RCT_EXTERN_METHOD(addBuySellMark:(nonnull NSNumber *)node + buySellMark:(nonnull NSDictionary *)buySellMark) + +RCT_EXTERN_METHOD(removeBuySellMark:(nonnull NSNumber *)node + buySellMarkId:(nonnull NSString *)buySellMarkId) + +RCT_EXTERN_METHOD(updateBuySellMark:(nonnull NSNumber *)node + buySellMark:(nonnull NSDictionary *)buySellMark) + +RCT_EXTERN_METHOD(getBuySellMarks:(nonnull NSNumber *)node) + @end diff --git a/ios/Classes/RNKLineView.swift b/ios/Classes/RNKLineView.swift index 862c1c9..ab329b6 100644 --- a/ios/Classes/RNKLineView.swift +++ b/ios/Classes/RNKLineView.swift @@ -103,4 +103,42 @@ class RNKLineView: RCTViewManager { return view.getOrderLines() } + @objc func addBuySellMark(_ node: NSNumber, buySellMark: NSDictionary) { + DispatchQueue.main.async { + guard let view = self.bridge?.uiManager.view(forReactTag: node) as? HTKLineContainerView else { + print("RNKLineView: Could not find HTKLineContainerView for node \(node)") + return + } + view.addBuySellMark(buySellMark) + } + } + + @objc func removeBuySellMark(_ node: NSNumber, buySellMarkId: NSString) { + DispatchQueue.main.async { + guard let view = self.bridge?.uiManager.view(forReactTag: node) as? HTKLineContainerView else { + print("RNKLineView: Could not find HTKLineContainerView for node \(node)") + return + } + view.removeBuySellMark(buySellMarkId as String) + } + } + + @objc func updateBuySellMark(_ node: NSNumber, buySellMark: NSDictionary) { + DispatchQueue.main.async { + guard let view = self.bridge?.uiManager.view(forReactTag: node) as? HTKLineContainerView else { + print("RNKLineView: Could not find HTKLineContainerView for node \(node)") + return + } + view.updateBuySellMark(buySellMark) + } + } + + @objc func getBuySellMarks(_ node: NSNumber) -> NSArray { + guard let view = self.bridge?.uiManager.view(forReactTag: node) as? HTKLineContainerView else { + print("RNKLineView: Could not find HTKLineContainerView for node \(node)") + return NSArray() + } + return view.getBuySellMarks() + } + }