模組:PythonScript/ExtraLibrarys/Matplotlib/ChartRenderer
外观
-- Module:PythonScript/ExtraLibrarys/Matplotlib/ChartRenderer
-- 專責各類圖表 (Pie / Line / Step / Fill / Bar / Barh / Broken_Barh / Hist /
-- Boxplot / Violinplot / Errorbar / Scatter / Imshow) 之統計計算、值域掃描與圖表序列渲染邏輯
local p = {}
local RendererUtils = require("Module:PythonScript/ExtraLibrarys/Matplotlib/RendererUtils")
local format, floor, ceil, abs, max, min, sqrt = string.format, math.floor, math.ceil, math.abs, math.max, math.min, math.sqrt
local sin, cos, exp, pi = math.sin, math.cos, math.exp, math.pi
local DEFAULT_COLORS = RendererUtils.DEFAULT_COLORS
local parseColor = RendererUtils.parseColor
local esc = RendererUtils.esc
local safeFormat = RendererUtils.safeFormat
local renderSymbol = RendererUtils.renderSymbol
local formatTickValue = RendererUtils.formatTickValue
local buildSegmentPath = RendererUtils.buildSegmentPath
local detectArrayJumps = RendererUtils.detectArrayJumps
local getDashArray = RendererUtils.getDashArray
local sampleColormap = RendererUtils.sampleColormap
-- ==========================================
-- 1. 統計計算輔助器 (供 Boxplot 與 Violinplot KDE 使用)
-- ==========================================
local function computeQuantile(sorted, q)
local n = #sorted
if n == 0 then return 0 end
if n == 1 then return sorted[1] end
local pos = 1 + (n - 1) * q
local lo = floor(pos)
local hi = ceil(pos)
local frac = pos - lo
return sorted[lo] + (sorted[hi] - sorted[lo]) * frac
end
local function computeBoxStats(rawArr, whis)
whis = tonumber(whis) or 1.5
local clean = {}
local sum = 0
for i = 1, #(rawArr or {}) do
local v = tonumber(rawArr[i])
if v and v == v and abs(v) < 1e12 then
clean[#clean + 1] = v
sum = sum + v
end
end
table.sort(clean)
local n = #clean
if n == 0 then
return { q1 = 0, med = 0, q3 = 0, wLow = 0, wHigh = 0, mean = 0, min = 0, max = 0, fliers = {}, sorted = {} }
end
local q1 = computeQuantile(clean, 0.25)
local med = computeQuantile(clean, 0.50)
local q3 = computeQuantile(clean, 0.75)
local iqr = q3 - q1
local fenceLow = q1 - whis * iqr
local fenceHigh = q3 + whis * iqr
local wLow, wHigh = q1, q3
local fliers = {}
for i = 1, n do
local v = clean[i]
if v >= fenceLow and v <= fenceHigh then
if v < wLow then wLow = v end
if v > wHigh then wHigh = v end
else
fliers[#fliers + 1] = v
end
end
return {
q1 = q1, med = med, q3 = q3,
wLow = wLow, wHigh = wHigh,
mean = sum / n,
min = clean[1], max = clean[n],
fliers = fliers,
sorted = clean
}
end
local function computeViolinKDE(sorted, nSteps)
local n = #sorted
if n == 0 then return {}, 0, 1 end
local minV, maxV = sorted[1], sorted[n]
if minV == maxV then minV, maxV = minV - 1, maxV + 1 end
local mean = 0
for i = 1, n do mean = mean + sorted[i] end
mean = mean / n
local var = 0
for i = 1, n do var = var + (sorted[i] - mean) ^ 2 end
local std = sqrt(var / max(1, n - 1))
if std <= 1e-9 then std = (maxV - minV) * 0.2 end
-- Silverman 經驗頻寬
local bw = 1.06 * std * (n ^ (-0.2))
if bw <= 1e-9 then bw = (maxV - minV) * 0.15 end
nSteps = nSteps or 24
local grid = {}
local maxDens = 0
local invBw = 1.0 / bw
for k = 0, nSteps do
local y = minV + (k / nSteps) * (maxV - minV)
local dens = 0
for i = 1, n do
local u = (y - sorted[i]) * invBw
dens = dens + exp(-0.5 * u * u)
end
if dens > maxDens then maxDens = dens end
grid[k + 1] = { val = y, dens = dens }
end
if maxDens > 0 then
for k = 1, #grid do grid[k].dens = grid[k].dens / maxDens end
end
return grid, minV, maxV
end
-- ==========================================
-- 2. 互動式 Hover 感應區與浮動 Tooltip 輔助器
-- ==========================================
local function addHoverBox(overlays, x, y, w, h, dotColor, boldText, detailText, topBound)
local ttCls = (y - (topBound or 0) < 28) and "mpl-tt mpl-tt-b" or "mpl-tt"
local bStr = (boldText and boldText ~= "") and format("<b>%s</b>", tostring(boldText)) or ""
overlays[#overlays + 1] = format(
'<div class="mpl-ht mpl-hb" style="left:%.1fpx;top:%.1fpx;width:%.1fpx;height:%.1fpx;"><span class="%s"><i class="mpl-dot" style="background:%s;"></i>%s%s</span></div>',
x, y, max(4, w), max(4, h), ttCls, dotColor, bStr, tostring(detailText or "")
)
end
p.addHoverBox = addHoverBox
local function addHoverPoint(overlays, cx, cy, ringRadius, dotColor, boldText, detailText, topBound)
local d = max(14, ringRadius * 2)
local ttCls = (cy - (topBound or 0) < 28) and "mpl-tt mpl-tt-b" or "mpl-tt"
local bStr = (boldText and boldText ~= "") and format("<b>%s</b>", tostring(boldText)) or ""
overlays[#overlays + 1] = format(
'<div class="mpl-ht mpl-hp" style="left:%.1fpx;top:%.1fpx;width:%.1fpx;height:%.1fpx;"><span class="%s"><i class="mpl-dot" style="background:%s;"></i>%s%s</span></div>',
cx, cy, d, d, ttCls, dotColor, bStr, tostring(detailText or "")
)
end
p.addHoverPoint = addHoverPoint
-- ==========================================
-- 3. 圓餅圖與甜甜圈圖渲染器 (ax.pie)
-- ==========================================
function p.renderPieAxes(ax, box, buf, overlays, customState, useHtmlLabels, svgHelpers)
local palette = ax.palette or DEFAULT_COLORS
local s = (ax.series and ax.series[1]) or {}
local vals = s.x or {}
local labels = s.labels or {}
local explode = s.explode or {}
local colors = s.colors or palette
local autopct = s.autopct
local pctdist = tonumber(s.pctdistance) or 0.6
local lbldist = tonumber(s.labeldistance) or 1.14
local startangle = tonumber(s.startangle) or 0
local ccw = (s.counterclock ~= false)
local wedgeW = tonumber(s.wedge_width) -- 若有指定則為甜甜圈圖 (Donut Chart)
local edgeCol = parseColor(s.edgecolor, "#ffffff")
local edgeLW = tonumber(s.linewidth) or 1.2
local showLegend = (ax.show_legend == true)
local legItems, legBoxW, legBoxH, legFontSize = nil, 0, 0, 11.0
if showLegend and svgHelpers and svgHelpers.prepareAxesLegend then
legItems, legBoxW, legBoxH, legFontSize = svgHelpers.prepareAxesLegend(ax, palette, useHtmlLabels)
end
local hasTitle = (ax.title and ax.title ~= "")
local padT = hasTitle and 30 or 14
local padB = 14
local padL = 24
local padR = (showLegend and legItems) and (legBoxW + 16) or 24
local availW = max(80, box.w - padL - padR)
local availH = max(80, box.h - padT - padB)
local side = min(availW, availH)
local cx = box.x + padL + availW * 0.5
local cy = box.y + padT + availH * 0.5
local outerR = side * 0.38
local innerR = (wedgeW and wedgeW > 0 and wedgeW < 1) and (outerR * (1 - wedgeW)) or 0
local total = 0
for i = 1, #vals do
local v = max(0, tonumber(vals[i]) or 0)
total = total + v
end
if total <= 0 then total = 1 end
local curAng = startangle * pi / 180
local dir = ccw and 1 or -1
for i = 1, #vals do
local v = max(0, tonumber(vals[i]) or 0)
local frac = v / total
if frac > 0 then
local dAng = frac * 2 * pi * dir
local nextAng = curAng + dAng
local midAng = curAng + dAng * 0.5
local expDist = (tonumber(explode[i]) or 0) * outerR
local wcx = cx + expDist * cos(midAng)
local wcy = cy - expDist * sin(midAng)
local wCol, wAlpha = parseColor(colors[(i - 1) % #colors + 1])
local opAttr = (s.alpha or wAlpha) and format(' fill-opacity="%.2f"', tonumber(s.alpha or wAlpha)) or ''
if frac >= 0.9999 then
if innerR > 0 then
local pathD = format(
"M %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f A %.2f,%.2f 0 1,0 %.2f,%.2f M %.2f,%.2f A %.2f,%.2f 0 1,1 %.2f,%.2f A %.2f,%.2f 0 1,1 %.2f,%.2f Z",
wcx + outerR, wcy, outerR, outerR, wcx - outerR, wcy, outerR, outerR, wcx + outerR, wcy,
wcx + innerR, wcy, innerR, innerR, wcx - innerR, wcy, innerR, innerR, wcx + innerR, wcy
)
buf[#buf + 1] = format('<path d="%s" fill="%s" fill-rule="evenodd" stroke="%s" stroke-width="%.1f"%s/>', pathD, wCol, edgeCol, edgeLW, opAttr)
else
buf[#buf + 1] = format('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s" stroke="%s" stroke-width="%.1f"%s/>', wcx, wcy, outerR, wCol, edgeCol, edgeLW, opAttr)
end
else
local x1, y1 = wcx + outerR * cos(curAng), wcy - outerR * sin(curAng)
local x2, y2 = wcx + outerR * cos(nextAng), wcy - outerR * sin(nextAng)
local largeArc = (frac > 0.5) and 1 or 0
local sweepOuter = ccw and 0 or 1
local pathD
if innerR > 0 then
local ix1, iy1 = wcx + innerR * cos(nextAng), wcy - innerR * sin(nextAng)
local ix2, iy2 = wcx + innerR * cos(curAng), wcy - innerR * sin(curAng)
local sweepInner = ccw and 1 or 0
pathD = format(
"M %.2f,%.2f A %.2f,%.2f 0 %d,%d %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,%d %.2f,%.2f Z",
x1, y1, outerR, outerR, largeArc, sweepOuter, x2, y2,
ix1, iy1, innerR, innerR, largeArc, sweepInner, ix2, iy2
)
else
pathD = format(
"M %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,%d %.2f,%.2f Z",
wcx, wcy, x1, y1, outerR, outerR, largeArc, sweepOuter, x2, y2
)
end
buf[#buf + 1] = format('<path d="%s" fill="%s" stroke="%s" stroke-width="%.1f" stroke-linejoin="round"%s/>', pathD, wCol, edgeCol, edgeLW, opAttr)
end
-- 繪製百分比標籤 (autopct)
if autopct and autopct ~= "" then
local pctVal = frac * 100
local pctStr = safeFormat(tostring(autopct), pctVal)
local pr = (innerR > 0) and ((outerR + innerR) * 0.5) or (outerR * pctdist)
local px = wcx + pr * cos(midAng)
local py = wcy - pr * sin(midAng) + 3.5
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-val">%s</text>', px, py, esc(pctStr))
end
-- 繪製扇形外部文字標籤 (labels)
local lbl = labels[i]
if lbl and tostring(lbl) ~= "" then
local lr = outerR * lbldist
local lx = wcx + lr * cos(midAng)
local ly = wcy - lr * sin(midAng)
local cosM = cos(midAng)
if useHtmlLabels then
local alignCls = (cosM > 0.25) and "mpl-v" or ((cosM < -0.25) and "mpl-r" or "mpl-c")
overlays[#overlays + 1] = format('<span class="mpl-ov %s mpl-fs11" style="left:%.1fpx;top:%.1fpx;">%s</span>', alignCls, lx, ly, tostring(lbl))
else
local anc = (cosM > 0.25) and "start" or ((cosM < -0.25) and "end" or "middle")
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" class="mpl-tk">%s</text>', lx, ly + 3.5, anc, esc(lbl))
end
end
-- 圓餅圖扇形 Hover 隱形光圈與 Tooltip
local hr = (innerR > 0) and ((outerR + innerR) * 0.5) or (outerR * 0.55)
local hcx = wcx + hr * cos(midAng)
local hcy = wcy - hr * sin(midAng)
local ringR = max(12, min(22, outerR * 0.24))
local sliceTitle = (lbl and tostring(lbl) ~= "") and tostring(lbl) or ("Slice " .. i)
local sliceDetail = format(": %g (%.1f%%)", v, frac * 100)
addHoverPoint(overlays, hcx, hcy, ringR, wCol, sliceTitle, sliceDetail, box.y)
curAng = nextAng
end
end
if svgHelpers and svgHelpers.appendSubplotTitle then
svgHelpers.appendSubplotTitle(ax, box, box.x + padL, availW, box.y + padT, buf, overlays, useHtmlLabels)
end
if showLegend and legItems and svgHelpers and svgHelpers.appendAxesLegend then
local legX = box.x + box.w - legBoxW - 8
local legY = box.y + padT
svgHelpers.appendAxesLegend(buf, overlays, legItems, legX, legY, legBoxW, legBoxH, ax, useHtmlLabels, legFontSize, customState)
end
end
-- ==========================================
-- 4. 2D / 極座標數據值域掃描與統計預處理
-- ==========================================
function p.scan2DDataBounds(series_list, isPolarCoord)
local dataXMin, dataXMax = math.huge, -math.huge
local dataYMin, dataYMax = math.huge, -math.huge
local maxPolarR = 0
local function updateBounds(vx, vy)
if vx and vy and vx == vx and vy == vy and abs(vx) < 1e12 and abs(vy) < 1e12 then
if vx < dataXMin then dataXMin = vx end
if vx > dataXMax then dataXMax = vx end
if vy < dataYMin then dataYMin = vy end
if vy > dataYMax then dataYMax = vy end
local r_d = sqrt(vx * vx + vy * vy)
if r_d > maxPolarR then maxPolarR = r_d end
end
end
for _, s in ipairs(series_list or {}) do
local isPol = isPolarCoord or (s.polar == true)
if s.kind == "line" or s.kind == "scatter" or s.kind == "fill" or s.kind == "errorbar" then
local xs, ys = s.x or {}, s.y or {}
local n = min(#xs, #ys)
s._cx, s._cy = {}, {}
for j = 1, n do
local a, b = tonumber(xs[j]), tonumber(ys[j])
if a and b and a == a and b == b then
local vx, vy = a, b
if isPol then
local th, r = a, b
if s.r_first then r, th = a, b end
vx, vy = r * cos(th), r * sin(th)
end
s._cx[j], s._cy[j] = vx, vy
updateBounds(vx, vy)
if s.kind == "errorbar" then
local yL = s.yerr_low and tonumber(s.yerr_low[j]) or 0
local yH = s.yerr_high and tonumber(s.yerr_high[j]) or 0
local xL = s.xerr_low and tonumber(s.xerr_low[j]) or 0
local xH = s.xerr_high and tonumber(s.xerr_high[j]) or 0
updateBounds(vx - xL, vy - yL)
updateBounds(vx + xH, vy + yH)
end
else
s._cx[j], s._cy[j] = false, false
end
end
for _, mk in ipairs(s.marks or {}) do
updateBounds(mk.x, mk.y)
end
elseif s.kind == "bar" or s.kind == "hist" then
local xs, hs = s.x or {}, s.height or {}
local wIsTbl = (type(s.width) == "table")
local bIsTbl = (type(s.bottom) == "table")
for j = 1, min(#xs, #hs) do
local vx, vh = tonumber(xs[j]), tonumber(hs[j])
local w = wIsTbl and (tonumber(s.width[j]) or 0.8) or (tonumber(s.width) or 0.8)
local bot = bIsTbl and (tonumber(s.bottom[j]) or 0) or (tonumber(s.bottom) or 0)
if vx and vh then
if isPol then
if bot + vh > maxPolarR then maxPolarR = bot + vh end
elseif s.align == "edge" then
updateBounds(vx, bot)
updateBounds(vx + w, bot + vh)
else
updateBounds(vx - w * 0.55, bot)
updateBounds(vx + w * 0.55, bot + vh)
end
end
end
elseif s.kind == "barh" then
local ys, ws = s.x or {}, s.height or {}
local hIsTbl = (type(s.width) == "table")
local lIsTbl = (type(s.bottom) == "table")
for j = 1, min(#ys, #ws) do
local vy, vw = tonumber(ys[j]), tonumber(ws[j])
local h = hIsTbl and (tonumber(s.width[j]) or 0.8) or (tonumber(s.width) or 0.8)
local left = lIsTbl and (tonumber(s.bottom[j]) or 0) or (tonumber(s.bottom) or 0)
if vy and vw then
if s.align == "edge" then
updateBounds(left, vy)
updateBounds(left + vw, vy + h)
else
updateBounds(left, vy - h * 0.55)
updateBounds(left + vw, vy + h * 0.55)
end
end
end
elseif s.kind == "broken_barh" then
local xranges = s.xranges or {}
local y0 = tonumber(s.yrange and s.yrange[1]) or 0
local yh = tonumber(s.yrange and s.yrange[2]) or 1
for _, xr in ipairs(xranges) do
local xstart = tonumber(xr[1])
local xwidth = tonumber(xr[2])
if xstart and xwidth then
updateBounds(xstart, y0)
updateBounds(xstart + xwidth, y0 + yh)
end
end
elseif s.kind == "boxplot" or s.kind == "violinplot" then
local datasets = s.datasets or {}
local positions = s.positions or {}
local vert = (s.vert ~= false)
local boxW = tonumber(s.widths) or 0.5
s._stats = {}
for k, ds in ipairs(datasets) do
local pos = tonumber(positions[k]) or k
local st = computeBoxStats(ds, s.whis)
if s.kind == "violinplot" then
st.kde = computeViolinKDE(st.sorted, 24)
end
s._stats[k] = { pos = pos, st = st }
if vert then
updateBounds(pos - boxW * 0.8, st.min)
updateBounds(pos + boxW * 0.8, st.max)
else
updateBounds(st.min, pos - boxW * 0.8)
updateBounds(st.max, pos + boxW * 0.8)
end
end
elseif s.kind == "fill_between" then
local xs, y1s, y2s = s.x or {}, s.y1 or {}, s.y2 or {}
for j = 1, #xs do
local vx = tonumber(xs[j])
local v1 = tonumber(y1s[j])
local v2 = type(y2s) == "table" and tonumber(y2s[j]) or tonumber(y2s) or 0
if vx and v1 then updateBounds(vx, v1) end
if vx and v2 then updateBounds(vx, v2) end
end
elseif s.kind == "imshow" then
local ext = s.extent
local g = s.grid or {}
local nR = #g
local nC = (nR > 0 and #g[1]) or 1
local x0 = (ext and tonumber(ext[1])) or -0.5
local x1 = (ext and tonumber(ext[2])) or (nC - 0.5)
local y0 = (ext and tonumber(ext[3])) or -0.5
local y1 = (ext and tonumber(ext[4])) or (nR - 0.5)
updateBounds(x0, y0)
updateBounds(x1, y1)
end
end
return dataXMin, dataXMax, dataYMin, dataYMax, maxPolarR
end
-- ==========================================
-- 5. 2D / 極座標全圖表序列繪製引擎
-- ==========================================
function p.render2DSeries(ax, series_list, ctx)
local buf = ctx.buf
local overlays = ctx.overlays
local customState = ctx.customState
local palette = ctx.palette
local isPolarCoord = ctx.isPolarCoord
local plotL, plotT, plotW, plotH = ctx.plotL, ctx.plotT, ctx.plotW, ctx.plotH
local yMin, yMax = ctx.yMin, ctx.yMax
local xSpan, ySpan = ctx.xSpan, ctx.ySpan
local xUnit, xUnitLabel = ctx.xUnit, ctx.xUnitLabel
local yUnit, yUnitLabel = ctx.yUnit, ctx.yUnitLabel
local tickFracX, tickFracY = ctx.tickFracX, ctx.tickFracY
local xFmt, yFmt = ctx.xFmt, ctx.yFmt
local mapX, mapY = ctx.mapX, ctx.mapY
local animateMode = ctx.animateMode
local lastCmap, lastVMin, lastVMax = "viridis", 0, 1
local yMargin = ySpan * 3.5
local lineClass = (animateMode == "hover" or animateMode == "draw" or animateMode == "auto") and "mpl-line mpl-anim-path" or "mpl-line"
for i, s in ipairs(series_list or {}) do
local rawColor = (type(s.color) == "table" and s.color[1]) or s.color or palette[(i - 1) % #palette + 1]
local strokeColor, strokeOpacity = parseColor(rawColor)
local alpha = tonumber(s.alpha) or strokeOpacity
if s.kind == "imshow" then
local grid = s.grid or {}
local nR = #grid
local nC = (nR > 0 and #grid[1]) or 1
local ext = s.extent
local gx0 = (ext and tonumber(ext[1])) or -0.5
local gx1 = (ext and tonumber(ext[2])) or (nC - 0.5)
local gy0 = (ext and tonumber(ext[3])) or -0.5
local gy1 = (ext and tonumber(ext[4])) or (nR - 0.5)
local pxL, pxR = mapX(gx0), mapX(gx1)
local pyT, pyB = mapY(gy1), mapY(gy0)
local cellW = (pxR - pxL) / max(1, nC)
local cellH = (pyB - pyT) / max(1, nR)
local vMin, vMax = tonumber(s.vmin) or math.huge, tonumber(s.vmax) or -math.huge
if vMin == math.huge or vMax == -math.huge then
for r = 1, nR do
for c = 1, nC do
local v = tonumber(grid[r][c])
if v and v == v then
if v < vMin then vMin = v end
if v > vMax then vMax = v end
end
end
end
end
if vMin == math.huge then vMin, vMax = 0, 1 end
local vSpan = (vMax > vMin) and (vMax - vMin) or 1
local cmap = s.cmap or ax.colormap or "viridis"
lastCmap, lastVMin, lastVMax = cmap, vMin, vMax
local isLower = (s.origin == "lower")
buf[#buf + 1] = '<g shape-rendering="crispEdges">'
for r = 1, nR do
local rowIdx = isLower and (nR - r + 1) or r
local row = grid[rowIdx] or {}
local py = pyT + (r - 1) * cellH
local c = 1
while c <= nC do
local v = tonumber(row[c]) or vMin
local colHex = sampleColormap(cmap, (v - vMin) / vSpan, 1.0)
local runLen = 1
while (c + runLen <= nC) do
local vn = tonumber(row[c + runLen]) or vMin
if sampleColormap(cmap, (vn - vMin) / vSpan, 1.0) ~= colHex then break end
runLen = runLen + 1
end
buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>', pxL + (c - 1) * cellW, py, runLen * cellW, cellH, colHex)
c = c + runLen
end
end
buf[#buf + 1] = '</g>'
elseif s.kind == "fill" then
local cxArr, cyArr = s._cx or {}, s._cy or {}
local n = min(#cxArr, #cyArr)
if n >= 2 then
local pbuf = {}
for j = 1, n do
local vx, vy = cxArr[j], cyArr[j]
if vx and vy then
pbuf[#pbuf + 1] = format("%s %.2f,%.2f", (#pbuf == 0 and "M" or "L"), mapX(vx), mapY(vy))
end
end
pbuf[#pbuf + 1] = "Z"
local fAlpha = alpha or 0.30
local eAttr = (s.edgecolor and s.edgecolor ~= "none") and format(' stroke="%s" stroke-width="%.1f"', parseColor(s.edgecolor), tonumber(s.linewidth) or 1.0) or ' stroke="none"'
buf[#buf + 1] = format('<path d="%s" fill="%s" fill-opacity="%.2f"%s/>', table.concat(pbuf, " "), strokeColor, fAlpha, eAttr)
end
elseif s.kind == "fill_between" then
local xs, y1s, y2s = s.x or {}, s.y1 or {}, s.y2 or {}
local whereArr = s.where
local n = min(#xs, #y1s)
if n >= 2 then
local pbuf = {}
for j = 1, n do
if not whereArr or whereArr[j] ~= false then
local vx, v1 = tonumber(xs[j]), tonumber(y1s[j])
if vx and v1 then
pbuf[#pbuf + 1] = format("%s %.2f,%.2f", (#pbuf == 0 and "M" or "L"), mapX(vx), mapY(v1))
end
end
end
for j = n, 1, -1 do
if not whereArr or whereArr[j] ~= false then
local vx = tonumber(xs[j])
local v2 = type(y2s) == "table" and tonumber(y2s[j]) or tonumber(y2s) or 0
if vx and v2 then
pbuf[#pbuf + 1] = format("L %.2f,%.2f", mapX(vx), mapY(v2))
end
end
end
if #pbuf > 0 then
pbuf[#pbuf + 1] = "Z"
local fAlpha = alpha or 0.35
buf[#buf + 1] = format('<path d="%s" fill="%s" fill-opacity="%.2f" stroke="none"/>', table.concat(pbuf, " "), strokeColor, fAlpha)
end
end
elseif s.kind == "bar" or s.kind == "barh" or s.kind == "hist" then
local xs, hs = s.x or {}, s.height or {}
local wIsTbl = (type(s.width) == "table")
local bIsTbl = (type(s.bottom) == "table")
local cIsTbl = (type(s.color) == "table")
local barCols = s.bar_colors or {}
local barAlphas = s.bar_alphas or {}
local barEdges = s.bar_edgecolors or {}
local barLWs = s.bar_linewidths or {}
local defaultECol = s.edgecolor and parseColor(s.edgecolor) or strokeColor
local defaultLW = tonumber(s.linewidth) or 0.8
local sName = (s.label and not string.match(tostring(s.label), "^_")) and tostring(s.label) or nil
local isPolBar = (isPolarCoord or s.polar) and (s.kind == "bar" or s.kind == "hist")
for j = 1, min(#xs, #hs) do
local v1, v2 = tonumber(xs[j]), tonumber(hs[j])
local w = wIsTbl and (tonumber(s.width[j]) or 0.8) or (tonumber(s.width) or 0.8)
local bot = bIsTbl and (tonumber(s.bottom[j]) or 0) or (tonumber(s.bottom) or 0)
local bCol = (barCols[j] and parseColor(barCols[j], strokeColor))
or (cIsTbl and parseColor(s.color[(j - 1) % #s.color + 1], strokeColor))
or strokeColor
local bAlpha = tonumber(barAlphas[j]) or alpha
local eCol = (barEdges[j] and parseColor(barEdges[j])) or defaultECol
local lw = tonumber(barLWs[j]) or defaultLW
local opAttr = bAlpha and format(' fill-opacity="%.2f" stroke-opacity="%.2f"', bAlpha, bAlpha) or ''
if v1 and v2 then
if isPolBar then
-- 極座標扇形長條圖 (Polar Bar / Rose Chart Wedge)
local cx, cy = mapX(0), mapY(0)
local th1 = (s.align == "edge") and v1 or (v1 - w * 0.5)
local th2 = (s.align == "edge") and (v1 + w) or (v1 + w * 0.5)
local r0Px = max(0, (bot / xSpan) * plotW)
local r1Px = max(0, ((bot + v2) / xSpan) * plotW)
if r1Px > r0Px then
local largeArc = (abs(w) > pi) and 1 or 0
local sweepOuter = (w >= 0) and 0 or 1
local pathD
if r0Px <= 0.5 then
local x1, y1 = cx + r1Px * cos(th1), cy - r1Px * sin(th1)
local x2, y2 = cx + r1Px * cos(th2), cy - r1Px * sin(th2)
pathD = format(
"M %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,%d %.2f,%.2f Z",
cx, cy, x1, y1, r1Px, r1Px, largeArc, sweepOuter, x2, y2
)
else
local x1, y1 = cx + r1Px * cos(th1), cy - r1Px * sin(th1)
local x2, y2 = cx + r1Px * cos(th2), cy - r1Px * sin(th2)
local ix1, iy1 = cx + r0Px * cos(th2), cy - r0Px * sin(th2)
local ix2, iy2 = cx + r0Px * cos(th1), cy - r0Px * sin(th1)
local sweepInner = (w >= 0) and 1 or 0
pathD = format(
"M %.2f,%.2f A %.2f,%.2f 0 %d,%d %.2f,%.2f L %.2f,%.2f A %.2f,%.2f 0 %d,%d %.2f,%.2f Z",
x1, y1, r1Px, r1Px, largeArc, sweepOuter, x2, y2,
ix1, iy1, r0Px, r0Px, largeArc, sweepInner, ix2, iy2
)
end
buf[#buf + 1] = format(
'<path d="%s" fill="%s" stroke="%s" stroke-width="%.1f" stroke-linejoin="round"%s/>',
pathD, bCol, eCol, lw, opAttr
)
local midTh = 0.5 * (th1 + th2)
local midRPx = (r0Px > 0.5) and (0.5 * (r0Px + r1Px)) or (r1Px * 0.6)
addHoverPoint(
overlays,
cx + midRPx * cos(midTh), cy - midRPx * sin(midTh),
max(10, min(18, r1Px * 0.18)),
bCol, sName or format("θ=%.2f", v1), format(": r=%g (w=%g)", v2, w), plotT
)
end
elseif s.kind == "bar" or s.kind == "hist" then
local x0 = (s.align == "edge") and mapX(v1) or mapX(v1 - w * 0.5)
local x1 = (s.align == "edge") and mapX(v1 + w) or mapX(v1 + w * 0.5)
local y0, y1 = mapY(bot), mapY(bot + v2)
local rx, ry, rw, rh = min(x0, x1), min(y0, y1), max(0.8, abs(x1 - x0)), max(0.5, abs(y1 - y0))
buf[#buf + 1] = format(
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="%s" stroke-width="%.1f"%s/>',
rx, ry, rw, rh, bCol, eCol, lw, opAttr
)
local catLbl = (ax.xticklabels and ax.xticklabels[j]) or format("%g", v1)
local bTitle = sName or catLbl
local bDetail = sName and format(" (%s): %g", catLbl, v2) or format(": %g", v2)
addHoverBox(overlays, rx, ry, rw, rh, bCol, bTitle, bDetail, plotT)
else
local y0 = (s.align == "edge") and mapY(v1) or mapY(v1 - w * 0.5)
local y1 = (s.align == "edge") and mapY(v1 + w) or mapY(v1 + w * 0.5)
local x0, x1 = mapX(bot), mapX(bot + v2)
local rx, ry, rw, rh = min(x0, x1), min(y0, y1), max(0.5, abs(x1 - x0)), max(0.8, abs(y1 - y0))
buf[#buf + 1] = format(
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="%s" stroke-width="%.1f"%s/>',
rx, ry, rw, rh, bCol, eCol, lw, opAttr
)
local catLbl = (ax.yticklabels and ax.yticklabels[j]) or format("%g", v1)
local bTitle = catLbl or sName or ("Item " .. j)
local bDetail = (bIsTbl or abs(bot) > 1e-9) and format(": %g ~ %g", bot, bot + v2) or format(": %g", v2)
addHoverBox(overlays, rx, ry, rw, rh, bCol, bTitle, bDetail, plotT)
end
end
end
elseif s.kind == "broken_barh" then
local xranges = s.xranges or {}
local y0 = tonumber(s.yrange and s.yrange[1]) or 0
local yh = tonumber(s.yrange and s.yrange[2]) or 1
local fCols = s.facecolors
local eCol = s.edgecolor and parseColor(s.edgecolor) or "none"
local lw = tonumber(s.linewidth) or 0.8
local opAttr = alpha and format(' fill-opacity="%.2f"', alpha) or ''
local py0, py1 = mapY(y0), mapY(y0 + yh)
local sName = (s.label and not string.match(tostring(s.label), "^_")) and tostring(s.label) or "Task"
for j, xr in ipairs(xranges) do
local xstart, xwidth = tonumber(xr[1]), tonumber(xr[2])
if xstart and xwidth then
local cHex = strokeColor
if type(fCols) == "table" and #fCols > 0 then
cHex = parseColor(fCols[(j - 1) % #fCols + 1], strokeColor)
elseif type(fCols) == "string" then
cHex = parseColor(fCols, strokeColor)
end
local px0, px1 = mapX(xstart), mapX(xstart + xwidth)
local rx, ry, rw, rh = min(px0, px1), min(py0, py1), max(0.5, abs(px1 - px0)), max(0.8, abs(py1 - py0))
buf[#buf + 1] = format(
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="%s" stroke-width="%.1f"%s/>',
rx, ry, rw, rh, cHex, eCol, lw, opAttr
)
addHoverBox(overlays, rx, ry, rw, rh, cHex, sName, format(": %g ~ %g", xstart, xstart + xwidth), plotT)
end
end
elseif s.kind == "boxplot" then
local vert = (s.vert ~= false)
local boxW = tonumber(s.widths) or 0.5
local patchArtist = (s.patch_artist == true)
local showMeans = (s.showmeans == true)
local showFliers = (s.showfliers ~= false)
local fillCol = patchArtist and strokeColor or "#ffffff"
local fillOp = patchArtist and (alpha or 0.65) or 1.0
local bdrCol = patchArtist and RendererUtils.shadeColor(strokeColor, 0.65) or "#2b2b2b"
local medCol = "#ff7f0e"
for k, item in ipairs(s._stats or {}) do
local pos, st = item.pos, item.st
local hw = boxW * 0.5
local capW = hw * 0.55
local grpName = (vert and ax.xticklabels and ax.xticklabels[k]) or ((not vert) and ax.yticklabels and ax.yticklabels[k]) or ("Group " .. k)
local statDetail = format(" | Med: %g (Q1: %g, Q3: %g, Min: %g, Max: %g)", st.med, st.q1, st.q3, st.wLow, st.wHigh)
if vert then
local px = mapX(pos)
local pxL, pxR = mapX(pos - hw), mapX(pos + hw)
local cL, cR = mapX(pos - capW), mapX(pos + capW)
local pyQ1, pyMed, pyQ3 = mapY(st.q1), mapY(st.med), mapY(st.q3)
local pyWL, pyWH = mapY(st.wLow), mapY(st.wHigh)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" stroke-dasharray="3,2"/>', px, pyQ1, px, pyWL, bdrCol)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" stroke-dasharray="3,2"/>', px, pyQ3, px, pyWH, bdrCol)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', cL, pyWL, cR, pyWL, bdrCol)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', cL, pyWH, cR, pyWH, bdrCol)
buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="1.3"/>', min(pxL, pxR), min(pyQ1, pyQ3), max(2, abs(pxR - pxL)), max(1, abs(pyQ1 - pyQ3)), fillCol, fillOp, bdrCol)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="2.0"/>', pxL, pyMed, pxR, pyMed, medCol)
if showMeans then
buf[#buf + 1] = renderSymbol("triangle-up", px, mapY(st.mean), 3.5, "#2ca02c", "#2ca02c", 0.5, 1, customState)
end
if showFliers then
for _, fv in ipairs(st.fliers) do
buf[#buf + 1] = renderSymbol("circle", px, mapY(fv), 2.8, "none", "#333333", 1.1, 0.85, customState)
end
end
addHoverBox(overlays, min(pxL, pxR), min(pyWL, pyWH), abs(pxR - pxL), abs(pyWL - pyWH), strokeColor, grpName, statDetail, plotT)
else
local py = mapY(pos)
local pyT, pyB = mapY(pos + hw), mapY(pos - hw)
local cT, cB = mapY(pos + capW), mapY(pos - capW)
local pxQ1, pxMed, pxQ3 = mapX(st.q1), mapX(st.med), mapX(st.q3)
local pxWL, pxWH = mapX(st.wLow), mapX(st.wHigh)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" stroke-dasharray="3,2"/>', pxQ1, py, pxWL, py, bdrCol)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2" stroke-dasharray="3,2"/>', pxQ3, py, pxWH, py, bdrCol)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', pxWL, cT, pxWL, cB, bdrCol)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', pxWH, cT, pxWH, cB, bdrCol)
buf[#buf + 1] = format('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="1.3"/>', min(pxQ1, pxQ3), min(pyT, pyB), max(1, abs(pxQ3 - pxQ1)), max(2, abs(pyB - pyT)), fillCol, fillOp, bdrCol)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="2.0"/>', pxMed, pyT, pxMed, pyB, medCol)
if showMeans then
buf[#buf + 1] = renderSymbol("triangle-right", mapX(st.mean), py, 3.5, "#2ca02c", "#2ca02c", 0.5, 1, customState)
end
if showFliers then
for _, fv in ipairs(st.fliers) do
buf[#buf + 1] = renderSymbol("circle", mapX(fv), py, 2.8, "none", "#333333", 1.1, 0.85, customState)
end
end
addHoverBox(overlays, min(pxWL, pxWH), min(pyT, pyB), abs(pxWH - pxWL), abs(pyB - pyT), strokeColor, grpName, statDetail, plotT)
end
end
elseif s.kind == "violinplot" then
local vert = (s.vert ~= false)
local vWidth = tonumber(s.widths) or 0.5
local showMeans = (s.showmeans == true)
local showMedians = (s.showmedians == true)
local showExtrema = (s.showextrema ~= false)
local vAlpha = alpha or 0.50
local bdrCol = RendererUtils.shadeColor(strokeColor, 0.72)
for k, item in ipairs(s._stats or {}) do
local pos, st = item.pos, item.st
local kde = st.kde or {}
local hw = vWidth * 0.5
local grpName = (vert and ax.xticklabels and ax.xticklabels[k]) or ((not vert) and ax.yticklabels and ax.yticklabels[k]) or ("Violin " .. k)
local statDetail = format(" | Med: %g (Mean: %.2f, Min: %g, Max: %g)", st.med, st.mean, st.min, st.max)
if #kde >= 2 then
local pts = {}
if vert then
for idx_k = 1, #kde do
pts[#pts + 1] = format("%.2f,%.2f", mapX(pos - kde[idx_k].dens * hw), mapY(kde[idx_k].val))
end
for idx_k = #kde, 1, -1 do
pts[#pts + 1] = format("%.2f,%.2f", mapX(pos + kde[idx_k].dens * hw), mapY(kde[idx_k].val))
end
else
for idx_k = 1, #kde do
pts[#pts + 1] = format("%.2f,%.2f", mapX(kde[idx_k].val), mapY(pos - kde[idx_k].dens * hw))
end
for idx_k = #kde, 1, -1 do
pts[#pts + 1] = format("%.2f,%.2f", mapX(kde[idx_k].val), mapY(pos + kde[idx_k].dens * hw))
end
end
buf[#buf + 1] = format('<polygon points="%s" fill="%s" fill-opacity="%.2f" stroke="%s" stroke-width="1.2"/>', table.concat(pts, " "), strokeColor, vAlpha, bdrCol)
if vert then
local px = mapX(pos)
local pxL, pxR = mapX(pos - hw), mapX(pos + hw)
local cL, cR = mapX(pos - hw * 0.35), mapX(pos + hw * 0.35)
local pyMin, pyMax = mapY(st.min), mapY(st.max)
if showExtrema then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2"/>', px, pyMin, px, pyMax, bdrCol)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', cL, pyMin, cR, pyMin, bdrCol)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', cL, pyMax, cR, pyMax, bdrCol)
end
if showMedians then
local pyMed = mapY(st.med)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#222222" stroke-width="2.0"/>', cL, pyMed, cR, pyMed)
end
if showMeans then
local pyMean = mapY(st.mean)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d62728" stroke-width="1.8"/>', cL, pyMean, cR, pyMean)
end
addHoverBox(overlays, min(pxL, pxR), min(pyMin, pyMax), abs(pxR - pxL), abs(pyMin - pyMax), strokeColor, grpName, statDetail, plotT)
else
local py = mapY(pos)
local pyT, pyB = mapY(pos + hw), mapY(pos - hw)
local cT, cB = mapY(pos + hw * 0.35), mapY(pos - hw * 0.35)
local pxMin, pxMax = mapX(st.min), mapX(st.max)
if showExtrema then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.2"/>', pxMin, py, pxMax, py, bdrCol)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', pxMin, cT, pxMin, cB, bdrCol)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="1.3"/>', pxMax, cT, pxMax, cB, bdrCol)
end
if showMedians then
local pxMed = mapX(st.med)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#222222" stroke-width="2.0"/>', pxMed, cT, pxMed, cB)
end
if showMeans then
local pxMean = mapX(st.mean)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d62728" stroke-width="1.8"/>', pxMean, cT, pxMean, cB)
end
addHoverBox(overlays, min(pxMin, pxMax), min(pyT, pyB), abs(pxMax - pxMin), abs(pyB - pyT), strokeColor, grpName, statDetail, plotT)
end
end
end
elseif s.kind == "hline" or s.kind == "vline" then
local posVal = tonumber(s.pos) or 0
local fMin = tonumber(s.min_frac) or 0
local fMax = tonumber(s.max_frac) or 1
local lw = tonumber(s.linewidth) or 1.5
local dash = getDashArray(s.linestyle)
local dashAttr = dash and format(' stroke-dasharray="%s"', dash) or ''
local opAttr = alpha and format(' stroke-opacity="%.2f"', alpha) or ''
if s.kind == "hline" then
local py = mapY(posVal)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s%s/>', plotL + fMin * plotW, py, plotL + fMax * plotW, py, strokeColor, lw, dashAttr, opAttr)
else
local px = mapX(posVal)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s%s/>', px, plotT + (1 - fMax) * plotH, px, plotT + (1 - fMin) * plotH, strokeColor, lw, dashAttr, opAttr)
end
elseif s.kind == "line" or s.kind == "errorbar" then
local cxArr, cyArr = s._cx or {}, s._cy or {}
local rawX, rawY = s.x or {}, s.y or {}
local n_pts = min(#cxArr, #cyArr)
local sLW = (s.linestyle == "none") and 0 or (tonumber(s.linewidth) or 2.0)
local sInterp = (isPolarCoord or s.polar) and "linear" or (s.interpolate or "linear")
local dash = getDashArray(s.linestyle)
local dashAttr = dash and format(' stroke-dasharray="%s"', dash) or ''
local opAttr = alpha and format(' stroke-opacity="%.2f"', alpha) or ''
if s.kind == "errorbar" then
local eCol = parseColor(s.ecolor, strokeColor)
local eLW = tonumber(s.elinewidth) or max(1.0, sLW * 0.75)
local capSz = tonumber(s.capsize) or 3.5
for j = 1, n_pts do
local vx, vy = cxArr[j], cyArr[j]
if vx and vy then
local px, py = mapX(vx), mapY(vy)
if s.yerr_low and s.yerr_high then
local yl = tonumber(s.yerr_low[j]) or 0
local yh = tonumber(s.yerr_high[j]) or 0
local py1, py2 = mapY(vy - yl), mapY(vy + yh)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s/>', px, py1, px, py2, eCol, eLW, opAttr)
if capSz > 0 then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s/>', px - capSz, py1, px + capSz, py1, eCol, eLW, opAttr)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s/>', px - capSz, py2, px + capSz, py2, eCol, eLW, opAttr)
end
end
if s.xerr_low and s.xerr_high then
local xl = tonumber(s.xerr_low[j]) or 0
local xh = tonumber(s.xerr_high[j]) or 0
local px1, px2 = mapX(vx - xl), mapX(vx + xh)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s/>', px1, py, px2, py, eCol, eLW, opAttr)
if capSz > 0 then
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s/>', px1, py - capSz, px1, py + capSz, eCol, eLW, opAttr)
buf[#buf + 1] = format('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" stroke-width="%.2f"%s/>', px2, py - capSz, px2, py + capSz, eCol, eLW, opAttr)
end
end
end
end
end
local userJumpTh = tonumber(s.jump_threshold or ax.jump_threshold)
local jumpCutSpan = userJumpTh or (ySpan * 1.5)
local jumpType = string.lower(tostring(s.jump_type or ax.jump_type or "auto"))
local showJumpCircles = (s.mark_jumps == true) or (ax.mark_jumps == true) or (s.jumps ~= nil)
local jumps = (type(s.jumps) == "table" and s.jumps) or ((showJumpCircles or userJumpTh) and detectArrayJumps(cxArr, cyArr, ySpan, userJumpTh, jumpType)) or {}
local jumpByIdx = {}
for _, jmp in ipairs(jumps) do
if jmp.splitIdx then jumpByIdx[jmp.splitIdx] = jmp end
end
if sLW > 0 then
local segments, curr_seg = {}, {}
local prev_vy = nil
for j = 1, n_pts do
local vx, vy = cxArr[j], cyArr[j]
local isValid = vx and vy and vx == vx and vy == vy and vy >= (yMin - yMargin) and vy <= (yMax + yMargin)
if isValid then
local isAutoCut = (not isPolarCoord and not s.polar and prev_vy) and (
(userJumpTh and abs(vy - prev_vy) >= userJumpTh) or
(abs(vy - prev_vy) > jumpCutSpan and ((vy - yMin) * (prev_vy - yMin) < 0 or (vy > yMax and prev_vy < yMin) or (vy < yMin and prev_vy > yMax)))
)
if isAutoCut then
if #curr_seg > 1 then segments[#segments + 1] = curr_seg end
curr_seg = {}
end
curr_seg[#curr_seg + 1] = { mapX(vx), mapY(vy) }
prev_vy = vy
if jumpByIdx[j] then
local jmp = jumpByIdx[j]
curr_seg[#curr_seg] = { mapX(jmp.x), mapY(jmp.yL) }
if #curr_seg > 1 then segments[#segments + 1] = curr_seg end
curr_seg = { { mapX(jmp.x), mapY(jmp.yR) } }
end
else
if #curr_seg > 1 then segments[#segments + 1] = curr_seg end
curr_seg, prev_vy = {}, nil
end
end
if #curr_seg > 1 then segments[#segments + 1] = curr_seg end
for _, seg in ipairs(segments) do
local path_d = buildSegmentPath(seg, sInterp)
buf[#buf + 1] = format('<path d="%s" class="%s" stroke="%s" stroke-width="%.2f"%s%s/>', path_d, lineClass, strokeColor, sLW, dashAttr, opAttr)
end
end
if showJumpCircles and #jumps > 0 then
local jRadius = max(3.2, (sLW > 0 and sLW or 2.0) * 1.4)
local jStrokeW = max(1.5, (sLW > 0 and sLW or 2.0) * 0.8)
for _, jmp in ipairs(jumps) do
local px = mapX(jmp.x)
local pyL, pyR = mapY(jmp.yL), mapY(jmp.yR)
local solidY, hollowY1, hollowY2 = nil, nil, nil
if jumpType == "left" or jumpType == "left-continuous" then
solidY, hollowY1 = pyL, pyR
elseif jumpType == "right" or jumpType == "right-continuous" then
hollowY1, solidY = pyL, pyR
else
if jmp.y0 ~= nil and abs(jmp.y0 - jmp.yL) <= 1e-4 then solidY, hollowY1 = pyL, pyR
elseif jmp.y0 ~= nil and abs(jmp.y0 - jmp.yR) <= 1e-4 then hollowY1, solidY = pyL, pyR
elseif jmp.y0 ~= nil then hollowY1, hollowY2, solidY = pyL, pyR, mapY(jmp.y0)
else hollowY1, solidY = pyL, pyR end
end
if hollowY1 then buf[#buf + 1] = renderSymbol("circle", px, hollowY1, jRadius, "#f8f8f8", strokeColor, jStrokeW, alpha, customState) end
if hollowY2 then buf[#buf + 1] = renderSymbol("circle", px, hollowY2, jRadius, "#f8f8f8", strokeColor, jStrokeW, alpha, customState) end
if solidY then buf[#buf + 1] = renderSymbol("circle", px, solidY, jRadius, strokeColor, strokeColor, 1.0, alpha, customState) end
end
end
local hasMarker = (s.marker ~= nil and s.marker ~= "" and s.marker ~= "none") or (s.kind == "errorbar" and s.marker ~= "none")
local marks = s.marks or {}
local showVals = (s.show_mark_values == true) or (ax.show_mark_values == true)
local mvFmt = s.mark_value_format or ax.mark_value_format
local mvDir = string.lower(tostring(s.mark_value_dir or ax.mark_value_dir or "top"))
local symR = tonumber(s.markersize) or max(3.2, (sLW > 0 and sLW or 2.0) * 1.35)
local symShape = s.marker or "circle"
local sName = (s.label and not string.match(tostring(s.label), "^_")) and tostring(s.label) or ("y" .. i)
local function drawMarkAndVal(vx, vy, origIdx)
local px, py = mapX(vx), mapY(vy)
if hasMarker or #marks > 0 or sLW <= 0 then
buf[#buf + 1] = renderSymbol(symShape, px, py, symR, strokeColor, strokeColor, 0, alpha, customState)
end
if showVals and py >= plotT - 2 and py <= plotT + plotH + 2 then
local valStr = mvFmt and safeFormat(mvFmt, vy) or formatTickValue(vy, yUnit, yUnitLabel, tickFracY, yFmt)
local dir = mvDir
if dir == "top" and (py - plotT < 15) then dir = "bottom"
elseif dir == "bottom" and (plotT + plotH - py < 15) then dir = "top" end
local tx, ty, anchor = px, py, "middle"
local offset = symR + 4.5
if dir == "bottom" then ty = py + offset + 7.5
elseif dir == "left" then tx, ty, anchor = px - offset - 2, py + 3.5, "end"
elseif dir == "right" then tx, ty, anchor = px + offset + 2, py + 3.5, "start"
else ty = py - offset end
if anchor == "middle" then
if tx - plotL < 14 then anchor = "start"; tx = max(plotL + 2, tx - 4)
elseif plotL + plotW - tx < 14 then anchor = "end"; tx = min(plotL + plotW - 2, tx + 4) end
end
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="%s" class="mpl-val">%s</text>', tx, ty, anchor, esc(valStr))
end
if px >= plotL - 2 and px <= plotL + plotW + 2 and py >= plotT - 2 and py <= plotT + plotH + 2 then
local xDisp = (origIdx and ax.xticklabels and ax.xticklabels[origIdx]) or formatTickValue(origIdx and tonumber(rawX[origIdx]) or vx, xUnit, xUnitLabel, tickFracX, xFmt)
local yDisp = formatTickValue(origIdx and tonumber(rawY[origIdx]) or vy, yUnit, yUnitLabel, tickFracY, yFmt)
addHoverPoint(overlays, px, py, max(8.5, symR + 5), strokeColor, sName, format(" (%s): %s", xDisp, yDisp), plotT)
end
end
if #marks > 0 then
for _, mk in ipairs(marks) do drawMarkAndVal(mk.x, mk.y, nil) end
else
local hoverStep = (n_pts <= 65 or hasMarker or sLW <= 0) and 1 or max(1, floor(n_pts / 40))
for j = 1, n_pts do
local vx, vy = cxArr[j], cyArr[j]
if vx and vy and ((j - 1) % hoverStep == 0 or j == n_pts) then
drawMarkAndVal(vx, vy, j)
end
end
end
elseif s.kind == "scatter" then
local cxArr, cyArr = s._cx or {}, s._cy or {}
local rawX, rawY = s.x or {}, s.y or {}
local n_pts = min(#cxArr, #cyArr)
local cArr = s.c_array
local sArr = s.s_array
local baseR = tonumber(s.markersize) or 3.8
local shape = s.marker or "circle"
local cmap = s.cmap or ax.colormap or "viridis"
local edgeCol = s.edgecolor and parseColor(s.edgecolor) or nil
local edgeLW = tonumber(s.linewidth) or (edgeCol and 0.8 or 0)
local showVals = (s.show_mark_values == true) or (ax.show_mark_values == true)
local mvFmt = s.mark_value_format or ax.mark_value_format
local sName = (s.label and not string.match(tostring(s.label), "^_")) and tostring(s.label) or nil
local cMin, cMax = math.huge, -math.huge
if cArr and #cArr > 0 then
for _, cv in ipairs(cArr) do
if type(cv) == "number" and cv == cv then
if cv < cMin then cMin = cv end
if cv > cMax then cMax = cv end
end
end
end
if cMin < math.huge then
lastCmap, lastVMin, lastVMax = cmap, cMin, cMax
end
local cSpan = (cMax > cMin) and (cMax - cMin) or 1
for j = 1, n_pts do
local vx, vy = cxArr[j], cyArr[j]
if vx and vy then
local px, py = mapX(vx), mapY(vy)
local ptCol = strokeColor
if cArr and cArr[j] ~= nil then
if type(cArr[j]) == "number" then
ptCol = sampleColormap(cmap, (cArr[j] - cMin) / cSpan, 1.0)
else
ptCol = parseColor(cArr[j], strokeColor)
end
end
local ptR = baseR
local rawSize = sArr and tonumber(sArr[j]) or nil
if rawSize then
ptR = max(1.5, sqrt(rawSize) * 0.58)
end
buf[#buf + 1] = renderSymbol(shape, px, py, ptR, ptCol, edgeCol or ptCol, edgeLW, alpha, customState)
if showVals then
local valStr = mvFmt and safeFormat(mvFmt, vy) or formatTickValue(vy, yUnit, yUnitLabel, tickFracY, yFmt)
local ty = (py - plotT < 15) and (py + ptR + 11) or (py - ptR - 4)
buf[#buf + 1] = format('<text x="%.2f" y="%.2f" text-anchor="middle" class="mpl-val">%s</text>', px, ty, esc(valStr))
end
if px >= plotL - 2 and px <= plotL + plotW + 2 and py >= plotT - 2 and py <= plotT + plotH + 2 then
local xDisp = formatTickValue(tonumber(rawX[j]) or vx, xUnit, xUnitLabel, tickFracX, xFmt)
local yDisp = formatTickValue(tonumber(rawY[j]) or vy, yUnit, yUnitLabel, tickFracY, yFmt)
local bTitle = sName or xDisp
local bDetail = sName and format(" (%s): %s", xDisp, yDisp) or format(": %s", yDisp)
if rawSize then bDetail = bDetail .. format(" (size: %g)", rawSize) end
addHoverPoint(overlays, px, py, max(8.5, ptR + 4.5), ptCol, bTitle, bDetail, plotT)
end
end
end
end
end
return lastCmap, lastVMin, lastVMax
end
return p