diff --git a/common/changes/@visactor/vtable/feat-gantt-autoLocationIcon_2026-03-25-03-56.json b/common/changes/@visactor/vtable/feat-gantt-autoLocationIcon_2026-03-25-03-56.json new file mode 100644 index 000000000..9f994c0b8 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-gantt-autoLocationIcon_2026-03-25-03-56.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "feat: gantt add locateIcon for taskbar\n\n", + "type": "none", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} \ No newline at end of file diff --git a/docs/assets/demo/en/gantt/gantt-locate-taskbar.md b/docs/assets/demo/en/gantt/gantt-locate-taskbar.md new file mode 100644 index 000000000..6fad2d3b8 --- /dev/null +++ b/docs/assets/demo/en/gantt/gantt-locate-taskbar.md @@ -0,0 +1,77 @@ +--- +category: examples +group: gantt +title: Task Bar Locate (Offscreen Indicator) +cover: https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/gantt/gantt-locate-taskbar.gif +link: gantt/Getting_Started +option: Gantt#taskBar +--- + +# Task Bar Locate (Offscreen Indicator) + +When the timeline is long, task bars may be outside the current viewport. This demo shows how to enable the locate icon feature: when a task bar is horizontally outside the viewport, an icon is displayed at the left/right edge of the gantt view; hover highlights it, and click scrolls the task bar into view. + +## Key Option + +- `taskBar.locateIcon: true` + +## Live Demo + +```javascript livedemo template=vtable +// import * as VTableGantt from '@visactor/vtable-gantt'; +let ganttInstance; + +const records = [ + { id: 1, title: 'Offscreen on the left', start: '2024-02-05', end: '2024-02-20', progress: 20 }, + { id: 2, title: 'Offscreen on the left', start: '2024-03-10', end: '2024-03-18', progress: 60 }, + { id: 5, title: 'Visible in viewport', start: '2024-05-28', end: '2024-06-05', progress: 50 }, + { id: 3, title: 'Offscreen on the right', start: '2024-10-05', end: '2024-10-20', progress: 40 }, + { id: 4, title: 'Offscreen on the right', start: '2024-11-10', end: '2024-11-25', progress: 80 } +]; + +const columns = [ + { field: 'title', title: 'title', width: 160, sort: true }, + { field: 'start', title: 'start', width: 120, sort: true }, + { field: 'end', title: 'end', width: 120, sort: true }, + { field: 'progress', title: 'progress', width: 100, sort: true } +]; + +const option = { + records, + taskKeyField: 'id', + taskListTable: { + columns, + tableWidth: 280, + minTableWidth: 240, + maxTableWidth: 600 + }, + taskBar: { + startDateField: 'start', + endDateField: 'end', + progressField: 'progress', + locateIcon: true + }, + minDate: '2024-01-01', + maxDate: '2024-12-31', + timelineHeader: { + colWidth: 30, + scales: [{ unit: 'day', step: 1 }] + }, + scrollStyle: { + visible: 'scrolling' + }, + grid: { + verticalLine: { lineWidth: 1, lineColor: '#e1e4e8' }, + horizontalLine: { lineWidth: 1, lineColor: '#e1e4e8' } + } +}; + +ganttInstance = new VTableGantt.Gantt(document.getElementById(CONTAINER_ID), option); +window['ganttInstance'] = ganttInstance; + +setTimeout(() => { + const x = ganttInstance.getXByTime(new Date('2024-06-01 00:00:00').getTime()); + ganttInstance.scrollLeft = x; +}, 0); +``` + diff --git a/docs/assets/demo/menu.json b/docs/assets/demo/menu.json index 467d6ecf8..4da642d8c 100644 --- a/docs/assets/demo/menu.json +++ b/docs/assets/demo/menu.json @@ -314,6 +314,13 @@ "en": "Gantt Basic" } }, + { + "path": "gantt-locate-taskbar", + "title": { + "zh": "任务条定位(超出可视区)", + "en": "Task Bar Locate" + } + }, { "path": "gantt-customLayout", "title": { diff --git a/docs/assets/demo/zh/gantt/gantt-locate-taskbar.md b/docs/assets/demo/zh/gantt/gantt-locate-taskbar.md new file mode 100644 index 000000000..3a5710661 --- /dev/null +++ b/docs/assets/demo/zh/gantt/gantt-locate-taskbar.md @@ -0,0 +1,77 @@ +--- +category: examples +group: gantt +title: 甘特图任务条定位(超出可视区提示) +cover: https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/gantt/gantt-locate-taskbar.gif +link: gantt/Getting_Started +option: Gantt#taskBar +--- + +# 甘特图任务条定位(超出可视区提示) + +当时间轴很长时,任务条可能不在当前可视区域内。本示例展示如何开启定位图标能力:当任务条横向超出可视区域时,在甘特图左右边缘显示定位图标;鼠标 hover 会高亮,点击后会自动滚动将任务条带入可视区域。 + +## 关键配置 + +- `taskBar.locateIcon: true` + +## 代码演示 + +```javascript livedemo template=vtable +// import * as VTableGantt from '@visactor/vtable-gantt'; +let ganttInstance; + +const records = [ + { id: 1, title: '任务条在左侧不可见', start: '2024-02-05', end: '2024-02-20', progress: 20 }, + { id: 2, title: '任务条在左侧不可见', start: '2024-03-10', end: '2024-03-18', progress: 60 }, + { id: 5, title: '任务条在可见区', start: '2024-05-28', end: '2024-06-05', progress: 50 }, + { id: 3, title: '任务条在右侧不可见', start: '2024-10-05', end: '2024-10-20', progress: 40 }, + { id: 4, title: '任务条在右侧不可见', start: '2024-11-10', end: '2024-11-25', progress: 80 } +]; + +const columns = [ + { field: 'title', title: 'title', width: 160, sort: true }, + { field: 'start', title: 'start', width: 120, sort: true }, + { field: 'end', title: 'end', width: 120, sort: true }, + { field: 'progress', title: 'progress', width: 100, sort: true } +]; + +const option = { + records, + taskKeyField: 'id', + taskListTable: { + columns, + tableWidth: 280, + minTableWidth: 240, + maxTableWidth: 600 + }, + taskBar: { + startDateField: 'start', + endDateField: 'end', + progressField: 'progress', + locateIcon: true + }, + minDate: '2024-01-01', + maxDate: '2024-12-31', + timelineHeader: { + colWidth: 30, + scales: [{ unit: 'day', step: 1 }] + }, + scrollStyle: { + visible: 'scrolling' + }, + grid: { + verticalLine: { lineWidth: 1, lineColor: '#e1e4e8' }, + horizontalLine: { lineWidth: 1, lineColor: '#e1e4e8' } + } +}; + +ganttInstance = new VTableGantt.Gantt(document.getElementById(CONTAINER_ID), option); +window['ganttInstance'] = ganttInstance; + +setTimeout(() => { + const x = ganttInstance.getXByTime(new Date('2024-06-01 00:00:00').getTime()); + ganttInstance.scrollLeft = x; +}, 0); +``` + diff --git a/docs/assets/guide/en/gantt/introduction.md b/docs/assets/guide/en/gantt/introduction.md index 8e8f87143..9042c1dc8 100644 --- a/docs/assets/guide/en/gantt/introduction.md +++ b/docs/assets/guide/en/gantt/introduction.md @@ -133,6 +133,20 @@ You can set whether task bars are resizable through the `taskBar.resizable` conf You can set whether task bars are adjustable through the `taskBar.progressAdjustable` configuration item. +#### Task Bar Locate + +When the timeline is long and the task bar is outside the current viewport, you can enable the “locate icon” feature: an icon is shown on the left/right edge of the gantt view, and clicking it scrolls the task bar into the viewport. + +Key option: + +```javascript +const option = { + taskBar: { + locateIcon: true + } +}; +``` + #### Adjusting the Width of the Left Table You can set the divider line to be draggable by configuring `frame.verticalSplitLineMoveable` to true. diff --git a/docs/assets/guide/zh/gantt/introduction.md b/docs/assets/guide/zh/gantt/introduction.md index e9836d0cc..401699ccf 100644 --- a/docs/assets/guide/zh/gantt/introduction.md +++ b/docs/assets/guide/zh/gantt/introduction.md @@ -133,6 +133,20 @@ links:[ 通过 `taskBar.progressAdjustable` 配置项,可以设置任务条是否可调整进度。 +#### 任务条定位 + +当时间轴较长、任务条不在当前可视区域内时,可以开启“定位图标”能力:在甘特图左右边缘显示图标,点击后自动滚动到该任务条的可视区域。 + +关键配置: + +```javascript +const option = { + taskBar: { + locateIcon: true + } +}; +``` + #### 调整左侧表格宽度 通过 `frame.verticalSplitLineMoveable` 配置为 true,可以设置分割线可拖拽。 diff --git a/docs/assets/option/en/common/gantt/task-bar.md b/docs/assets/option/en/common/gantt/task-bar.md index 447f27152..6a9b6e72f 100644 --- a/docs/assets/option/en/common/gantt/task-bar.md +++ b/docs/assets/option/en/common/gantt/task-bar.md @@ -171,6 +171,12 @@ Whether the service clause is optional, the default is true Not required +${prefix} locateIcon(boolean) = false + +When the task bar is horizontally outside the current viewport, show a “locate icon” at the left/right edge of the gantt view. Hover highlights the icon, and click scrolls the task bar into the viewport. + +Optional + ${prefix} scheduleCreatable(boolean | Function) = true When there is no scheduling data, scheduling can be done by creating a task bar. When `tasksShowMode` is `TasksShowMode.Tasks_Separate` or `TasksShowMode.Sub_Tasks_Separate`, `scheduleCreatable` defaults to `true`, otherwise, when `tasksShowMode` is `TasksShowMode.Sub_Tasks_Inline`, `TasksShowMode.Sub_Tasks_Arrange`, or `TasksShowMode.Sub_Tasks_Compact`, `scheduleCreatable` defaults to `false`. @@ -267,4 +273,3 @@ Vertical position of the baseline bar relative to the main task bar: - `overlap`: baseline overlaps and is centered with the task bar. Optional - diff --git a/docs/assets/option/zh/common/gantt/task-bar.md b/docs/assets/option/zh/common/gantt/task-bar.md index d27d462d7..446434e89 100644 --- a/docs/assets/option/zh/common/gantt/task-bar.md +++ b/docs/assets/option/zh/common/gantt/task-bar.md @@ -176,6 +176,12 @@ ${prefix} selectable(boolean) 非必填 +${prefix} locateIcon(boolean) = false + +当任务条在横向上不在当前可视区域内时,在甘特图左右边缘展示“定位图标”;鼠标 hover 会高亮,点击后会一键滚动到任务条可视区域内。 + +非必填 + ${prefix} scheduleCreatable(boolean | Function) = true 数据没有排期时,可通过创建任务条排期。当 tasksShowMode 为 TasksShowMode.Tasks_Separate 或 TasksShowMode.Sub_Tasks_Separate 时 `scheduleCreatable` 默认为 true,其他情况即当 tasksShowMode 为 TasksShowMode.Sub_Tasks_Inline 或 TasksShowMode.Sub_Tasks_Arrange 或 TasksShowMode.Sub_Tasks_Compact 时 `scheduleCreatable` 默认为 false @@ -273,4 +279,3 @@ ${prefix} baselinePosition('top' | 'bottom' | 'overlap') = 'bottom' - `overlap`:基线与主任务条重叠居中。 非必填 - diff --git a/packages/vtable-gantt/examples/gantt/gantt-locate-taskbar.ts b/packages/vtable-gantt/examples/gantt/gantt-locate-taskbar.ts new file mode 100644 index 000000000..20a933396 --- /dev/null +++ b/packages/vtable-gantt/examples/gantt/gantt-locate-taskbar.ts @@ -0,0 +1,67 @@ +import type { ColumnsDefine } from '@visactor/vtable'; +import type { GanttConstructorOptions } from '../../src/index'; +import { Gantt } from '../../src/index'; + +const CONTAINER_ID = 'vTable'; + +export function createTable() { + const records = [ + { id: 1, title: '任务条在左侧不可见', start: '2024-02-05', end: '2024-02-20', progress: 20 }, + { id: 2, title: '任务条在左侧不可见', start: '2024-03-10', end: '2024-03-18', progress: 60 }, + { id: 5, title: '任务条在可见区', start: '2024-05-28', end: '2024-06-05', progress: 50 }, + { id: 3, title: '任务条在右侧不可见', start: '2024-10-05', end: '2024-10-20', progress: 40 }, + { id: 4, title: '任务条在右侧不可见', start: '2024-11-10', end: '2024-11-25', progress: 80 } + ]; + + const columns: ColumnsDefine = [ + { field: 'title', title: 'title', width: 160, sort: true }, + { field: 'start', title: 'start', width: 120, sort: true }, + { field: 'end', title: 'end', width: 120, sort: true }, + { field: 'progress', title: 'progress', width: 100, sort: true } + ]; + + const option: GanttConstructorOptions = { + records, + taskListTable: { + columns, + tableWidth: 280, + minTableWidth: 240, + maxTableWidth: 600 + }, + taskKeyField: 'id', + taskBar: { + startDateField: 'start', + endDateField: 'end', + progressField: 'progress', + locateIcon: true + }, + minDate: '2024-01-01', + maxDate: '2024-12-31', + timelineHeader: { + colWidth: 30, + scales: [{ unit: 'day', step: 1 }] + }, + scrollStyle: { + visible: 'scrolling' + }, + grid: { + // backgroundColor: 'gray', + verticalLine: { + lineWidth: 1, + lineColor: '#e1e4e8' + }, + horizontalLine: { + lineWidth: 1, + lineColor: '#e1e4e8' + } + } + }; + + const ganttInstance = new Gantt(document.getElementById(CONTAINER_ID)!, option); + (window as any).ganttInstance = ganttInstance; + + setTimeout(() => { + const x = ganttInstance.getXByTime(new Date('2024-06-01 00:00:00').getTime()); + ganttInstance.scrollLeft = x; + }, 0); +} diff --git a/packages/vtable-gantt/examples/menu.ts b/packages/vtable-gantt/examples/menu.ts index ce9d8a3b6..727abebef 100644 --- a/packages/vtable-gantt/examples/menu.ts +++ b/packages/vtable-gantt/examples/menu.ts @@ -170,6 +170,10 @@ export const menus = [ { path: 'gantt', name: 'project-sub-tasks-inline' + }, + { + path: 'gantt', + name: 'gantt-locate-taskbar' } // ] // } diff --git a/packages/vtable-gantt/src/Gantt.ts b/packages/vtable-gantt/src/Gantt.ts index efb1bbced..3e45afe08 100644 --- a/packages/vtable-gantt/src/Gantt.ts +++ b/packages/vtable-gantt/src/Gantt.ts @@ -165,6 +165,7 @@ export class Gantt extends EventTarget { tasksShowMode: TasksShowMode; projectSubTasksExpandable: boolean; taskBarClip: boolean; + taskBarLocateIcon: boolean; startDateField: string; endDateField: string; diff --git a/packages/vtable-gantt/src/event/event-manager.ts b/packages/vtable-gantt/src/event/event-manager.ts index dec71970d..b02f464ef 100644 --- a/packages/vtable-gantt/src/event/event-manager.ts +++ b/packages/vtable-gantt/src/event/event-manager.ts @@ -1,5 +1,5 @@ import { vglobal } from '@visactor/vtable/es/vrender'; -import type { FederatedPointerEvent, FederatedWheelEvent } from '@visactor/vtable/es/vrender'; +import type { FederatedPointerEvent, FederatedWheelEvent, Group } from '@visactor/vtable/es/vrender'; import type { Gantt } from '../Gantt'; import { EventHandler } from '../event/EventHandler'; import { handleWhell } from '../event/scroll'; @@ -220,6 +220,19 @@ function bindTableGroupListener(event: EventManager) { event.touchSetTimeout = undefined; } if (stateManager.interactionState === InteractionState.default) { + let locateIconTarget: Group | null = null; + if (gantt.parsedOptions.taskBarLocateIcon) { + // 优先处理定位图标 hover:避免与任务条 hover 互相抢占状态 + locateIconTarget = e.detailPath.find((pathNode: any) => { + return pathNode.name === 'task-bar-locate-icon-left' || pathNode.name === 'task-bar-locate-icon-right'; + }) as any as Group; + if (locateIconTarget) { + scene._gantt.scenegraph.taskBar.setLocateIconHover(locateIconTarget); + } else if (scene._gantt.scenegraph.taskBar.currentHoverLocateIcon) { + scene._gantt.scenegraph.taskBar.setLocateIconHover(null); + } + } + const taskBarTarget = e.detailPath.find((pathNode: any) => { return pathNode.name === 'task-bar'; // || pathNode.name === 'task-bar-hover-shadow'; }); @@ -267,7 +280,7 @@ function bindTableGroupListener(event: EventManager) { }); } } - } else { + } else if (!locateIconTarget) { if (scene._gantt.stateManager.hoverTaskBar.target) { if (scene._gantt.hasListeners(GANTT_EVENT_TYPE.MOUSELEAVE_TASK_BAR)) { // const taskIndex = getTaskIndexByY(e.offset.y, scene._gantt); @@ -445,8 +458,10 @@ function bindTableGroupListener(event: EventManager) { let depedencyLink; let isClickMarklineIcon = false; let isClickMarklineContent = false; + let isClickLocateIcon = false; let markLineContentTarget: any; let markLineIconTarget: any; + let locateIconTarget: any; const taskBarTarget = e.detailPath.find((pathNode: any) => { if (pathNode.name === 'task-bar') { @@ -476,11 +491,30 @@ function bindTableGroupListener(event: EventManager) { isClickMarklineContent = true; markLineContentTarget = pathNode; return false; + } else if ( + gantt.parsedOptions.taskBarLocateIcon && + (pathNode.name === 'task-bar-locate-icon-left' || pathNode.name === 'task-bar-locate-icon-right') + ) { + isClickLocateIcon = true; + locateIconTarget = pathNode; + return false; } return false; }); - if (isClickBar && scene._gantt.parsedOptions.taskBarSelectable && event.poniterState === 'down') { + if (isClickLocateIcon && event.poniterState === 'down') { + // 点击定位图标:将任务条滚动到可视区(左右边缘附近),便于快速定位长时间轴任务 + const barNode = locateIconTarget?.attachedToTaskBarNode as GanttTaskBarNode; + const side = locateIconTarget?.side as 'left' | 'right'; + if (barNode) { + const barLeft = barNode.attribute.x; + const barRight = barLeft + barNode.attribute.width; + const viewWidth = gantt.tableNoFrameWidth; + const padding = 12; + const targetLeft = side === 'left' ? barLeft - padding : barRight - viewWidth + padding; + gantt.stateManager.setScrollLeft(targetLeft); + } + } else if (isClickBar && scene._gantt.parsedOptions.taskBarSelectable && event.poniterState === 'down') { stateManager.hideDependencyLinkSelectedLine(); const taskBarNode = taskBarTarget as any as GanttTaskBarNode; stateManager.showTaskBarSelectedBorder(taskBarNode); @@ -678,6 +712,9 @@ function bindTableGroupListener(event: EventManager) { }); scene.ganttGroup.addEventListener('pointerleave', (e: FederatedPointerEvent) => { + if (scene._gantt.scenegraph.taskBar.currentHoverLocateIcon) { + scene._gantt.scenegraph.taskBar.setLocateIconHover(null); + } if ( (gantt.parsedOptions.scrollStyle.horizontalVisible && gantt.parsedOptions.scrollStyle.horizontalVisible === 'focus') || diff --git a/packages/vtable-gantt/src/gantt-helper.ts b/packages/vtable-gantt/src/gantt-helper.ts index 87f347345..47f09f056 100644 --- a/packages/vtable-gantt/src/gantt-helper.ts +++ b/packages/vtable-gantt/src/gantt-helper.ts @@ -136,6 +136,8 @@ export function initOptions(gantt: Gantt) { gantt.parsedOptions.baselineEndDateField = options.taskBar?.baselineEndDateField; gantt.parsedOptions.baselinePosition = options.taskBar?.baselinePosition ?? 'bottom'; gantt.parsedOptions.taskBarClip = options?.taskBar?.clip ?? true; + // 是否开启“任务条超出可视区”的定位图标能力(默认关闭) + gantt.parsedOptions.taskBarLocateIcon = options?.taskBar?.locateIcon ?? false; gantt.parsedOptions.projectSubTasksExpandable = options?.projectSubTasksExpandable ?? true; // gantt.parsedOptions.minDate = options?.minDate // ? gantt.parsedOptions.timeScaleIncludeHour diff --git a/packages/vtable-gantt/src/scenegraph/task-bar.ts b/packages/vtable-gantt/src/scenegraph/task-bar.ts index 4e957d2b0..244f47737 100644 --- a/packages/vtable-gantt/src/scenegraph/task-bar.ts +++ b/packages/vtable-gantt/src/scenegraph/task-bar.ts @@ -12,6 +12,12 @@ const TASKBAR_HOVER_ICON = ` `; export const TASKBAR_HOVER_ICON_WIDTH = 10; +const LOCATE_ICON_SIZE = 22; +const LOCATE_ICON_PADDING = 4; +const LOCATE_ICON_BG = '#f2f3f5'; +const LOCATE_ICON_BG_HOVER = '#4080ff'; +const LOCATE_ICON_ARROW = '#4e5969'; +const LOCATE_ICON_ARROW_HOVER = '#ffffff'; export class TaskBar { formatMilestoneText(text: string, record: any): string { @@ -98,6 +104,8 @@ export class TaskBar { hoverBarLeftIcon: Image; hoverBarRightIcon: Image; hoverBarProgressHandle: Group; + locateIconsGroup?: Group; + currentHoverLocateIcon: Group | null; _scene: Scenegraph; width: number; height: number; @@ -119,6 +127,10 @@ export class TaskBar { scene.ganttGroup.addChild(this.group); this.initBars(); this.initHoverBarIcons(); + if (scene._gantt.parsedOptions.taskBarLocateIcon) { + // 定位图标层:用于提示“任务条在当前可视区外”,并支持一键滚动定位 + this.initLocateIconsGroup(); + } } initBars() { @@ -525,6 +537,7 @@ export class TaskBar { if (baselineBar) { this.barContainer.insertBefore(baselineBar, barGroupBox); } + this.updateOffscreenIndicators(); } initHoverBarIcons() { const hoverBarGroup = new Group({ @@ -602,11 +615,176 @@ export class TaskBar { hoverBarGroup.appendChild(progressHandle); } + initLocateIconsGroup() { + // 覆盖在任务条区域之上(clip = true),仅用于绘制定位图标,避免受任务条容器滚动影响 + const locateIconsGroup = new Group({ + x: 0, + y: 0, + width: this.width, + height: this.height, + clip: true, + pickable: false + }); + this.locateIconsGroup = locateIconsGroup; + locateIconsGroup.name = 'task-bar-locate-icons'; + this.group.appendChild(locateIconsGroup); + } + + applyLocateIconStyle(icon: Group, hover: boolean) { + const background = (icon as any).background; + const arrow = (icon as any).arrow; + if (background) { + background.setAttribute('fill', hover ? LOCATE_ICON_BG_HOVER : LOCATE_ICON_BG); + } + if (arrow) { + arrow.setAttribute('fill', hover ? LOCATE_ICON_ARROW_HOVER : LOCATE_ICON_ARROW); + } + } + + createLocateIcon(side: 'left' | 'right', target: GanttTaskBarNode) { + const iconGroup = new Group({ + x: 0, + y: 0, + width: LOCATE_ICON_SIZE, + height: LOCATE_ICON_SIZE, + pickable: true, + cursor: 'pointer', + visibleAll: false + }); + iconGroup.name = side === 'left' ? 'task-bar-locate-icon-left' : 'task-bar-locate-icon-right'; + (iconGroup as any).attachedToTaskBarNode = target; + (iconGroup as any).side = side; + const background = createRect({ + x: 0, + y: 0, + width: LOCATE_ICON_SIZE, + height: LOCATE_ICON_SIZE, + cornerRadius: 4, + fill: LOCATE_ICON_BG, + pickable: false + }); + const arrowSize = 6; + const center = LOCATE_ICON_SIZE / 2; + const arrow = + side === 'left' + ? new Polygon({ + points: [ + { x: center + arrowSize / 2, y: center - arrowSize }, + { x: center - arrowSize / 2, y: center }, + { x: center + arrowSize / 2, y: center + arrowSize } + ], + fill: LOCATE_ICON_ARROW, + pickable: false + }) + : new Polygon({ + points: [ + { x: center - arrowSize / 2, y: center - arrowSize }, + { x: center + arrowSize / 2, y: center }, + { x: center - arrowSize / 2, y: center + arrowSize } + ], + fill: LOCATE_ICON_ARROW, + pickable: false + }); + iconGroup.appendChild(background); + iconGroup.appendChild(arrow); + (iconGroup as any).background = background; + (iconGroup as any).arrow = arrow; + this.applyLocateIconStyle(iconGroup, false); + return iconGroup; + } + + setLocateIconHover(icon: Group | null) { + if (this.currentHoverLocateIcon && this.currentHoverLocateIcon !== icon) { + this.applyLocateIconStyle(this.currentHoverLocateIcon, false); + } + if (icon) { + this.applyLocateIconStyle(icon, true); + } + this.currentHoverLocateIcon = icon; + this._scene.updateNextFrame(); + } + + updateOffscreenIndicators() { + if (!this.locateIconsGroup) { + return; + } + // 任务条相对 barContainer 的坐标系:与滚动值一致(scrollLeft / scrollTop) + const gantt = this._scene._gantt; + const scrollLeft = gantt.stateManager.scrollLeft; + const scrollTop = gantt.stateManager.scrollTop; + const viewWidth = gantt.tableNoFrameWidth; + const viewHeight = this.height; + const visibleLeft = scrollLeft; + const visibleRight = scrollLeft + viewWidth; + const visibleTop = scrollTop; + const visibleBottom = scrollTop + viewHeight; + + let child = this.barContainer.firstChild as any; + while (child) { + if (child.name === 'task-bar') { + const bar = child as GanttTaskBarNode; + const barLeft = bar.attribute.x; + const barRight = barLeft + bar.attribute.width; + const barTop = bar.attribute.y; + const barBottom = barTop + bar.attribute.height; + // 仅当该行在纵向可视范围内时,才展示横向定位图标 + const verticalVisible = barBottom >= visibleTop && barTop <= visibleBottom; + let side: 'left' | 'right' | null = null; + if (verticalVisible) { + if (barRight < visibleLeft) { + side = 'left'; + } else if (barLeft > visibleRight) { + side = 'right'; + } + } + const leftIcon = (bar as any).locateLeftIcon as Group; + const rightIcon = (bar as any).locateRightIcon as Group; + if (!side) { + // 使用 visibleAll 关闭整组显隐(包含子图形),避免只隐藏 group 导致残留 + leftIcon?.setAttribute('visibleAll', false); + rightIcon?.setAttribute('visibleAll', false); + if (this.currentHoverLocateIcon === leftIcon || this.currentHoverLocateIcon === rightIcon) { + this.setLocateIconHover(null); + } + } else { + let icon = side === 'left' ? leftIcon : rightIcon; + if (!icon) { + icon = this.createLocateIcon(side, bar); + if (side === 'left') { + (bar as any).locateLeftIcon = icon; + } else { + (bar as any).locateRightIcon = icon; + } + this.locateIconsGroup.appendChild(icon); + } else if (icon.parent !== this.locateIconsGroup) { + this.locateIconsGroup.appendChild(icon); + } + const iconX = side === 'left' ? LOCATE_ICON_PADDING : viewWidth - LOCATE_ICON_SIZE - LOCATE_ICON_PADDING; + // 图标固定在左右边缘,y 跟随任务条行,并转换到“可视区坐标系” + const iconY = barTop - scrollTop + (bar.attribute.height - LOCATE_ICON_SIZE) / 2; + icon.setAttributes({ + x: iconX, + y: iconY, + visibleAll: true + }); + const otherIcon = side === 'left' ? rightIcon : leftIcon; + otherIcon?.setAttribute('visibleAll', false); + if (this.currentHoverLocateIcon === otherIcon) { + this.setLocateIconHover(null); + } + } + } + child = child._next; + } + } + setX(x: number) { this.barContainer.setAttribute('x', x); + this.updateOffscreenIndicators(); } setY(y: number) { this.barContainer.setAttribute('y', y); + this.updateOffscreenIndicators(); } /** 重新创建任务条节点 */ refresh() { @@ -617,6 +795,11 @@ export class TaskBar { width: this.width, y: this._scene._gantt.getAllHeaderRowsHeight() }); + this.locateIconsGroup?.setAttributes({ + width: this.width, + height: this.height + }); + this.locateIconsGroup?.removeAllChild(); const x = this.barContainer.attribute.x; const y = this.barContainer.attribute.y; this.barContainer.removeAllChild(); @@ -624,12 +807,18 @@ export class TaskBar { this.initBars(); this.setX(x); this.setY(y); + this.updateOffscreenIndicators(); } resize() { this.width = this._scene._gantt.tableNoFrameWidth; this.height = this._scene._gantt.gridHeight; this.group.setAttribute('width', this.width); this.group.setAttribute('height', this.height); + this.locateIconsGroup?.setAttributes({ + width: this.width, + height: this.height + }); + this.updateOffscreenIndicators(); } showHoverBar(x: number, y: number, width: number, height: number, target?: GanttTaskBarNode) { diff --git a/packages/vtable-gantt/src/ts-types/gantt-engine.ts b/packages/vtable-gantt/src/ts-types/gantt-engine.ts index 2bf81c6a5..cb33f7b05 100644 --- a/packages/vtable-gantt/src/ts-types/gantt-engine.ts +++ b/packages/vtable-gantt/src/ts-types/gantt-engine.ts @@ -150,6 +150,8 @@ export interface GanttConstructorOptions { }; /** 数据没有排期时,可通过创建任务条排期。默认为true */ scheduleCreatable?: boolean | ((interactionArgs: TaskBarInteractionArgumentType) => boolean); + /** 是否开启“任务条超出可视区”定位图标能力。默认 false */ + locateIcon?: boolean; /** 针对没有分配日期的任务,可以显示出创建按钮 */ scheduleCreation?: { buttonStyle?: ILineStyle & {