Skip to content

Commit 156fdf3

Browse files
committed
feat: add smooth freehand drawing with continuous drawing mode
- Add continuous drawing mode for overlays (click-drag-release pattern) - Implement smooth freehand drawing with sub-pixel precision using float indices - Add precise timestamp interpolation for drawings to persist across timeframe changes - Support drawing beyond the last candle (extrapolation into future) - Add freePath overlay for freehand drawing - Add lineCap/lineJoin support for smooth stroke rendering New methods in Store: - floatIndexToTimestamp(): converts float indices to interpolated timestamps - timestampToFloatIndex(): converts precise timestamps back to float indices
1 parent 0368f9d commit 156fdf3

8 files changed

Lines changed: 335 additions & 23 deletions

File tree

src/Event.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,19 @@ export default class Event implements EventHandler {
155155
return widget.dispatchEvent('mouseDownEvent', event)
156156
}
157157
case WidgetNameConstants.MAIN: {
158-
const yAxis = (pane as DrawPane<YAxis>).getAxisComponent()
159-
if (!yAxis.getAutoCalcTickFlag()) {
160-
const range = yAxis.getRange()
161-
this._prevYAxisRange = { ...range }
158+
// Dispatch event first to allow overlays (e.g., continuous drawing) to consume it
159+
const consumed = widget.dispatchEvent('mouseDownEvent', event)
160+
// Only start scrolling if the event was not consumed by an overlay
161+
if (!consumed) {
162+
const yAxis = (pane as DrawPane<YAxis>).getAxisComponent()
163+
if (!yAxis.getAutoCalcTickFlag()) {
164+
const range = yAxis.getRange()
165+
this._prevYAxisRange = { ...range }
166+
}
167+
this._startScrollCoordinate = { x: event.x, y: event.y }
168+
this._chart.getChartStore().startScroll()
162169
}
163-
this._startScrollCoordinate = { x: event.x, y: event.y }
164-
this._chart.getChartStore().startScroll()
165-
return widget.dispatchEvent('mouseDownEvent', event)
170+
return consumed
166171
}
167172
case WidgetNameConstants.X_AXIS: {
168173
return this._processXAxisScrollStartEvent(widget, event)
@@ -234,6 +239,9 @@ export default class Event implements EventHandler {
234239
const consumed = widget.dispatchEvent('pressedMouseMoveEvent', event)
235240
if (!consumed) {
236241
this._processMainScrollingEvent(widget as Widget<DrawPane<YAxis>>, event)
242+
} else {
243+
// Explicitly update overlay when event was consumed (e.g., continuous drawing)
244+
this._chart.updatePane(UpdateLevel.Overlay)
237245
}
238246
if (!consumed || widget.getForceCursor() === 'pointer') {
239247
crosshair = { x: event.x, y: event.y, paneId: pane?.getId() }

src/Store.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,140 @@ export default class StoreImp implements Store {
10271027
return Math.ceil(this.coordinateToFloatIndex(x)) - 1
10281028
}
10291029

1030+
/**
1031+
* Converts a float data index to an interpolated timestamp.
1032+
* This allows sub-bar precision for smooth freehand drawings.
1033+
* Supports extrapolation beyond the data range (drawing in the "future").
1034+
* @param floatIndex - A floating point index (e.g., 42.75)
1035+
* @returns An interpolated timestamp between two bars
1036+
*/
1037+
floatIndexToTimestamp (floatIndex: number): Nullable<number> {
1038+
const length = this._dataList.length
1039+
if (length === 0) {
1040+
return null
1041+
}
1042+
1043+
const lastIndex = length - 1
1044+
1045+
// Handle float index beyond the last bar (extrapolate into the future)
1046+
if (floatIndex > lastIndex && length >= 2) {
1047+
const lastTimestamp = this._dataList[lastIndex].timestamp
1048+
const secondLastTimestamp = this._dataList[lastIndex - 1].timestamp
1049+
const barDuration = lastTimestamp - secondLastTimestamp
1050+
if (barDuration > 0) {
1051+
const barsBeyondLast = floatIndex - lastIndex
1052+
return Math.round(lastTimestamp + barsBeyondLast * barDuration)
1053+
}
1054+
}
1055+
1056+
// Handle float index before the first bar (extrapolate into the past)
1057+
if (floatIndex < 0 && length >= 2) {
1058+
const firstTimestamp = this._dataList[0].timestamp
1059+
const secondTimestamp = this._dataList[1].timestamp
1060+
const barDuration = secondTimestamp - firstTimestamp
1061+
if (barDuration > 0) {
1062+
return Math.round(firstTimestamp + floatIndex * barDuration)
1063+
}
1064+
}
1065+
1066+
// Normal case: interpolate between two bars within the data range
1067+
const intIndex = Math.floor(floatIndex)
1068+
const fraction = floatIndex - intIndex
1069+
1070+
// Get timestamp at the integer index
1071+
const timestampAtInt = this.dataIndexToTimestamp(intIndex)
1072+
1073+
// If no fractional part, return the integer timestamp
1074+
if (fraction === 0 || !isNumber(timestampAtInt)) {
1075+
return timestampAtInt
1076+
}
1077+
1078+
// Get timestamp at the next index for interpolation
1079+
const timestampAtNext = this.dataIndexToTimestamp(intIndex + 1)
1080+
1081+
if (isNumber(timestampAtNext)) {
1082+
// Linear interpolation between the two timestamps
1083+
return Math.round(timestampAtInt + (timestampAtNext - timestampAtInt) * fraction)
1084+
}
1085+
1086+
return timestampAtInt
1087+
}
1088+
1089+
/**
1090+
* Converts a precise timestamp to a float data index.
1091+
* This preserves sub-bar precision for smooth freehand drawings across timeframe changes.
1092+
* Supports extrapolation beyond the data range (drawing in the "future").
1093+
* @param timestamp - A precise timestamp (possibly between or beyond bars)
1094+
* @returns A floating point index representing the exact position
1095+
*/
1096+
timestampToFloatIndex (timestamp: number): number {
1097+
const length = this._dataList.length
1098+
if (length === 0) {
1099+
return 0
1100+
}
1101+
1102+
const firstTimestamp = this._dataList[0].timestamp
1103+
const lastTimestamp = this._dataList[length - 1].timestamp
1104+
1105+
// Handle timestamp beyond the last bar (drawing in the "future")
1106+
if (timestamp > lastTimestamp && length >= 2) {
1107+
// Calculate average bar duration from the last two bars
1108+
const secondLastTimestamp = this._dataList[length - 2].timestamp
1109+
const barDuration = lastTimestamp - secondLastTimestamp
1110+
if (barDuration > 0) {
1111+
const timeBeyondLast = timestamp - lastTimestamp
1112+
const barsBeyond = timeBeyondLast / barDuration
1113+
return length - 1 + barsBeyond
1114+
}
1115+
}
1116+
1117+
// Handle timestamp before the first bar
1118+
if (timestamp < firstTimestamp && length >= 2) {
1119+
const secondTimestamp = this._dataList[1].timestamp
1120+
const barDuration = secondTimestamp - firstTimestamp
1121+
if (barDuration > 0) {
1122+
const timeBeforeFirst = firstTimestamp - timestamp
1123+
const barsBefore = timeBeforeFirst / barDuration
1124+
return -barsBefore
1125+
}
1126+
}
1127+
1128+
// Find the floor bar index using binary search
1129+
// We need the bar where barTimestamp <= timestamp < nextBarTimestamp
1130+
let left = 0
1131+
let right = length - 1
1132+
let floorIndex = 0
1133+
1134+
while (left <= right) {
1135+
const mid = Math.floor((left + right) / 2)
1136+
const midTimestamp = this._dataList[mid].timestamp
1137+
1138+
if (midTimestamp <= timestamp) {
1139+
floorIndex = mid
1140+
left = mid + 1
1141+
} else {
1142+
right = mid - 1
1143+
}
1144+
}
1145+
1146+
// Get the floor bar and the next bar for interpolation
1147+
const dataAtFloor = this._dataList[floorIndex]
1148+
const dataAtNext = floorIndex + 1 < length ? this._dataList[floorIndex + 1] : null
1149+
1150+
if (isValid(dataAtFloor) && isValid(dataAtNext)) {
1151+
const timestampAtFloor = dataAtFloor.timestamp
1152+
const timestampAtNext = dataAtNext.timestamp
1153+
1154+
// Calculate fractional position between the two bars
1155+
if (timestamp >= timestampAtFloor && timestampAtNext > timestampAtFloor) {
1156+
const fraction = (timestamp - timestampAtFloor) / (timestampAtNext - timestampAtFloor)
1157+
return floorIndex + Math.min(fraction, 1) // Clamp to max 1
1158+
}
1159+
}
1160+
1161+
return floorIndex
1162+
}
1163+
10301164
zoom (scale: number, coordinate: Nullable<Partial<Coordinate>>, position: 'main' | 'xAxis'): void {
10311165
if (!this._zoomEnabled) {
10321166
return
@@ -1595,7 +1729,11 @@ export default class StoreImp implements Store {
15951729
}
15961730

15971731
isOverlayDrawing (): boolean {
1598-
return this._progressOverlayInfo?.overlay.isDrawing() ?? false
1732+
const info = this._progressOverlayInfo
1733+
if (info !== null) {
1734+
return info.overlay.isDrawing()
1735+
}
1736+
return false
15991737
}
16001738

16011739
private _clearLastPriceMarkExtendTextUpdateTimer (): void {

src/component/Overlay.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ export interface OverlayPerformEventParams {
3737
performPoint: Partial<Point>
3838
}
3939

40+
/**
41+
* Drawing mode for overlays
42+
* - 'step': Traditional click-based drawing (default)
43+
* - 'continuous': Freehand drawing with mouse down, move, up
44+
*/
45+
export type OverlayDrawingMode = 'step' | 'continuous'
46+
4047
export interface OverlayEventCollection<E> {
4148
onDrawStart: Nullable<OverlayEventCallback<E>>
4249
onDrawing: Nullable<OverlayEventCallback<E>>
@@ -124,6 +131,11 @@ export interface Overlay<E = unknown> extends OverlayEventCollection<E> {
124131
*/
125132
currentStep: number
126133

134+
/**
135+
* Drawing mode: 'step' for click-based, 'continuous' for freehand
136+
*/
137+
drawingMode: OverlayDrawingMode
138+
127139
/**
128140
* Whether it is locked. When it is true, it will not respond to events
129141
*/
@@ -229,6 +241,7 @@ export default class OverlayImp<E = unknown> implements Overlay<E> {
229241
name: string
230242
totalStep = 1
231243
currentStep = OVERLAY_DRAW_STEP_START
244+
drawingMode: OverlayDrawingMode = 'step'
232245
lock = false
233246
visible = true
234247
zLevel = 0
@@ -237,7 +250,7 @@ export default class OverlayImp<E = unknown> implements Overlay<E> {
237250
needDefaultYAxisFigure = false
238251
mode: OverlayMode = 'normal'
239252
modeSensitivity = 8
240-
points: Array<Partial<Omit<Point, 'dataIndex'>>> = []
253+
points: Array<Partial<Point>> = []
241254
extendData: E
242255
styles: Nullable<DeepPartial<OverlayStyle>> = null
243256
createPointFigures: Nullable<OverlayCreateFiguresCallback<E>> = null
@@ -372,6 +385,44 @@ export default class OverlayImp<E = unknown> implements Overlay<E> {
372385
return this.currentStep === OVERLAY_DRAW_STEP_START
373386
}
374387

388+
isContinuousDrawing (): boolean {
389+
return this.drawingMode === 'continuous'
390+
}
391+
392+
/**
393+
* Add a point during continuous drawing mode
394+
*/
395+
addPointForContinuousDrawing (point: Partial<Point>): boolean {
396+
const newPoint: Partial<Point> = {}
397+
if (isNumber(point.timestamp)) {
398+
newPoint.timestamp = point.timestamp
399+
}
400+
if (isNumber(point.dataIndex)) {
401+
newPoint.dataIndex = point.dataIndex
402+
}
403+
if (isNumber(point.value)) {
404+
newPoint.value = point.value
405+
}
406+
this.points.push(newPoint)
407+
return true
408+
}
409+
410+
/**
411+
* Start continuous drawing - set first point
412+
*/
413+
startContinuousDrawing (point: Partial<Point>): void {
414+
this.points = []
415+
this.addPointForContinuousDrawing(point)
416+
this.currentStep = 2 // Mark as actively drawing
417+
}
418+
419+
/**
420+
* Complete continuous drawing
421+
*/
422+
completeContinuousDrawing (): void {
423+
this.currentStep = OVERLAY_DRAW_STEP_FINISHED
424+
}
425+
375426
eventMoveForDrawing (point: Partial<Point>): void {
376427
const pointIndex = this.currentStep - 1
377428
const newPoint: Partial<Point> = {}

src/extension/figure/line.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,27 @@ export function lineTo (ctx: CanvasRenderingContext2D, coordinates: Coordinate[]
138138
}
139139
}
140140

141-
export function drawLine (ctx: CanvasRenderingContext2D, attrs: LineAttrs | LineAttrs[], styles: Partial<SmoothLineStyle>): void {
141+
export function drawLine (ctx: CanvasRenderingContext2D, attrs: LineAttrs | LineAttrs[], styles: Partial<SmoothLineStyle> & { lineCap?: CanvasLineCap, lineJoin?: CanvasLineJoin }): void {
142142
let lines: LineAttrs[] = []
143143
lines = lines.concat(attrs)
144-
const { style = 'solid', smooth = false, size = 1, color = 'currentColor', dashedValue = [2, 2] } = styles
144+
const { style = 'solid', smooth = false, size = 1, color = 'currentColor', dashedValue = [2, 2], lineCap, lineJoin } = styles
145145
ctx.lineWidth = size
146146
ctx.strokeStyle = color
147+
// Use explicit lineCap/lineJoin if provided, otherwise default based on smooth
148+
if (lineCap !== undefined) {
149+
ctx.lineCap = lineCap
150+
} else if (smooth !== false && smooth !== 0) {
151+
ctx.lineCap = 'round'
152+
} else {
153+
ctx.lineCap = 'butt'
154+
}
155+
if (lineJoin !== undefined) {
156+
ctx.lineJoin = lineJoin
157+
} else if (smooth !== false && smooth !== 0) {
158+
ctx.lineJoin = 'round'
159+
} else {
160+
ctx.lineJoin = 'miter'
161+
}
147162
if (style === 'dashed') {
148163
ctx.setLineDash(dashedValue)
149164
} else {

src/extension/overlay/freePath.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
15+
import type { OverlayTemplate } from '../../component/Overlay'
16+
17+
/**
18+
* Free path overlay - freehand drawing with click and drag
19+
* Uses continuous drawing mode for smooth path creation
20+
*/
21+
const freePath: OverlayTemplate = {
22+
name: 'freePath',
23+
totalStep: 2,
24+
drawingMode: 'continuous',
25+
needDefaultPointFigure: false,
26+
needDefaultXAxisFigure: false,
27+
needDefaultYAxisFigure: false,
28+
createPointFigures: ({ coordinates }) => {
29+
if (coordinates.length < 2) {
30+
return []
31+
}
32+
return [
33+
{
34+
type: 'line',
35+
attrs: { coordinates },
36+
styles: {
37+
smooth: false,
38+
lineCap: 'round',
39+
lineJoin: 'round'
40+
}
41+
}
42+
]
43+
}
44+
}
45+
46+
export default freePath

src/extension/overlay/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@ import verticalStraightLine from './verticalStraightLine'
3232

3333
import simpleAnnotation from './simpleAnnotation'
3434
import simpleTag from './simpleTag'
35+
import freePath from './freePath'
3536

3637
const overlays: Record<string, OverlayInnerConstructor> = {}
3738

3839
const extensions = [
3940
fibonacciLine, horizontalRayLine, horizontalSegment, horizontalStraightLine,
4041
parallelStraightLine, priceChannelLine, priceLine, rayLine, segment,
4142
straightLine, verticalRayLine, verticalSegment, verticalStraightLine,
42-
simpleAnnotation, simpleTag
43+
simpleAnnotation, simpleTag, freePath
4344
]
4445

4546
extensions.forEach((template: OverlayTemplate) => {

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import {
4646
import { calcTextWidth } from './common/utils/canvas'
4747
import type { ActionType } from './common/Action'
4848
import type { IndicatorSeries } from './component/Indicator'
49-
import type { OverlayMode } from './component/Overlay'
49+
import type { OverlayMode, OverlayDrawingMode } from './component/Overlay'
5050

5151
import type { FormatDateType, Options, ZoomAnchor } from './Options'
5252
import ChartImp, { type Chart, type DomPosition } from './Chart'
@@ -175,5 +175,5 @@ export {
175175
utils,
176176
type LineType, type PolygonType, type TooltipShowRule, type TooltipShowType, type FeatureType, type TooltipFeaturePosition, type CandleTooltipRectPosition,
177177
type CandleType, type FormatDateType, type ZoomAnchor,
178-
type DomPosition, type ActionType, type IndicatorSeries, type OverlayMode
178+
type DomPosition, type ActionType, type IndicatorSeries, type OverlayMode, type OverlayDrawingMode
179179
}

0 commit comments

Comments
 (0)