import solara
import solara.lab # ensure lab extensions (including Card) are available
from solara import component, reactive, SliderFloat, Columns, Markdown, FigurePlotly
import numpy as np
import plotly.graph_objects as go
# — Static geometry (precomputed once) —
img_N = 512
domain = 1.5
xg = np.linspace(-domain, domain, img_N)
yg = np.linspace(-domain, domain, img_N)
theta = np.linspace(0, 2 * np.pi, 7)[:6]
subs = [-0.8, 0, 0.8]
theta[0:2] = subs[0] + np.pi * 0.015 * np.array([-1, 1])
theta[2:4] = subs[1] + np.pi * 0.015 * np.array([-1, 1])
theta[4:6] = subs[2] + np.pi * 0.015 * np.array([-1, 1])
fix_x = np.sin(theta)
fix_y = np.cos(theta)
fix_labels = ['', 'FIX1,2', 'FIX3,4', '', 'FIX5,6', '']
background_traces = []
# fixture markers
background_traces.append(go.Scatter(
x=fix_x, y=fix_y,
mode="markers",
marker=dict(color="white", size=8, line=dict(color="black", width=2)),
showlegend=False,
))
# labels
for i, lbl in enumerate(fix_labels):
if lbl:
background_traces.append(go.Scatter(
x=[1.1*fix_x[i]], y=[1.1*fix_y[i]],
mode="text",
text=[lbl],
textfont=dict(color="white", size=12),
showlegend=False,
))
# outer ring
background_traces.append(go.Scatter(
x=[0], y=[0],
mode="markers",
marker=dict(size=76000,
color="rgba(0,0,0,0)",
line=dict(color="darkgrey", width=100)),
showlegend=False,
))
def make_alpha_mask(x0, y0, radius, xg, yg, alpha_max=0.5,
direction_angle=0.0, direction_sigma=np.pi/8):
X, Y = np.meshgrid(xg, yg)
dx, dy = X - x0, Y - y0
rsq = dx*dx + dy*dy
base_theta = np.arctan2(-y0, -x0)
angle_to_pixel = np.arctan2(dy, dx)
offset = (angle_to_pixel - base_theta - direction_angle + np.pi) % (2*np.pi) - np.pi
dir_mod = np.exp(-0.5*(offset/direction_sigma)**2)
return np.clip(1 - rsq/radius, 0, 1) * dir_mod * alpha_max
def make_blob(alpha_mask, R, G, B, W, dim):
img = np.zeros(alpha_mask.shape + (4,))
img[...,0] = dim*(R*alpha_mask + W*alpha_mask)
img[...,1] = dim*(G*alpha_mask + W*alpha_mask)
img[...,2] = dim*(B*alpha_mask + W*alpha_mask)
img[...,3] = dim*alpha_mask
return img
def sum_blobs(blobs):
out = np.zeros(blobs[0].shape)
for b in blobs:
out[..., :3] += b[..., :3]
out[..., 3] += b[..., 3]
out[..., :3] = np.clip(out[..., :3], 0, 1)
out[..., 3] = np.clip(out[..., 3], 0, 1)
return out
# — Module‐level reactive state —
fixtures = []
for _ in range(6):
fixtures.append({
"R": reactive(1.0),
"G": reactive(0.0),
"B": reactive(0.0),
"W": reactive(0.0),
"radius": reactive(1.0),
"angle": reactive(0.0),
"sigma": reactive(np.pi/8),
})
master_dimmer = reactive(1.0)
# — The card component, exactly 4-tabs style —
@component
def FixtureBlendCard():
def build_figure():
fig = go.Figure()
# static background
for t in background_traces:
fig.add_trace(t)
# dynamic blobs
blobs = []
dim = master_dimmer.value
for i, fx in enumerate(fixtures):
mask = make_alpha_mask(
fix_x[i], fix_y[i],
fx["radius"].value, xg, yg,
alpha_max=0.5,
direction_angle=fx["angle"].value,
direction_sigma=fx["sigma"].value,
)
blobs.append(make_blob(
mask,
fx["R"].value, fx["G"].value, fx["B"].value, fx["W"].value,
dim,
))
img = sum_blobs(blobs)
fig.add_trace(go.Image(z=img))
fig.update_layout(
showlegend=False,
margin=dict(l=0, r=0, t=0, b=0),
plot_bgcolor="black", paper_bgcolor="black",
)
fig.update_xaxes(visible=False, range=[-domain, domain], constrain="domain")
fig.update_yaxes(visible=False, scaleanchor="x", scaleratio=1, range=[-domain, domain])
return fig
# list all reactives so FigurePlotly only reruns on change
deps = [master_dimmer]
for fx in fixtures:
deps += [fx[k] for k in ("R", "G", "B", "W", "radius", "angle", "sigma")]
controls = [SliderFloat("Master Dimmer", value=master_dimmer, min=0.0, max=1.0, step=0.01)]
for i, fx in enumerate(fixtures, start=1):
controls += [
Markdown(f"**Fixture {i}**"),
SliderFloat("R", value=fx["R"], min=0.0, max=1.0, step=0.01),
SliderFloat("G", value=fx["G"], min=0.0, max=1.0, step=0.01),
SliderFloat("B", value=fx["B"], min=0.0, max=1.0, step=0.01),
SliderFloat("W", value=fx["W"], min=0.0, max=1.0, step=0.01),
SliderFloat("blob_radius", value=fx["radius"], min=0.2, max=2.5, step=0.01),
SliderFloat("direction_angle",value=fx["angle"], min=-np.pi, max=np.pi, step=0.01),
SliderFloat("direction_sigma",value=fx["sigma"], min=0.01, max=1.0, step=0.01),
]
# use positional args: first the title, then the single child Columns(...)
return solara.Card(
"Fixture Blend",
Columns([2, 1], [
FigurePlotly(build_figure, dependencies=deps),
controls
])
)
# Then simply add to your tabs:
# tabs = [
# ...,
# ("Fixture Blend", FixtureBlendCard),
# ]
@component
def Page():
return FixtureBlendCard()