Public
Edited
Apr 9
Insert cell
Insert cell
{
// --- Create a parameter control widget without a box ---
function createParameterControl(name, defaultValue) {
const container = html`
<div style="margin:10px; font-family:inherit; display:flex; align-items:center;">
<span class="currentValue" style="margin-right:20px; width:50px;"><i>${name}</i> = ${defaultValue}</span>
<input type="range" min="2" max="12" step="1" value="${defaultValue}" style="width:300px; margin-right:10px;">
<button class="inc" style="margin-right:5px;">+1</button>
<button class="toggleInfinity">∞</button>
</div>
`;
const slider = container.querySelector("input[type='range']");
const incButton = container.querySelector(".inc");
const toggle = container.querySelector(".toggleInfinity");
const valueDisplay = container.querySelector(".currentValue");
let isInfinite = false;
function updateDisplay() {
valueDisplay.innerHTML = `<i>${name}</i> = ${isInfinite ? "∞" : slider.value}`;
updateTiling();
}
slider.oninput = updateDisplay;
incButton.onclick = () => {
if (!isInfinite) {
let newValue = +slider.value + 1;
if (newValue > +slider.max) {
slider.max = newValue;
}
slider.value = newValue;
updateDisplay();
}
};
toggle.onclick = () => {
isInfinite = !isInfinite;
slider.disabled = isInfinite;
incButton.disabled = isInfinite;
toggle.style.color = isInfinite ? "blue" : "";
updateDisplay();
};
return {
container,
slider,
getValue: () => isInfinite ? Infinity : +slider.value,
getMin: () => +slider.min
};
}
// --- Create parameter controls with default values: p = 2, q = 3, r = 7 ---
const pControl = createParameterControl("p", 2);
const qControl = createParameterControl("q", 3);
const rControl = createParameterControl("r", 7);
// --- Create a toggle for triangle placement mode ---
const placementToggle = html`
<button class="placementToggle" style="margin:10px;">Free p-vertex</button>
`;
let alternatePlacement = false;
placementToggle.onclick = () => {
alternatePlacement = !alternatePlacement;
placementToggle.textContent = alternatePlacement ? "Fix p-vertex" : "Free p-vertex";
updateTiling();
};
// --- Create clickable labels for the Möbius transform parameter slider ---
// Changed to buttons with the same basic style as the "+1" button.
const labelDisk = html`<button style="margin-right:5px; cursor:pointer;">Poincaré disk</button>`;
const labelUHP = html`<button style="margin-right:5px; cursor:pointer;">upper half plane</button>`;
// --- Animation logic for the slider ---
let currentAnimation = null;
function animateSliderTo(target, duration) {
if (currentAnimation) currentAnimation.stop();
const startValue = +tSlider.value;
const interp = d3.interpolateNumber(startValue, target);
const startTime = Date.now();
currentAnimation = d3.timer(function() {
const elapsed = Date.now() - startTime;
const tVal = Math.min(1, elapsed / duration);
tSlider.value = interp(tVal);
update();
if (tVal >= 1) {
currentAnimation.stop();
currentAnimation = null;
return true;
}
});
}
// Adjust duration (in ms) to control the animation speed.
labelDisk.onclick = () => {
animateSliderTo(0, 1000);
};
labelUHP.onclick = () => {
animateSliderTo(0.995, 1000);
};
// --- t–slider for the Möbius transform parameter ---
const tSlider = html`
<input type="range" min="0" max="0.995" step="0.005" value="0" style="flex:1; cursor:pointer;">
`;
// Cancel any active animation if the user starts dragging.
tSlider.addEventListener("mousedown", () => {
if (currentAnimation) { currentAnimation.stop(); currentAnimation = null; }
});
tSlider.addEventListener("touchstart", () => {
if (currentAnimation) { currentAnimation.stop(); currentAnimation = null; }
});
// Wrap slider and labels in a container.
const tSliderContainer = html`
<div style="display:flex; align-items:center; width:500px; margin:10px 0;">
${labelDisk}
${tSlider}
${labelUHP}
</div>
`;
// --- Assume ComplexPlane utilities are available ---
const {
cAdd, cSub, cMul, cDiv, cConj, cAbs, cScale, cSqrt,
applyMobius, mobiusCompose, mobiusInverse,
triangleArea, ensureOrientation,
mobiusFromTriples, computeFixedPoints,
T_from_a, T_from_a_inv,
geodesicSegmentPointsRaw, trianglePolygonPointsRaw,
fullGeodesicEndpoints, fullGeodesicPointsRaw,
reflectPoint, reflectTriangle
} = ComplexPlane;
// --- One-parameter Möbius transform ---
function T_param(t) {
return {
a: { x: 1, y: 0 },
b: { x: 0, y: t },
c: { x: 0, y: t },
d: { x: 1, y: 0 }
};
}
// --- Set Up Disk and SVG ---
const height = 600;
const diskRadius = height * 0.4;
const svg = d3.select(DOM.svg(width, height))
.attr("width", width)
.attr("height", height);
const zoomContainer = svg.append("g").attr("class", "zoom-container");
const boundaryGroup = zoomContainer.append("g").attr("class", "boundary");
const tileGroup = zoomContainer.append("g").attr("class", "tiles");
const fullArcGroup = zoomContainer.append("g").attr("class", "full-arcs");
const redArcGroup = zoomContainer.append("g").attr("class", "red-arcs");
// New groups for invariant axes:
const axisGroup = zoomContainer.append("g").attr("class", "axis-group"); // for fixed axes
const axisHoverGroup = zoomContainer.append("g").attr("class", "axis-hover-group"); // for dynamic (hover) axis
const defs = svg.append("defs");
let gradientCounter = 0;
let tiling = [];
let edges = [];
const color = "LightSkyBlue";
const scheme = {
even: d3.color(color).brighter(0.5).toString(),
odd: d3.color(color).brighter(1.5).toString(),
outline: d3.color(color).darker(0.5).toString()
};
const boundaryPoints = [];
const numBoundaryPts = 200;
for (let i = 0; i <= numBoundaryPts; i++) {
let theta = 2 * Math.PI * i / numBoundaryPts;
boundaryPoints.push({ x: Math.cos(theta), y: Math.sin(theta) });
}
function diskToSVG(z) {
return { x: width / 2 + z.x * diskRadius, y: height / 2 - z.y * diskRadius };
}
// --- Store the fundamental (base) triangle ---
let fundamentalTriangle = [];
// --- Update Tiling and Redraw (augmented to compute each tile’s invariant axis) ---
function updateTiling() {
// Clear any fixed axes when parameters change
axisGroup.selectAll("*").remove();
const p_val = pControl.getValue();
const q_val = qControl.getValue();
const r_val = rControl.getValue();
tiling = [];
edges = [];
let seenTriangles = new Set();
let seenEdges = new Set();
function canonicalEdge(v1, v2) {
const s1 = v1.x.toFixed(5) + "," + v1.y.toFixed(5);
const s2 = v2.x.toFixed(5) + "," + v2.y.toFixed(5);
return s1 < s2 ? s1 + ";" + s2 : s2 + ";" + s1;
}
function addEdge(v1, v2, depth) {
const key = canonicalEdge(v1, v2);
if (!seenEdges.has(key)) {
seenEdges.add(key);
edges.push({ v1, v2, depth });
}
}
function isInside(tri) {
return tri.some(v => cAbs(v) < 0.9);
}
function addTriangle(tri, depth) {
// Adjust orientation to match the fundamental triangle
tri = ensureOrientation(tri, triangleArea(fundamentalTriangle));
const key = tri.map(v => v.x.toFixed(3) + "," + v.y.toFixed(3)).join(";");
if (!seenTriangles.has(key)) {
seenTriangles.add(key);
// Compute the transformation T mapping the fundamental triangle to this triangle.
const T_tile = mobiusFromTriples(fundamentalTriangle, tri);
// Compute the invariant axis as the geodesic joining the two fixed points.
const axis = computeFixedPoints(T_tile);
tiling.push({ vertices: tri, depth, T: T_tile, axis });
for (let i = 0; i < 3; i++) {
addEdge(tri[i], tri[(i + 1) % 3], depth);
}
return true;
}
return false;
}
const p_eff = isFinite(p_val) ? p_val : 1000;
const q_eff = isFinite(q_val) ? q_val : 1000;
const r_eff = isFinite(r_val) ? r_val : 1000;
const angleA = Math.PI / p_eff;
const angleB = Math.PI / q_eff;
const angleC = Math.PI / r_eff;
const side_a = Math.acosh((Math.cos(angleA) + Math.cos(angleB) * Math.cos(angleC)) / (Math.sin(angleB) * Math.sin(angleC)));
const side_b = Math.acosh((Math.cos(angleB) + Math.cos(angleA) * Math.cos(angleC)) / (Math.sin(angleA) * Math.sin(angleC)));
const side_c = Math.acosh((Math.cos(angleC) + Math.cos(angleA) * Math.cos(angleB)) / (Math.sin(angleA) * Math.sin(angleB)));
let A, B, C;
if (!alternatePlacement) {
A = { x: 0, y: 0 };
B = {
x: -Math.tanh(side_c / 2) * Math.sin(Math.PI / p_eff),
y: Math.tanh(side_c / 2) * Math.cos(Math.PI / p_eff)
};
C = { x: 0, y: Math.tanh(side_b / 2) };
} else {
const h = Math.asinh((Math.sinh(side_a) * Math.sinh(side_c) * Math.sin(angleB)) / Math.sinh(side_b));
const a0 = Math.acosh(Math.cosh(side_c) / Math.cosh(h));
const c0 = Math.acosh(Math.cosh(side_a) / Math.cosh(h));
A = { x: 0, y: -Math.tanh(a0 / 2) };
B = { x: -Math.tanh(h / 2), y: 0 };
C = { x: 0, y: Math.tanh(c0 / 2) };
}
fundamentalTriangle = [A, B, C];
// Initialize the tiling from the fundamental triangle.
let queue = [];
if (isInside(fundamentalTriangle) && addTriangle(fundamentalTriangle, 0)) {
queue.push({ vertices: fundamentalTriangle, depth: 0 });
}
while (queue.length > 0) {
const { vertices, depth } = queue.shift();
// Use the provided reflectTriangle (which returns new vertices) to generate reflected triangles.
for (let i = 0; i < 3; i++) {
const reflected = reflectTriangle(vertices, i);
if (isInside(reflected) && addTriangle(reflected, depth + 1)) {
queue.push({ vertices: reflected, depth: depth + 1 });
}
}
}
// Now add polygon and edge arcs.
tiling.forEach(tile => {
tile.polygon = trianglePolygonPointsRaw(tile.vertices);
});
edges.forEach(edge => {
edge.arc = fullGeodesicPointsRaw(edge.v1, edge.v2, 80);
});
update();
}
// === Drawing invariant set for tile: either hyperbolic geodesic or rotation circle ===
function drawAxis(tile, group, style) {
// Use the current global transformation.
const T_global = T_param(+tSlider.value);
// Check if the transformation is elliptic: if the two fixed points nearly coincide.
const fpDiff = cAbs(cSub(tile.axis[0], tile.axis[1]));
if (fpDiff < 0.01) {
// Elliptic: use the average as the unique fixed point.
const fixed = tile.axis[0];
const svgFixed = diskToSVG(applyMobius(T_global, fixed));
// Compute the centroid of the tile (after global transform).
const transformedVertices = tile.vertices.map(pt => diskToSVG(applyMobius(T_global, pt)));
const cx = d3.mean(transformedVertices, d => d.x);
const cy = d3.mean(transformedVertices, d => d.y);
const centroid = { x: cx, y: cy };
const rad = Math.sqrt((centroid.x - svgFixed.x) ** 2 + (centroid.y - svgFixed.y) ** 2);
group.append("circle")
.attr("cx", svgFixed.x)
.attr("cy", svgFixed.y)
.attr("r", rad)
.attr("stroke", "blue") // different color for elliptic rotation circle
.attr("stroke-width", 2)
.attr("fill", "none")
.attr("class", "axis-path");
} else {
// Hyperbolic: draw invariant geodesic arc between the two fixed points.
const arcPts = fullGeodesicPointsRaw(tile.axis[0], tile.axis[1], 100);
const transformedPts = arcPts.map(pt => diskToSVG(applyMobius(T_global, pt)));
group.append("path")
.attr("d", d3.line().x(d => d.x).y(d => d.y)(transformedPts))
.attr("stroke", style.stroke)
.attr("stroke-width", style.strokeWidth)
.attr("fill", "none")
.attr("class", "axis-path");
}
}
function clearHoverAxis() {
axisHoverGroup.selectAll("*").remove();
}
function handleTileHover(tile) { clearHoverAxis(); drawAxis(tile, axisHoverGroup, { stroke: "green", strokeWidth: 2 }); }
function fixHoverAxis(tile) { drawAxis(tile, axisGroup, { stroke: "green", strokeWidth: 2 }); }
// === Main update drawing routine ===
function update() {
const tVal = +tSlider.value;
const T_global = T_param(tVal);
function transformPoints(points) {
return points.map(pt => diskToSVG(applyMobius(T_global, pt)));
}
boundaryGroup.selectAll("*").remove();
tileGroup.selectAll("*").remove();
fullArcGroup.selectAll("*").remove();
redArcGroup.selectAll("*").remove();
defs.selectAll("*").remove();
gradientCounter = 0;
axisHoverGroup.selectAll("*").remove();
boundaryGroup.append("path")
.attr("d", d3.line().x(d => d.x).y(d => d.y)(transformPoints(boundaryPoints)))
.attr("fill", "none")
.attr("stroke", "gray")
.attr("stroke-width", 2)
.style("vector-effect", "non-scaling-stroke");
tiling.forEach(tile => {
const pts = transformPoints(tile.polygon);
const dStr = "M" + pts.map(pt => [pt.x, pt.y].join(",")).join(" L") + " Z";
const tilePath = tileGroup.append("path")
.attr("d", dStr)
.attr("fill", tile.depth === 0 ? "red" : (tile.depth % 2 === 0 ? scheme.even : scheme.odd))
.attr("stroke", tile.depth === 0 ? "red" : "rgba(0,0,0,0.15)")
.attr("stroke-width", tile.depth === 0 ? 5 : 0.5)
.style("stroke-linejoin", "round")
.style("stroke-linecap", "round")
.style("vector-effect", "non-scaling-stroke");
tilePath.datum(tile)
.on("mouseover", (event, d) => { handleTileHover(d); })
.on("mouseout", (event, d) => { clearHoverAxis(); })
.on("click", (event, d) => { fixHoverAxis(d); });
});
edges.forEach(edge => {
if (edge.depth !== 0) {
const pts = transformPoints(edge.arc);
const dStr = d3.line().x(d => d.x).y(d => d.y)(pts);
const endpoints = [pts[0], pts[pts.length - 1]];
const gradId = "grad" + (gradientCounter++);
const grad = defs.append("linearGradient")
.attr("id", gradId)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", endpoints[0].x).attr("y1", endpoints[0].y)
.attr("x2", endpoints[1].x).attr("y2", endpoints[1].y);
grad.append("stop").attr("offset", "0%").attr("stop-color", scheme.outline).attr("stop-opacity", 0);
grad.append("stop").attr("offset", "25%").attr("stop-color", scheme.outline).attr("stop-opacity", 0.8);
grad.append("stop").attr("offset", "75%").attr("stop-color", scheme.outline).attr("stop-opacity", 0.8);
grad.append("stop").attr("offset", "100%").attr("stop-color", scheme.outline).attr("stop-opacity", 0);
fullArcGroup.append("path")
.attr("d", dStr)
.attr("stroke", `url(#${gradId})`)
.attr("stroke-width", 1)
.attr("fill", "none")
.style("stroke-linejoin", "round")
.style("stroke-linecap", "round")
.style("shape-rendering", "geometricPrecision")
.style("vector-effect", "non-scaling-stroke");
}
});
edges.forEach(edge => {
if (edge.depth === 0) {
const pts = transformPoints(geodesicSegmentPointsRaw(edge.v1, edge.v2));
const dStr = d3.line().x(d => d.x).y(d => d.y)(pts);
redArcGroup.append("path")
.attr("d", dStr)
.attr("stroke", "red")
.attr("stroke-width", 2)
.attr("fill", "none")
.style("vector-effect", "non-scaling-stroke");
}
});
}
tSlider.oninput = () => { requestAnimationFrame(update); };
updateTiling();
svg.call(
d3.zoom()
.scaleExtent([0.5, 10])
.on("zoom", event => {
zoomContainer.attr("transform", event.transform);
})
);
const container = html`
<div style="display:flex; flex-direction:column; align-items:center;">
${pControl.container}
${qControl.container}
${rControl.container}
${tSliderContainer}
${placementToggle}
${svg.node()}
</div>
`;
return container;
}
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more