mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-25 08:01:20 -08:00
110 lines
4.8 KiB
TypeScript
110 lines
4.8 KiB
TypeScript
import type { DanmuItem, HeatmapOptions, HeatmapPoint } from './types'
|
|
import { MAX_HEATMAP_POINTS, sampleHeatmap } from './heatmap-sampling'
|
|
|
|
function map(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number {
|
|
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
|
|
}
|
|
|
|
function line(pointA: HeatmapPoint, pointB: HeatmapPoint) {
|
|
const lengthX = pointB[0] - pointA[0]
|
|
const lengthY = pointB[1] - pointA[1]
|
|
return { length: Math.sqrt(lengthX ** 2 + lengthY ** 2), angle: Math.atan2(lengthY, lengthX) }
|
|
}
|
|
|
|
interface GeometryInput {
|
|
width: number
|
|
height: number
|
|
duration: number
|
|
queue: readonly Pick<DanmuItem, 'time'>[]
|
|
option: boolean | HeatmapOptions
|
|
points?: HeatmapPoint[]
|
|
}
|
|
|
|
export interface HeatmapGeometry {
|
|
path: string
|
|
opacity: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
export function heatmapGeometry({ width, height, duration, queue, option, points: input = [] }: GeometryInput): HeatmapGeometry | null {
|
|
if (![width, height, duration].every(value => Number.isFinite(value) && value > 0))
|
|
return null
|
|
const defaults: Required<HeatmapOptions> = {
|
|
xMin: 0,
|
|
xMax: width,
|
|
yMin: 0,
|
|
yMax: 128,
|
|
scale: 0.25,
|
|
opacity: 0.2,
|
|
minHeight: Math.floor(height * 0.05),
|
|
sampling: Math.max(1, Math.floor(width / 100)),
|
|
smoothing: 0.2,
|
|
flattening: 0.2,
|
|
}
|
|
const configured: HeatmapOptions = Object.assign({}, typeof option === 'object' ? option : {})
|
|
const options = Object.assign({}, defaults, configured)
|
|
for (const key of Object.keys(defaults) as (keyof HeatmapOptions)[]) {
|
|
if (!Number.isFinite(options[key]))
|
|
options[key] = defaults[key]
|
|
}
|
|
if (options.sampling <= 0)
|
|
options.sampling = defaults.sampling
|
|
if (options.xMin === options.xMax || options.yMin === options.yMax)
|
|
return null
|
|
const hasCustomPoints = Array.isArray(input) && input.length > 0
|
|
const points = hasCustomPoints ? [...input] : sampleHeatmap(queue, width, duration, options.sampling)
|
|
if (!points.length
|
|
|| !points.every(point => Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1]))) { return null }
|
|
// The nonempty check above guarantees both endpoints used below.
|
|
const lastPoint = points[points.length - 1]!
|
|
if (lastPoint[0] !== width) {
|
|
if (points.length === MAX_HEATMAP_POINTS)
|
|
return null
|
|
points.push([width, lastPoint[1]])
|
|
}
|
|
let yMin = Infinity
|
|
let yMax = -Infinity
|
|
for (const point of points) {
|
|
yMin = Math.min(yMin, point[1])
|
|
yMax = Math.max(yMax, point[1])
|
|
}
|
|
const yMid = (yMin + yMax) / 2
|
|
// Historical points events copy only the outer array: callers observe these y writes.
|
|
for (const point of points) {
|
|
const y = point[1]
|
|
point[1] = y * (y > yMid ? 1 + options.scale : 1 - options.scale) + options.minHeight
|
|
}
|
|
// Explicit axes and custom points retain their coordinate interpretation. Fit automatic
|
|
// charts into the lower quarter so dense data cannot obscure the video.
|
|
const automatic = !hasCustomPoints && !Number.isFinite(configured.yMin) && !Number.isFinite(configured.yMax)
|
|
let peak = 0
|
|
for (const point of points) peak = Math.max(peak, point[1])
|
|
const fitted = automatic && peak > defaults.yMax / 4
|
|
if (fitted)
|
|
options.yMax = peak * 4
|
|
const boundY = (value: number) => fitted ? Math.min(height, Math.max(height * 0.75, value)) : value
|
|
const controlPoint = (current: HeatmapPoint, previous: HeatmapPoint | undefined, next: HeatmapPoint | undefined, reverse?: boolean): HeatmapPoint => {
|
|
const geometry = line(previous || current, next || current)
|
|
const flat = map(Math.cos(geometry.angle) * options.flattening, 0, 1, 1, 0)
|
|
const angle = geometry.angle * flat + (reverse ? Math.PI : 0)
|
|
const length = geometry.length * options.smoothing
|
|
// A cubic stays inside the convex hull of its endpoints and controls.
|
|
return [current[0] + Math.cos(angle) * length, boundY(current[1] + Math.sin(angle) * length)]
|
|
}
|
|
const positions = points.map<HeatmapPoint>(point => [map(point[0], options.xMin, options.xMax, 0, width), boundY(map(point[1], options.yMin, options.yMax, height, 0))])
|
|
if (!positions.every(point => point.every(Number.isFinite)))
|
|
return null
|
|
const path = positions.map((point, index, all) => {
|
|
if (index === 0)
|
|
return `M ${all[all.length - 1]![0]},${height} L ${point[0]},${height} L ${point[0]},${point[1]}`
|
|
// Every noninitial point has its immediately preceding control point.
|
|
const start = controlPoint(all[index - 1]!, all[index - 2], point)
|
|
const end = controlPoint(point, all[index - 1], all[index + 1], true)
|
|
return `C ${start[0]},${start[1]} ${end[0]},${end[1]} ${point[0]},${point[1]}${index === all.length - 1 ? ' z' : ''}`
|
|
}).join(' ')
|
|
if (/NaN|Infinity/u.test(path))
|
|
return null
|
|
return { path, opacity: options.opacity, width, height }
|
|
}
|