refactor(chart): enforce 400-line source limit
Some checks failed
Package component / package (push) Failing after 4m19s
Some checks failed
Package component / package (push) Failing after 4m19s
This commit is contained in:
262
src/components/annotation/WaveformAnnotationEditor.css
Normal file
262
src/components/annotation/WaveformAnnotationEditor.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
275
src/components/annotation/annotationLayout.ts
Normal file
275
src/components/annotation/annotationLayout.ts
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
346
src/components/annotation/useWaveformChartAnnotations.ts
Normal file
346
src/components/annotation/useWaveformChartAnnotations.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user