Below an idea for a PlotlyJS-based user defined plotting function.
The function can be called with an x-vector and an y-vector/-array.
And it can handle up to 4-y-axes as well.
I am happy about comments.
"""
priv_plot_data(x_vec, y_array, ...) or
priv_plot_data([], y_array, ...)
Gives back a PlotlyJS-Print-Object.
# Inputs:
* `x_vec`: x-values
* `y_data`: y-data matrix (trace data points)
* `trace_labels`: trace lables
* `title_and_x_axis_label`: two element string vector
* `y_axis_labels`: coloured trace specific labels on each axis
* `axes_bindings`: bind a trace to one of the y-axes
# Named Inputs (optional):
* `color_palette`: your own colorway
* `axis_positions`: seven-element vector to define the horizontal location of the y-axes
* `in_xDBG`: variable name or label for exspression to be investigated
# Output:
PlotlyJS-Print-Object
# Current Developer:
Stefan Pofahl (12-03-2026)
"""
function priv_plot_traces(in_x_vec::AbstractVector, in_y_data::Union{AbstractVector, AbstractMatrix},
in_trace_labels::AbstractVector=[""], in_title_and_x_axis_label::AbstractVector=[""],
in_y_axis_labels::AbstractVector=[""], in_axes_bindings::AbstractVector=[];
color_palette::AbstractVector=[""], in_xDBG::Bool=false,
axis_positions::AbstractVector{<:Real}=Float64[])
#: --- ToDo -----------------------------------------------------------------------
#: optioonal parameters:
#: - xMarkerON (vector of int, 0= false, 1= true)
#: - define the position of the legend (can also be used to switch off the legend)
#: --------------------------------------------------------------------------------
data_pts = size(in_y_data, 1)
traces_count = size(in_y_data, 2)
if isempty(in_x_vec)
in_x_vec = collect(1:data_pts)
end
if isempty(color_palette) || color_palette == [""]
color_palette = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"]
end
if isempty(in_title_and_x_axis_label[1]); in_title_and_x_axis_label = ["", "x-axis"]; end;
yaxis_list = ["y", "y2", "y3", "y4"]
yaxis_keys = [:yaxis2, :yaxis3, :yaxis4]
max_y_axes = length(yaxis_list)
#: ---
in_trace_labels = Vector{String}(undef, traces_count); fill!(in_trace_labels, "")
if isempty(in_trace_labels[1])
for i = 1:traces_count
in_trace_labels[i] = string("y", i);
end
end;
#: ---
in_y_axis_labels = Vector{String}(undef, traces_count); fill!(in_y_axis_labels, "")
if isempty(in_y_axis_labels[1])
for i = 1:traces_count
in_y_axis_labels[i] = string("tr", i);
end
end;
if isempty(in_axes_bindings); in_axes_bindings = ones(Int, traces_count); end;
# ── Input validation ──────────────────────────────────────────────────────
length(in_title_and_x_axis_label) != 2 && error("in_title_and_x_axis_label needs exactly 2 elements [title, x-label]")
data_pts != size(in_y_data, 1) && error("Y-Data rows $(size(in_y_data,1)) ≠ x-vector length $data_pts")
traces_count != length(in_trace_labels) && error("y_labels length $(length(in_trace_labels)) ≠ data columns $traces_count")
length(in_y_axis_labels) != traces_count && error("in_y_axis_labels must have length $traces_count (one per trace)")
traces_count != length(in_axes_bindings) && error("axes_bindings length $(length(in_axes_bindings)) ≠ data columns $traces_count")
!isempty(axis_positions) && length(axis_positions) != 7 && error("axis_positions must have exactly 7 elements")
in_axes_bindings = Int.(round.(in_axes_bindings))
used_axes = sort(unique(in_axes_bindings))
right_spacing = 0.12 # in case axis_positions is not specified
# ── Decode geometry from axis_positions (or auto-compute) ────────────────
#
# axis_positions layout:
# [1] domain_start [2] domain_end
# [3] pos_y3_left [4] pos_y4_left (-1 → that axis goes right)
# [5] pos_right1 [6] pos_right2 [7] pos_right3 (-1 → slot unused)
#
# Decision rule:
# axis_positions[3] == -1 → all secondary axes on right (≙ case 1)
# axis_positions[3] != -1 → y3 on left, rest on right (≙ case 2)
# axis_positions[4] != -1 → additionally y4 on left
left_axes_ids = [1] # y1 always primary-left
right_axes_ids = Int[]
left_positions = Dict{Int, Float64}()
right_positions = Dict{Int, Float64}()
if isempty(axis_positions)
# ── Auto-compute: all secondary axes right (classic case 1) ──────────
if length(used_axes) == 2
right_axes_ids = filter(a -> a > 1, used_axes)
n_right = length(right_axes_ids)
x_domain_start = 0.0
x_domain_end = max(0.99, 1.0 - n_right * right_spacing)
right_positions = Dict(ax => 1.0 - (i - 1) * right_spacing
for (i, ax) in enumerate(right_axes_ids))
elseif length(used_axes) == 3
right_axes_ids = filter(a -> a > 1, used_axes)
n_right = length(right_axes_ids)
x_domain_start = 0.0
x_domain_end = max(0.65, 1.0 - n_right * right_spacing)
right_positions = Dict(ax => 1.0 - (i - 1) * right_spacing
for (i, ax) in enumerate(right_axes_ids))
else
right_axes_ids = filter(a -> a > 1, used_axes)
n_right = length(right_axes_ids)
x_domain_start = 0.0
x_domain_end = max(0.65, 1.0 - n_right * right_spacing)
right_positions = Dict(ax => 1.0 - (i - 1) * right_spacing
for (i, ax) in enumerate(right_axes_ids))
end
else
# ── Explicit positions from vector ────────────────────────────────────
x_domain_start = axis_positions[1]
x_domain_end = axis_positions[2]
pos_left_slots = axis_positions[3:4] # indexed by: y3→slot1, y4→slot2
pos_right_slots = axis_positions[5:7]
left_slot_map = Dict(3 => 1, 4 => 2) # which left slot each axis uses
right_slot_idx = 1 # next available right slot
for ax in filter(a -> a > 1, used_axes)
# Check if this axis has a left-slot assignment
slot = get(left_slot_map, ax, 0)
if slot > 0 && pos_left_slots[slot] != -1.0
push!(left_axes_ids, ax)
left_positions[ax] = pos_left_slots[slot]
else
# Validate that a right slot is available
while right_slot_idx <= 3 && pos_right_slots[right_slot_idx] == -1.0
right_slot_idx += 1
end
right_slot_idx > 3 && error("Not enough right slots in axis_positions for axis y$ax")
push!(right_axes_ids, ax)
right_positions[ax] = pos_right_slots[right_slot_idx]
right_slot_idx += 1
end
end
n_right = length(right_axes_ids)
end
# ── Build colored y-axis title parts ─────────────────────────────────────
y_title_parts = Matrix{String}(undef, traces_count, max_y_axes)
fill!(y_title_parts, "")
vector_of_traces = PlotlyJS.GenericTrace[]
for i in 1:traces_count
color_i = color_palette[mod1(i, length(color_palette))]
yAxesNr = in_axes_bindings[i]
y_title_parts[i, yAxesNr] = "<span style='color:$color_i'>$(in_y_axis_labels[i])</span>"
push!(vector_of_traces, PlotlyJS.scatter(;
x = in_x_vec,
y = in_y_data[:, i],
mode = "lines+markers",
name = in_trace_labels[i],
line = PlotlyJS.attr(color = color_i),
marker = PlotlyJS.attr(color = color_i),
yaxis = yaxis_list[yAxesNr]
))
end
Y_AxesLabelVector = [join(filter(!isempty, col), " ") for col in eachcol(y_title_parts)]
# ── Build layout kwargs dynamically ──────────────────────────────────────
layout_kwargs = Dict{Symbol, Any}(
:title => in_title_and_x_axis_label[1],
:legend => PlotlyJS.attr(x = 0.01, y = 0.99),
:width => 1000,
:height => 600,
:xaxis => PlotlyJS.attr(
title = in_title_and_x_axis_label[2],
showgrid = true,
zeroline = true,
domain = [x_domain_start, x_domain_end]
)
)
layout_kwargs[:yaxis] = PlotlyJS.attr(
title = Y_AxesLabelVector[1],
titlefont = PlotlyJS.attr(size = 14),
showgrid = true,
zeroline = true,
side = "left"
)
for ax in filter(a -> a != 1, left_axes_ids)
layout_kwargs[yaxis_keys[ax - 1]] = PlotlyJS.attr(
title = Y_AxesLabelVector[ax],
titlefont = PlotlyJS.attr(size = 14),
showgrid = false,
zeroline = false,
overlaying = "y",
side = "left",
position = left_positions[ax]
)
end
for ax in right_axes_ids
layout_kwargs[yaxis_keys[ax - 1]] = PlotlyJS.attr(
title = Y_AxesLabelVector[ax],
titlefont = PlotlyJS.attr(size = 14),
showgrid = false,
zeroline = false,
overlaying = "y",
side = "right",
position = right_positions[ax]
)
end
layout = PlotlyJS.Layout(; layout_kwargs...)
plt = PlotlyJS.plot(vector_of_traces, layout)
if in_xDBG
return plt, n_right, x_domain_end, left_positions, right_positions, left_axes_ids, right_axes_ids
else
return plt
end
end