refactor(chart): enforce 400-line source limit
Some checks failed
Package component / package (push) Failing after 4m19s

This commit is contained in:
liqiyuan
2026-07-27 15:05:13 +08:00
parent dcc27a88be
commit 082462cf67
61 changed files with 9682 additions and 8407 deletions

View File

@@ -0,0 +1,262 @@
.waveform-annotation-editor {
position: absolute;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 20px;
background: rgb(16 24 40 / 32%);
animation: waveform-annotation-editor-fade-in 160ms ease-out;
}
.waveform-annotation-editor__panel {
display: grid;
gap: 20px;
width: min(440px, 100%);
max-height: 100%;
overflow-y: auto;
padding: 22px;
color: #344054;
font-size: 13px;
background: #fff;
border: 1px solid #e4e7ec;
border-radius: 12px;
box-shadow: 0 20px 50px rgb(16 24 40 / 22%);
animation: waveform-annotation-editor-rise-in 180ms ease-out;
}
.waveform-annotation-editor__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.waveform-annotation-editor__header h2 {
margin: 0;
color: #101828;
font-size: 18px;
line-height: 1.3;
}
.waveform-annotation-editor__header p {
margin: 5px 0 0;
color: #667085;
font-size: 12px;
}
.waveform-annotation-editor__close {
display: inline-grid;
flex: 0 0 30px;
width: 30px;
height: 30px;
padding: 0;
place-items: center;
color: #667085;
font-size: 22px;
line-height: 1;
background: transparent;
border: 0;
border-radius: 6px;
cursor: pointer;
}
.waveform-annotation-editor__close:hover {
color: #101828;
background: #f2f4f7;
}
.waveform-annotation-editor__coordinates {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.waveform-annotation-editor__coordinates span,
.waveform-annotation-editor__series {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
padding: 9px 10px;
background: #f8fafc;
border: 1px solid #eaecf0;
border-radius: 7px;
}
.waveform-annotation-editor__coordinates span {
color: #667085;
}
.waveform-annotation-editor__coordinates b {
color: #1677ff;
font-size: 11px;
}
.waveform-annotation-editor__coordinates code {
min-width: 0;
overflow: hidden;
color: #344054;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.waveform-annotation-editor__series {
gap: 10px;
}
.waveform-annotation-editor__series i {
flex: 0 0 12px;
width: 12px;
height: 12px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 2px;
}
.waveform-annotation-editor__series-label {
display: grid;
min-width: 0;
gap: 1px;
}
.waveform-annotation-editor__series select {
min-width: 0;
max-width: 190px;
margin-left: auto;
padding: 5px 7px;
color: #344054;
font: inherit;
background: #fff;
border: 1px solid #d0d5dd;
border-radius: 5px;
}
.waveform-annotation-editor__series small {
color: #667085;
font-size: 10px;
}
.waveform-annotation-editor__series b {
overflow: hidden;
color: #344054;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.waveform-annotation-editor__series code {
margin-left: auto;
color: #667085;
font-size: 11px;
}
.waveform-annotation-editor__field,
.waveform-annotation-editor__color-field {
display: grid;
gap: 4px;
}
.waveform-annotation-editor__label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.waveform-annotation-editor__label-row b,
.waveform-annotation-editor__colors legend {
color: #344054;
font-size: 12px;
font-weight: 600;
}
.waveform-annotation-editor__label-row small {
color: #98a2b3;
font-size: 11px;
}
.waveform-annotation-editor textarea {
box-sizing: border-box;
width: 100%;
min-height: 92px;
padding: 10px 11px;
color: #1d2939;
font: inherit;
line-height: 1.5;
background: #fff;
border: 1px solid #d0d5dd;
border-radius: 7px;
resize: vertical;
}
.waveform-annotation-editor textarea:focus {
border-color: #1677ff;
outline: 3px solid rgb(22 119 255 / 14%);
}
.waveform-annotation-editor__colors {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
min-width: 0;
margin: 0;
padding: 14px 0 0;
border: 0;
border-top: 1px solid #eaecf0;
}
.waveform-annotation-editor__colors legend {
grid-column: 1 / -1;
padding: 0;
}
.waveform-annotation-editor__color-field {
align-items: stretch;
min-width: 0;
justify-items: stretch;
color: #667085;
font-size: 11px;
text-align: left;
}
.waveform-annotation-editor__color-field :deep(.vc-color-wrap) {
width: 100%;
height: 34px;
margin-right: 0;
border: 1px solid #cfd5df;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 38%);
}
.waveform-annotation-editor__color-field :deep(.vc-color-wrap:hover) {
border-color: #98a2b3;
box-shadow: 0 0 0 3px rgb(22 119 255 / 12%);
}
.waveform-annotation-editor__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #eaecf0;
}
.waveform-annotation-editor__actions button {
height: 34px;
padding: 0 12px;
color: #475467;
background: #fff;
border: 1px solid #d0d5dd;
border-radius: 6px;
cursor: pointer;
}
.waveform-annotation-editor__actions button.is-primary {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.waveform-annotation-editor__actions button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@keyframes waveform-annotation-editor-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes waveform-annotation-editor-rise-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 420px) {
.waveform-annotation-editor {
padding: 12px;
}
.waveform-annotation-editor__panel {
gap: 16px;
padding: 18px;
}
.waveform-annotation-editor__coordinates {
grid-template-columns: 1fr;
}
}

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { ColorPicker } from 'vue3-colorpicker'
import 'vue3-colorpicker/style.css'
import type { WaveformAnnotation } from '../../types'
import { formatAnnotationTime, formatPlainNumber, type TimeUnit } from '../../utils'
@@ -7,11 +9,6 @@ import { ANNOTATION_MAX_TEXT_LENGTH, resolveAnnotationStyle } from './markup'
import type { AnnotationSeriesCandidate, AnnotationSeriesInfo } from './types'
import { useWaveformInstanceId } from '../../utils/waveformId'
const ColorPicker = defineAsyncComponent(async () => {
await import('vue3-colorpicker/style.css')
return (await import('vue3-colorpicker')).ColorPicker
})
interface Props {
annotation: WaveformAnnotation
mode: 'add' | 'edit'
@@ -213,308 +210,4 @@ function handleSeriesChange(event: Event) {
</div>
</template>
<style scoped>
.waveform-annotation-editor {
position: absolute;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 20px;
background: rgb(16 24 40 / 32%);
animation: waveform-annotation-editor-fade-in 160ms ease-out;
}
.waveform-annotation-editor__panel {
display: grid;
gap: 20px;
width: min(440px, 100%);
max-height: 100%;
overflow-y: auto;
padding: 22px;
color: #344054;
font-size: 13px;
background: #fff;
border: 1px solid #e4e7ec;
border-radius: 12px;
box-shadow: 0 20px 50px rgb(16 24 40 / 22%);
animation: waveform-annotation-editor-rise-in 180ms ease-out;
}
.waveform-annotation-editor__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.waveform-annotation-editor__header h2 {
margin: 0;
color: #101828;
font-size: 18px;
line-height: 1.3;
}
.waveform-annotation-editor__header p {
margin: 5px 0 0;
color: #667085;
font-size: 12px;
}
.waveform-annotation-editor__close {
display: inline-grid;
flex: 0 0 30px;
width: 30px;
height: 30px;
padding: 0;
place-items: center;
color: #667085;
font-size: 22px;
line-height: 1;
background: transparent;
border: 0;
border-radius: 6px;
cursor: pointer;
}
.waveform-annotation-editor__close:hover {
color: #101828;
background: #f2f4f7;
}
.waveform-annotation-editor__coordinates {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.waveform-annotation-editor__coordinates span {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
padding: 9px 10px;
color: #667085;
background: #f8fafc;
border: 1px solid #eaecf0;
border-radius: 7px;
}
.waveform-annotation-editor__coordinates b {
color: #1677ff;
font-size: 11px;
}
.waveform-annotation-editor__coordinates code {
min-width: 0;
overflow: hidden;
color: #344054;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.waveform-annotation-editor__series {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
padding: 9px 10px;
background: #f8fafc;
border: 1px solid #eaecf0;
border-radius: 7px;
}
.waveform-annotation-editor__series i {
flex: 0 0 12px;
width: 12px;
height: 12px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 2px;
}
.waveform-annotation-editor__series-label {
display: grid;
min-width: 0;
gap: 1px;
}
.waveform-annotation-editor__series select {
min-width: 0;
max-width: 190px;
margin-left: auto;
padding: 5px 7px;
color: #344054;
font: inherit;
background: #fff;
border: 1px solid #d0d5dd;
border-radius: 5px;
}
.waveform-annotation-editor__series small {
color: #667085;
font-size: 10px;
}
.waveform-annotation-editor__series b {
overflow: hidden;
color: #344054;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.waveform-annotation-editor__series code {
margin-left: auto;
color: #667085;
font-size: 11px;
}
.waveform-annotation-editor__field,
.waveform-annotation-editor__color-field {
display: grid;
gap: 4px;
}
.waveform-annotation-editor__label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.waveform-annotation-editor__label-row b,
.waveform-annotation-editor__colors legend {
color: #344054;
font-size: 12px;
font-weight: 600;
}
.waveform-annotation-editor__label-row small {
color: #98a2b3;
font-size: 11px;
}
.waveform-annotation-editor textarea {
box-sizing: border-box;
width: 100%;
min-height: 92px;
padding: 10px 11px;
color: #1d2939;
font: inherit;
line-height: 1.5;
background: #fff;
border: 1px solid #d0d5dd;
border-radius: 7px;
resize: vertical;
}
.waveform-annotation-editor textarea:focus {
border-color: #1677ff;
outline: 3px solid rgb(22 119 255 / 14%);
}
.waveform-annotation-editor__colors {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
min-width: 0;
margin: 0;
padding: 14px 0 0;
border: 0;
border-top: 1px solid #eaecf0;
}
.waveform-annotation-editor__colors legend {
grid-column: 1 / -1;
padding: 0;
}
.waveform-annotation-editor__color-field {
align-items: stretch;
min-width: 0;
justify-items: stretch;
color: #667085;
font-size: 11px;
text-align: left;
}
.waveform-annotation-editor__color-field :deep(.vc-color-wrap) {
width: 100%;
height: 34px;
margin-right: 0;
border: 1px solid #cfd5df;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 38%);
}
.waveform-annotation-editor__color-field :deep(.vc-color-wrap:hover) {
border-color: #98a2b3;
box-shadow: 0 0 0 3px rgb(22 119 255 / 12%);
}
.waveform-annotation-editor__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #eaecf0;
}
.waveform-annotation-editor__actions button {
height: 34px;
padding: 0 12px;
color: #475467;
background: #fff;
border: 1px solid #d0d5dd;
border-radius: 6px;
cursor: pointer;
}
.waveform-annotation-editor__actions button.is-primary {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.waveform-annotation-editor__actions button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@keyframes waveform-annotation-editor-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes waveform-annotation-editor-rise-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 420px) {
.waveform-annotation-editor {
padding: 12px;
}
.waveform-annotation-editor__panel {
gap: 16px;
padding: 18px;
}
.waveform-annotation-editor__coordinates {
grid-template-columns: 1fr;
}
}
</style>
<style scoped src="./WaveformAnnotationEditor.css"></style>

View File

@@ -0,0 +1,275 @@
import type { WaveformAnnotation } from '../../types'
import type {
AnnotationBoxLayout,
AnnotationPlacement,
AnnotationTrackLayout,
RenderedAnnotation,
} from './types'
import {
ANNOTATION_BOX_MAX_WIDTH,
ANNOTATION_BOX_MIN_HEIGHT,
ANNOTATION_BOX_MIN_WIDTH,
ANNOTATION_CONNECTOR_LENGTH,
ANNOTATION_TEXT_GLYPH_HEIGHT,
ANNOTATION_TEXT_LINE_HEIGHT,
ANNOTATION_TEXT_PADDING,
ANNOTATION_TEXT_VERTICAL_PADDING,
isFiniteAnnotation,
measureAnnotationTextWidth,
resolveAnnotationStyle,
wrapAnnotationText,
} from './markup'
function isWithin(value: number, domain: [number, number]): boolean {
return value >= Math.min(domain[0], domain[1]) && value <= Math.max(domain[0], domain[1])
}
function clampBox(box: AnnotationBoxLayout, width: number, height: number): AnnotationBoxLayout {
const x = Math.max(0, Math.min(box.x, Math.max(0, width - box.width)))
const y = Math.max(0, Math.min(box.y, Math.max(0, height - box.height)))
return {
...box,
x,
y,
lineEndX: Math.max(x, Math.min(box.lineEndX, x + box.width)),
lineEndY: Math.max(y, Math.min(box.lineEndY, y + box.height)),
}
}
function isPlacementWithinBounds(
anchorX: number,
anchorY: number,
width: number,
height: number,
placement: AnnotationPlacement,
plotWidth: number,
plotHeight: number,
): boolean {
const position = boxPosition(anchorX, anchorY, width, height, placement)
return (
position.x >= 0 &&
position.y >= 0 &&
position.x + width <= plotWidth &&
position.y + height <= plotHeight
)
}
function chooseBestPlacement(
anchorX: number,
anchorY: number,
width: number,
height: number,
plotWidth: number,
plotHeight: number,
): AnnotationPlacement {
const placements: AnnotationPlacement[] = [
'top',
'bottom',
'right',
'left',
'top-right',
'top-left',
'bottom-right',
'bottom-left',
]
// Try to find a placement that fits completely within bounds
for (const placement of placements) {
if (
isPlacementWithinBounds(anchorX, anchorY, width, height, placement, plotWidth, plotHeight)
) {
return placement
}
}
// Fallback to 'top' if no placement fits perfectly (will be clamped)
return 'top'
}
function resolveConnectorStart(
box: AnnotationBoxLayout,
anchorX: number,
anchorY: number,
): { x: number; y: number } {
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
const deltaX = anchorX - centerX
const deltaY = anchorY - centerY
if (Math.abs(deltaX) * box.height > Math.abs(deltaY) * box.width) {
const x = deltaX >= 0 ? box.x + box.width : box.x
const ratio = deltaX === 0 ? 0 : (x - centerX) / deltaX
return {
x,
y: Math.max(box.y, Math.min(box.y + box.height, centerY + deltaY * ratio)),
}
}
const y = deltaY >= 0 ? box.y + box.height : box.y
const ratio = deltaY === 0 ? 0 : (y - centerY) / deltaY
return {
x: Math.max(box.x, Math.min(box.x + box.width, centerX + deltaX * ratio)),
y,
}
}
function boxPosition(
anchorX: number,
anchorY: number,
width: number,
height: number,
placement: AnnotationPlacement,
): { x: number; y: number } {
const centeredX = anchorX - width / 2
const centeredY = anchorY - height / 2
const rightX = anchorX + ANNOTATION_CONNECTOR_LENGTH
const leftX = anchorX - width - ANNOTATION_CONNECTOR_LENGTH
const topY = anchorY - height - ANNOTATION_CONNECTOR_LENGTH
const bottomY = anchorY + ANNOTATION_CONNECTOR_LENGTH
switch (placement) {
case 'top':
return { x: centeredX, y: topY }
case 'bottom':
return { x: centeredX, y: bottomY }
case 'right':
return { x: rightX, y: centeredY }
case 'left':
return { x: leftX, y: centeredY }
case 'top-right':
return { x: rightX, y: topY }
case 'top-left':
return { x: leftX, y: topY }
case 'bottom-right':
return { x: rightX, y: bottomY }
case 'bottom-left':
return { x: leftX, y: bottomY }
}
}
function annotationBoxSize(
lines: string[],
plotWidth: number,
plotHeight: number,
): { width: number; height: number } {
return {
width: Math.min(
plotWidth,
Math.max(
ANNOTATION_BOX_MIN_WIDTH,
Math.min(
ANNOTATION_BOX_MAX_WIDTH,
Math.ceil(Math.max(...lines.map(measureAnnotationTextWidth), 0)) +
ANNOTATION_TEXT_PADDING * 2,
),
),
),
height: Math.min(
plotHeight,
Math.max(
ANNOTATION_BOX_MIN_HEIGHT,
(lines.length - 1) * ANNOTATION_TEXT_LINE_HEIGHT +
ANNOTATION_TEXT_GLYPH_HEIGHT +
ANNOTATION_TEXT_VERTICAL_PADDING * 2,
),
),
}
}
export function layoutAnnotationBox(
anchorX: number,
anchorY: number,
lines: string[],
plotWidth: number,
plotHeight: number,
placement: AnnotationPlacement,
offsetX = 0,
offsetY = 0,
): AnnotationBoxLayout {
const { width, height } = annotationBoxSize(lines, plotWidth, plotHeight)
const position = boxPosition(anchorX, anchorY, width, height, placement)
const clamped = clampBox(
{
x: position.x + offsetX,
y: position.y + offsetY,
width,
height,
lineEndX: anchorX,
lineEndY: anchorY,
},
plotWidth,
plotHeight,
)
const connectorStart = resolveConnectorStart(clamped, anchorX, anchorY)
return { ...clamped, lineEndX: connectorStart.x, lineEndY: connectorStart.y }
}
export function layoutAnnotations(
annotations: WaveformAnnotation[],
tracks: AnnotationTrackLayout[],
plotWidth: number,
plotHeight: number,
): RenderedAnnotation[] {
const rendered: RenderedAnnotation[] = []
annotations.forEach((annotation) => {
if (!isFiniteAnnotation(annotation)) return
const track = tracks.find((candidate) => candidate.series.id === annotation.seriesId)
if (!track) return
const xDomain = track.xScale.domain() as [number, number]
const yDomain = track.yScale.domain() as [number, number]
if (!isWithin(annotation.x, xDomain) || !isWithin(annotation.y, yDomain)) return
const lines = wrapAnnotationText(annotation.text)
const trackLeft = track.left ?? 0
const trackWidth = track.width ?? plotWidth
const localAnchorX = track.xScale(annotation.x)
const anchorX = trackLeft + localAnchorX
const anchorY = track.yScale(annotation.y) + track.top
const localHeight = Math.min(track.height, Math.max(0, plotHeight - track.top))
const localAnchorY = anchorY - track.top
// Choose best placement only if no manual offset exists
const hasManualOffset =
(Number.isFinite(annotation.labelOffsetX) && annotation.labelOffsetX !== 0) ||
(Number.isFinite(annotation.labelOffsetY) && annotation.labelOffsetY !== 0)
const { width, height } = annotationBoxSize(lines, trackWidth, localHeight)
const placement: AnnotationPlacement = hasManualOffset
? 'top'
: chooseBestPlacement(localAnchorX, localAnchorY, width, height, trackWidth, localHeight)
const box = layoutAnnotationBox(
localAnchorX,
localAnchorY,
lines,
trackWidth,
localHeight,
placement,
Number.isFinite(annotation.labelOffsetX) ? annotation.labelOffsetX : 0,
Number.isFinite(annotation.labelOffsetY) ? annotation.labelOffsetY : 0,
)
const globalBox = {
...box,
x: box.x + trackLeft,
y: box.y + track.top,
lineEndX: box.lineEndX + trackLeft,
lineEndY: box.lineEndY + track.top,
}
rendered.push({
annotation,
trackIndex: track.index,
anchorX,
anchorY,
placement,
lines,
box: {
...globalBox,
},
style: resolveAnnotationStyle(annotation.style),
})
})
return rendered
}

View File

@@ -1,14 +1,7 @@
import { bisector } from 'd3'
import type { WaveformAnnotation, WaveformAnnotationStyle, WaveformLineType } from '../../types'
import type {
AnnotationBoxLayout,
AnnotationHit,
AnnotationPlacement,
AnnotationSeriesCandidate,
AnnotationTrackLayout,
RenderedAnnotation,
} from './types'
import type { AnnotationHit, AnnotationSeriesCandidate, AnnotationTrackLayout } from './types'
export const DEFAULT_ANNOTATION_STYLE = {
borderColor: '#1677ff',
@@ -214,256 +207,4 @@ export function findNearestAnnotationPoint(
return closest
}
function isWithin(value: number, domain: [number, number]): boolean {
return value >= Math.min(domain[0], domain[1]) && value <= Math.max(domain[0], domain[1])
}
function clampBox(box: AnnotationBoxLayout, width: number, height: number): AnnotationBoxLayout {
const x = Math.max(0, Math.min(box.x, Math.max(0, width - box.width)))
const y = Math.max(0, Math.min(box.y, Math.max(0, height - box.height)))
return {
...box,
x,
y,
lineEndX: Math.max(x, Math.min(box.lineEndX, x + box.width)),
lineEndY: Math.max(y, Math.min(box.lineEndY, y + box.height)),
}
}
function isPlacementWithinBounds(
anchorX: number,
anchorY: number,
width: number,
height: number,
placement: AnnotationPlacement,
plotWidth: number,
plotHeight: number,
): boolean {
const position = boxPosition(anchorX, anchorY, width, height, placement)
return (
position.x >= 0 &&
position.y >= 0 &&
position.x + width <= plotWidth &&
position.y + height <= plotHeight
)
}
function chooseBestPlacement(
anchorX: number,
anchorY: number,
width: number,
height: number,
plotWidth: number,
plotHeight: number,
): AnnotationPlacement {
const placements: AnnotationPlacement[] = [
'top',
'bottom',
'right',
'left',
'top-right',
'top-left',
'bottom-right',
'bottom-left',
]
// Try to find a placement that fits completely within bounds
for (const placement of placements) {
if (
isPlacementWithinBounds(anchorX, anchorY, width, height, placement, plotWidth, plotHeight)
) {
return placement
}
}
// Fallback to 'top' if no placement fits perfectly (will be clamped)
return 'top'
}
function resolveConnectorStart(
box: AnnotationBoxLayout,
anchorX: number,
anchorY: number,
): { x: number; y: number } {
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
const deltaX = anchorX - centerX
const deltaY = anchorY - centerY
if (Math.abs(deltaX) * box.height > Math.abs(deltaY) * box.width) {
const x = deltaX >= 0 ? box.x + box.width : box.x
const ratio = deltaX === 0 ? 0 : (x - centerX) / deltaX
return {
x,
y: Math.max(box.y, Math.min(box.y + box.height, centerY + deltaY * ratio)),
}
}
const y = deltaY >= 0 ? box.y + box.height : box.y
const ratio = deltaY === 0 ? 0 : (y - centerY) / deltaY
return {
x: Math.max(box.x, Math.min(box.x + box.width, centerX + deltaX * ratio)),
y,
}
}
function boxPosition(
anchorX: number,
anchorY: number,
width: number,
height: number,
placement: AnnotationPlacement,
): { x: number; y: number } {
const centeredX = anchorX - width / 2
const centeredY = anchorY - height / 2
const rightX = anchorX + ANNOTATION_CONNECTOR_LENGTH
const leftX = anchorX - width - ANNOTATION_CONNECTOR_LENGTH
const topY = anchorY - height - ANNOTATION_CONNECTOR_LENGTH
const bottomY = anchorY + ANNOTATION_CONNECTOR_LENGTH
switch (placement) {
case 'top':
return { x: centeredX, y: topY }
case 'bottom':
return { x: centeredX, y: bottomY }
case 'right':
return { x: rightX, y: centeredY }
case 'left':
return { x: leftX, y: centeredY }
case 'top-right':
return { x: rightX, y: topY }
case 'top-left':
return { x: leftX, y: topY }
case 'bottom-right':
return { x: rightX, y: bottomY }
case 'bottom-left':
return { x: leftX, y: bottomY }
}
}
function annotationBoxSize(
lines: string[],
plotWidth: number,
plotHeight: number,
): { width: number; height: number } {
return {
width: Math.min(
plotWidth,
Math.max(
ANNOTATION_BOX_MIN_WIDTH,
Math.min(
ANNOTATION_BOX_MAX_WIDTH,
Math.ceil(Math.max(...lines.map(measureAnnotationTextWidth), 0)) +
ANNOTATION_TEXT_PADDING * 2,
),
),
),
height: Math.min(
plotHeight,
Math.max(
ANNOTATION_BOX_MIN_HEIGHT,
(lines.length - 1) * ANNOTATION_TEXT_LINE_HEIGHT +
ANNOTATION_TEXT_GLYPH_HEIGHT +
ANNOTATION_TEXT_VERTICAL_PADDING * 2,
),
),
}
}
export function layoutAnnotationBox(
anchorX: number,
anchorY: number,
lines: string[],
plotWidth: number,
plotHeight: number,
placement: AnnotationPlacement,
offsetX = 0,
offsetY = 0,
): AnnotationBoxLayout {
const { width, height } = annotationBoxSize(lines, plotWidth, plotHeight)
const position = boxPosition(anchorX, anchorY, width, height, placement)
const clamped = clampBox(
{
x: position.x + offsetX,
y: position.y + offsetY,
width,
height,
lineEndX: anchorX,
lineEndY: anchorY,
},
plotWidth,
plotHeight,
)
const connectorStart = resolveConnectorStart(clamped, anchorX, anchorY)
return { ...clamped, lineEndX: connectorStart.x, lineEndY: connectorStart.y }
}
export function layoutAnnotations(
annotations: WaveformAnnotation[],
tracks: AnnotationTrackLayout[],
plotWidth: number,
plotHeight: number,
): RenderedAnnotation[] {
const rendered: RenderedAnnotation[] = []
annotations.forEach((annotation) => {
if (!isFiniteAnnotation(annotation)) return
const track = tracks.find((candidate) => candidate.series.id === annotation.seriesId)
if (!track) return
const xDomain = track.xScale.domain() as [number, number]
const yDomain = track.yScale.domain() as [number, number]
if (!isWithin(annotation.x, xDomain) || !isWithin(annotation.y, yDomain)) return
const lines = wrapAnnotationText(annotation.text)
const trackLeft = track.left ?? 0
const trackWidth = track.width ?? plotWidth
const localAnchorX = track.xScale(annotation.x)
const anchorX = trackLeft + localAnchorX
const anchorY = track.yScale(annotation.y) + track.top
const localHeight = Math.min(track.height, Math.max(0, plotHeight - track.top))
const localAnchorY = anchorY - track.top
// Choose best placement only if no manual offset exists
const hasManualOffset =
(Number.isFinite(annotation.labelOffsetX) && annotation.labelOffsetX !== 0) ||
(Number.isFinite(annotation.labelOffsetY) && annotation.labelOffsetY !== 0)
const { width, height } = annotationBoxSize(lines, trackWidth, localHeight)
const placement: AnnotationPlacement = hasManualOffset
? 'top'
: chooseBestPlacement(localAnchorX, localAnchorY, width, height, trackWidth, localHeight)
const box = layoutAnnotationBox(
localAnchorX,
localAnchorY,
lines,
trackWidth,
localHeight,
placement,
Number.isFinite(annotation.labelOffsetX) ? annotation.labelOffsetX : 0,
Number.isFinite(annotation.labelOffsetY) ? annotation.labelOffsetY : 0,
)
const globalBox = {
...box,
x: box.x + trackLeft,
y: box.y + track.top,
lineEndX: box.lineEndX + trackLeft,
lineEndY: box.lineEndY + track.top,
}
rendered.push({
annotation,
trackIndex: track.index,
anchorX,
anchorY,
placement,
lines,
box: {
...globalBox,
},
style: resolveAnnotationStyle(annotation.style),
})
})
return rendered
}
export { layoutAnnotationBox, layoutAnnotations } from './annotationLayout'

View File

@@ -0,0 +1,346 @@
import { pointer, type ScaleLinear } from 'd3'
import type { ComputedRef, Ref } from 'vue'
import type { WaveformAnnotation, WaveformInteractionMode } from '../data/types'
import { findClosestTrackAtPointer } from '../core/layout'
import type { DisplaySeries, TrackLayout } from '../core/types'
import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
import {
ANNOTATION_AMBIGUITY_DISTANCE,
ANNOTATION_HIT_RADIUS,
findAnnotationSeriesCandidates,
interpolateAnnotationPoint,
} from './markup'
import type {
AnnotationEditorAnchor,
AnnotationHit,
AnnotationSeriesCandidate,
AnnotationTrackLayout,
} from './types'
import type { useWaveformAnnotationInteraction } from './useWaveformAnnotationInteraction'
interface AnnotationContext {
props: ResolvedWaveformChartProps
emit: WaveformChartEmit
container: Ref<HTMLDivElement | undefined>
chartSeries: ComputedRef<DisplaySeries[]>
hiddenSeriesIdSet: ComputedRef<Set<string>>
internalHiddenSeriesIds: Ref<Set<string>>
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
trackLayouts: ComputedRef<TrackLayout[]>
annotationLayoutsForTrack: (track: TrackLayout) => AnnotationTrackLayout[]
resolveSeriesYScale: (track: TrackLayout, seriesId: string) => ScaleLinear<number, number>
chartWidth: ComputedRef<number>
chartHeight: ComputedRef<number>
innerWidth: ComputedRef<number>
resolvedChartLeftMargin: ComputedRef<number>
titleAreaHeight: ComputedRef<number>
chartTopMargin: ComputedRef<number>
activeInteractionMode: ComputedRef<WaveformInteractionMode | undefined>
}
interface AnnotationCandidateContext {
candidates: AnnotationSeriesCandidate[]
editorAnchor: AnnotationEditorAnchor
}
let generatedAnnotationId = 0
function makeAnnotationId(): string {
generatedAnnotationId += 1
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `annotation-${Date.now()}-${generatedAnnotationId}`
}
export function useWaveformChartAnnotations(context: AnnotationContext) {
const {
props,
emit,
container,
chartSeries,
hiddenSeriesIdSet,
internalHiddenSeriesIds,
annotationInteraction,
editorSeriesOptions,
trackLayouts,
annotationLayoutsForTrack,
resolveSeriesYScale,
chartWidth,
chartHeight,
innerWidth,
resolvedChartLeftMargin,
titleAreaHeight,
chartTopMargin,
activeInteractionMode,
} = context
function toggleSeriesVisibility(seriesId: string) {
if (!chartSeries.value.some((series) => series.id === seriesId)) return
const nextHiddenSeriesIds = new Set(hiddenSeriesIdSet.value)
const visible = nextHiddenSeriesIds.has(seriesId)
if (visible) nextHiddenSeriesIds.delete(seriesId)
else nextHiddenSeriesIds.add(seriesId)
const ids = Array.from(nextHiddenSeriesIds)
if (props.hiddenSeriesIds === undefined) internalHiddenSeriesIds.value = nextHiddenSeriesIds
emit('update:hidden-series-ids', ids)
emit('series-visibility-change', { seriesId, visible, hiddenSeriesIds: ids })
}
function resolvePointerEditorAnchor(
event: MouseEvent,
trackIndex?: number,
): AnnotationEditorAnchor {
const overlay = event.currentTarget as SVGRectElement | null
if (!overlay) return { x: chartWidth.value / 2, y: chartHeight.value / 2 }
const [pointerX, pointerY] = pointer(event, overlay)
const track = trackIndex === undefined ? undefined : trackLayouts.value[trackIndex]
return {
x: resolvedChartLeftMargin.value + (track ? track.left + pointerX : pointerX),
y: titleAreaHeight.value + chartTopMargin.value + (track ? track.top + pointerY : pointerY),
}
}
function resolveAnnotationEditorAnchor(annotation: WaveformAnnotation): AnnotationEditorAnchor {
const track = trackLayouts.value.find((item) =>
item.seriesList.some((series) => series.id === annotation.seriesId),
)
return {
x: track
? resolvedChartLeftMargin.value + track.left + track.xScale(annotation.x)
: chartWidth.value / 2,
y: track
? titleAreaHeight.value +
chartTopMargin.value +
track.top +
resolveSeriesYScale(track, annotation.seriesId)(annotation.y)
: chartHeight.value / 2,
}
}
function beginCreate(
hit: AnnotationHit,
anchor: AnnotationEditorAnchor,
candidates: AnnotationSeriesCandidate[],
) {
annotationInteraction.openCreate(hit, makeAnnotationId, anchor)
editorSeriesOptions.value = candidates
const draft = annotationInteraction.editorDraft.value
const track = trackLayouts.value.find((item) => item.index === hit.trackIndex)
const series = track?.seriesList.find((item) => item.id === hit.seriesId)
if (draft?.mode === 'add') {
draft.annotation.style = {
borderColor: series?.color || '#1677ff',
textColor: '#333333',
backgroundColor: 'rgba(255, 255, 255, 0.92)',
}
}
}
function changeDraftSeries(seriesId: string) {
const draft = annotationInteraction.editorDraft.value
const candidate = editorSeriesOptions.value.find((item) => item.seriesId === seriesId)
const track = trackLayouts.value.find((item) =>
item.seriesList.some((series) => series.id === seriesId),
)
const series = track?.seriesList.find((item) => item.id === seriesId)
const point =
series && draft
? interpolateAnnotationPoint(series.points, draft.annotation.x, series.lineType)
: null
if (!draft || !candidate || !track || !point) return
draft.annotation = {
...draft.annotation,
seriesId,
y: point.y,
style: { ...draft.annotation.style, borderColor: candidate.color },
}
}
function cancelAnnotation() {
annotationInteraction.closeEditor()
editorSeriesOptions.value = []
}
function resolveTrackAtPointer(
pointerX: number,
pointerY: number,
trackIndex?: number,
): TrackLayout | undefined {
if (trackIndex !== undefined) {
const track = trackLayouts.value[trackIndex]
return track?.hasVisibleSeries ? track : undefined
}
const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
return findClosestTrackAtPointer(visibleTracks, pointerX, pointerY)
}
function resolveAnnotationCandidates(
event: MouseEvent,
trackIndex?: number,
): AnnotationCandidateContext | null {
const overlay = event.currentTarget as SVGRectElement | null
if (!overlay || !trackLayouts.value.length) return null
const [pointerX, pointerY] = pointer(event, overlay)
const sharedPointerY =
trackIndex === undefined ? pointerY : pointerY + (trackLayouts.value[trackIndex]?.top ?? 0)
const referenceTrack = resolveTrackAtPointer(pointerX, sharedPointerY, trackIndex)
if (!referenceTrack) return null
const clampedX = Math.max(0, Math.min(innerWidth.value, pointerX))
const localPointerX = Math.max(
0,
Math.min(referenceTrack.width, clampedX - referenceTrack.left),
)
const xValue = referenceTrack.xScale.invert(localPointerX)
return {
candidates: findAnnotationSeriesCandidates(
annotationLayoutsForTrack(referenceTrack),
xValue,
localPointerX,
sharedPointerY,
),
editorAnchor: resolvePointerEditorAnchor(event, trackIndex),
}
}
function nearbyCandidates(candidates: AnnotationSeriesCandidate[]): AnnotationSeriesCandidate[] {
const closest = candidates[0]
if (!closest || closest.distance > ANNOTATION_HIT_RADIUS) return []
return candidates.filter(
(candidate) =>
candidate.distance <= ANNOTATION_HIT_RADIUS &&
candidate.distance - closest.distance < ANNOTATION_AMBIGUITY_DISTANCE,
)
}
function handleAnnotationClick(event: MouseEvent, trackIndex?: number) {
if (!props.annotationsVisible || activeInteractionMode.value !== 'annotation') return
const candidateContext = resolveAnnotationCandidates(event, trackIndex)
if (!candidateContext) return
const nearby = nearbyCandidates(candidateContext.candidates)
if (!nearby.length) return
event.preventDefault()
event.stopPropagation()
beginCreate(nearby[0], candidateContext.editorAnchor, candidateContext.candidates)
}
function handleNativeContextMenu(event: MouseEvent) {
const target = event.target
if (
target instanceof Element &&
target.closest('input, textarea, [contenteditable]:not([contenteditable="false"])')
) {
return
}
event.preventDefault()
}
function handleAnnotationContextMenu(event: MouseEvent, trackIndex?: number) {
if (!props.annotationsVisible) return
event.preventDefault()
event.stopPropagation()
const candidateContext = resolveAnnotationCandidates(event, trackIndex)
if (!candidateContext || !candidateContext.candidates.length) return
const nearby = nearbyCandidates(candidateContext.candidates)
beginCreate(
nearby[0] ?? candidateContext.candidates[0],
candidateContext.editorAnchor,
candidateContext.candidates,
)
}
function handleExistingAnnotationContextMenu(annotationId: string, event: MouseEvent) {
if (!props.annotationsVisible || !container.value) return
editorSeriesOptions.value = []
const annotation = props.annotations.find((item) => item.id === annotationId)
if (!annotation) return
const bounds = container.value.getBoundingClientRect()
const editorAnchor = {
x: Math.max(0, Math.min(event.clientX - bounds.left, chartWidth.value)),
y: Math.max(0, Math.min(event.clientY - bounds.top, chartHeight.value)),
}
annotationInteraction.openContextMenu({
annotationId,
x: Math.max(4, Math.min(event.clientX - bounds.left, chartWidth.value - 120)),
y: Math.max(4, Math.min(event.clientY - bounds.top, chartHeight.value - 110)),
editorAnchor,
})
}
function handleAnnotationMove(annotationId: string, offsetX: number, offsetY: number) {
const annotation = props.annotations.find((item) => item.id === annotationId)
if (!annotation || !Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return
emit(
'update:annotations',
props.annotations.map((item) =>
item.id === annotationId ? { ...item, labelOffsetX: offsetX, labelOffsetY: offsetY } : item,
),
)
}
function editContextAnnotation() {
const menu = annotationInteraction.contextMenu.value
const annotation = props.annotations.find((item) => item.id === menu?.annotationId)
if (!annotation) return
const track = trackLayouts.value.find((item) =>
item.seriesList.some((series) => series.id === annotation.seriesId),
)
editorSeriesOptions.value = track
? findAnnotationSeriesCandidates(
annotationLayoutsForTrack(track),
annotation.x,
track.xScale(annotation.x),
track.top + resolveSeriesYScale(track, annotation.seriesId)(annotation.y),
)
: []
annotationInteraction.openEdit(
annotation,
menu?.editorAnchor ?? resolveAnnotationEditorAnchor(annotation),
)
}
function deleteContextAnnotation() {
const annotationId = annotationInteraction.contextMenu.value?.annotationId
const annotation = props.annotations.find((item) => item.id === annotationId)
if (!annotation) return
emit(
'update:annotations',
props.annotations.filter((item) => item.id !== annotation.id),
)
emit('annotation-delete', annotation)
annotationInteraction.closeContextMenu()
}
function confirmAnnotation(annotation: WaveformAnnotation) {
const draft = annotationInteraction.editorDraft.value
if (!draft) return
if (draft.mode === 'add') {
emit('update:annotations', [...props.annotations, annotation])
emit('annotation-create', annotation)
} else {
emit(
'update:annotations',
props.annotations.map((item) => (item.id === annotation.id ? annotation : item)),
)
emit('annotation-update', annotation, draft.previous)
}
cancelAnnotation()
}
return {
toggleSeriesVisibility,
changeDraftSeries,
cancelAnnotation,
resolveTrackAtPointer,
handleAnnotationClick,
handleNativeContextMenu,
handleAnnotationContextMenu,
handleExistingAnnotationContextMenu,
handleAnnotationMove,
editContextAnnotation,
deleteContextAnnotation,
confirmAnnotation,
}
}