Public
Edited
Jan 26
Importers
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
{
viewof model.setAngles(sliders.angles);
return md`Angles updating...`;
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function robotModel(layout, {
w=width, labels=true, buffers=true, labelSizeMM=1.5, labelColor = (id) => "cyan"
}={}) {
const {min,max,round,cos,sin,PI} = Math;
const deg2rad = PI/180;
// Use the requested width and scale the height to the coordinate extent.
const {xlo,xhi,ylo,yhi} = layout.extent;
const h = round(w*(yhi-ylo)/(xhi-xlo));
const X = d3.scaleLinear().domain([xlo,xhi]).range([0,w-1]);
const Y = d3.scaleLinear().domain([ylo,yhi]).range([h-1,0]);
const X0 = X(0), Y0 = Y(0);
const labelSize = X(xlo + labelSizeMM) - X(xlo); // equivalent in pixels
// Helper function to draw a keepout polygon.
function drawPoly(P) {
const p = d3.path();
d3.transpose([P.x,P.y]).forEach(([x,y],i) => {
if(i) p.lineTo(X(x),Y(y));
else p.moveTo(X(x),Y(y));
});
p.closePath();
return p;
}
const armLineWidth = max(1, min(4, X(0.2)-X0));
const goalRadius = min(w / 150, X(0.5)-X0);
// Build the SVG plot.
console.log("initializing svg...");
const svg = d3.create("svg")
.attr("width", w)
.attr("height", h)
.attr("viewBox", [0, 0, w, h]);
const g = svg.append("g")
.attr("fill", "lightgray")
.attr("stroke", "black");
const gg = svg.append("g")
.attr("fill", "none")
.attr("stroke", "lightseagreen")
.attr("stroke-width", 1);
//.attr("stroke-dasharray", [2,2]);
// Draw the robots.
g.selectAll("g.robot")
.data(layout.robots, d=>d.id)
.join("g")
.classed("robot", true)
// Translate to each robots (x,y) and theta origins.
.attr("transform", d => `translate(${X(d.x)-X0},${Y(d.y)-Y0}) rotate(${-d.T0},${X0},${Y0})`)
.call(R => {
R.append("g")
.classed("theta", true)
// Rotate to this robot's current internal theta.
.attr("transform", d => `rotate(${-d.T},${X0},${Y0})`)
.call(S => {
S.append("path")
.classed("theta", true)
.attr("d", drawPoly(physicalKeepouts.theta));
if(buffers) {
S.append("path")
.attr("fill", "none")
.attr("stroke-dasharray", [1,2])
.attr("d", drawPoly(bufferedKeepouts.theta));
}
const phi=S.append("g")
// Translate to this robot's phi arm axis.
.attr("transform", d => `translate(${X(d.r1)-X0},0)`)
.append("g")
.classed("phi", true)
// Rotate to this robot's current external phi.
.attr("transform", d => `rotate(${-(d.P+d.P0)},${X0},${Y0})`);
phi.append("path")
.classed("phi", true)
.attr("d", drawPoly(physicalKeepouts.phi));
if(buffers) {
phi.append("path")
.attr("fill", "none")
.attr("stroke-dasharray", [1,2])
.attr("d", drawPoly(bufferedKeepouts.phi));
}
phi.append("line")
.attr("stroke-width", armLineWidth)
.attr("stroke-linecap", "round")
.attr("x1", X0)
.attr("y1", Y0)
.attr("x2", d => X(d.r2))
.attr("y2", d => Y0);
S.append("line")
.attr("stroke-width", armLineWidth)
.attr("stroke-linecap", "round")
.attr("x1", X0)
.attr("y1", Y0)
.attr("x2", d => X(d.r1))
.attr("y2", d => Y0);
});
// Draw theta stop region relative to robot's (x,y) and theta origin.
R.append("path")
.attr("stroke", "blue")
.attr("fill", "none")
.attr("d", d => {
const p = d3.path();
p.moveTo(X(-2*d.cosTstop), Y(2*d.sinTstop));
p.lineTo(X0, Y0);
p.lineTo(X(-2*d.cosTstop), Y(-2*d.sinTstop));
return p;
});
if(labels) {
// Display an ID label for this robot.
R.append("text")
.attr("transform", d => `rotate(${d.T0},${X0},${Y0})`)
.attr("x", X0)
.attr("y", Y0)
.attr("stroke", "black")
.attr("fill", d => labelColor(d.id))
.attr("font-size", labelSize)
.attr("font-weight", "bold")
.attr("opacity", 0.3)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.text(d => d.id);
}
});

// Update the angles with minimal changes to the DOM.
function _setAngles(angles) {
g.selectAll("g.theta")
.data(angles, d=>d.id)
.join(
enter => {
if(enter.size()) {
const newIDs = enter.nodes().map(n=>n.__data__.id);
console.log("Ignoring angles for new robots:", newIDs);
}
},
update => {
update.attr("transform", d => `rotate(${-d.T},${X0},${Y0})`);
},
exit => { }
);
g.selectAll("g.phi")
.data(angles, d=>d.id)
.join(
enter => { },
update => {
update.attr("transform", d => `rotate(${-(d.P+d.P0)},${X0},${Y0})`);
},
exit => { }
);
}
// Update the keepout polygon colors based on their proximity values.
function _setProximityColors(proximities) {
proximities = [...proximities.values()];
g.selectAll("path.theta")
.data(proximities, d=>d.id)
.join(
enter => { },
update => { update.attr("fill", d => proximityColor(d.proximity.theta)); },
exit => { }
);
g.selectAll("path.phi")
.data(proximities, d=>d.id)
.join(
enter => { },
update => { update.attr("fill", d => proximityColor(d.proximity.phi)); },
exit => { }
);
}
const proximities = layout.getProximity();
_setProximityColors(proximities);
// Update the goal angles.
function _setGoals(goals) {
goals = goals.filter(d => d.goalT !== null && d.goalP !== null);
const goalT = goals.map(R => (R.goalT+R.T0)*deg2rad);
const goalTP = goals.map(R => (R.goalT+R.T0+R.goalP+R.P0)*deg2rad);
const cosT = goalT.map(T => cos(T)), sinT = goalT.map(T => sin(T));
const cosTP = goalTP.map(TP => cos(TP)), sinTP = goalTP.map(TP => sin(TP));
gg.selectAll("circle")
.data(goals, d=>d.id)
.join("circle")
.attr("cx", (d,i) => X(d.x + d.r1 * cosT[i] + d.r2 * cosTP[i]))
.attr("cy", (d,i) => Y(d.y + d.r1 * sinT[i] + d.r2 * sinTP[i]))
.attr("r", goalRadius);
gg.selectAll("path")
.data(goals, d=>d.id)
.join("path")
.attr("d", (d,i) => {
const p = d3.path();
p.moveTo(X(d.x), Y(d.y));
p.lineTo(X(d.x + d.r1 * cosT[i]), Y(d.y + d.r1 * sinT[i]));
p.lineTo(X(d.x + d.r1 * cosT[i] + d.r2 * cosTP[i]),
Y(d.y + d.r1 * sinT[i] + d.r2 * sinTP[i]));
return p;
});
}
_setGoals(layout.robots);

const model = Object.assign(svg.node(), {
value: layout,
setAngles(angles) {
angles.forEach(({id,T,P}) => {
if(this.value.index.has(id)) {
const robot = this.value.index.get(id);
robot.T = T;
robot.P = P;
//console.log(`robotModel: set T=${T} P=${P} for ${id}`);
}
});
_setAngles(this.value.robots);
const proximities = this.value.getProximity();
_setProximityColors(proximities);
model.dispatchEvent(new Event("input", {bubble:true}));
},
setGoals(goals) {
goals.forEach(({id,T,P}) => {
if(this.value.index.has(id)) {
const robot = this.value.index.get(id);
robot.goalT = T;
robot.goalP = P;
}
});
_setGoals(this.value.robots);
model.dispatchEvent(new Event("input", {bubble:true}));
}
});
return model;
}
Insert cell
Insert cell
Insert cell
Insert cell
class Robot {
static _count = 0;
static idFormat = d3.format("04d");
static angleFormat = d3.format(".1f");
static nextId = () => `M${Robot.idFormat(Robot._count)}`;
constructor({id, x=0, y=0, r1=3, r2=3, T=0, P=150, T0=0, P0=0, rangeT=390, rangeP=190,
goalT=null, goalP=null}={}) {
id = id || Robot.nextId();
Object.assign(this, {id,x,y,r1,r2,T,P,T0,P0,rangeT,rangeP,goalT,goalP});
this.minT = -rangeT / 2, this.maxT = +rangeT / 2;
this.minP = 185 - rangeP, this.maxP = 185;
// Precompute cos,sin of theta-stop angle.
const {cos,sin,PI} = Math;
const Tstop = (rangeT - 360)/2 * Math.PI/180;
this.cosTstop = cos(Tstop), this.sinTstop = sin(Tstop);
// Initialize keepout cache.
this.lastT = this.lastP = null;
Robot._count++;
}
inBounds(T, P) {
return (T >= this.minT) && (T <= this.maxT) && (P >= this.minP) && (P <= this.maxP);
}
keepouts(T, P, {buffered=true}={}) {
// Return the keepout polygons for the specified angles.
// Only the buffered keepouts are cached.
if(T != this.lastT || P != this.lastP || !buffered) {
const {sin,cos,PI} = Math, deg2rad = PI / 180;
const t = (T + this.T0) * deg2rad, tp = (T + this.T0 + P + this.P0) * deg2rad;
const cost = cos(t), sint = sin(t), costp = cos(tp), sintp = sin(tp);
const {x:xT, y:yT, r1} = this;
const polys = buffered ? bufferedKeepouts : physicalKeepouts;
const {x:xpolyT, y:ypolyT} = polys.theta;
const theta = {
x: xpolyT.map((x,i) => xT + x * cost - ypolyT[i] * sint),
y: ypolyT.map((y,i) => yT + xpolyT[i] * sint + y * cost),
length: xpolyT.length
};
const xP = xT + r1 * cost, yP = yT + r1 * sint;
const {x:xpolyP, y:ypolyP} = polys.phi;
const phi = {
x: xpolyP.map((x,i) => xP + x * costp - ypolyP[i] * sintp),
y: ypolyP.map((y,i) => yP + xpolyP[i] * sintp + y * costp),
length: xpolyP.length
};
this._keepouts = { theta, phi };
this.lastT = T, this.lastP = P;
}
return this._keepouts;
}
getInput(step=0.1) {
return view`<div class="angleSliders">
${["_id", Inputs.input(this.id)]}
${["T", Inputs.range([this.minT,this.maxT],{value:this.T, step:step, label:`${this.id}-T`})]}
${["P", Inputs.range([this.minP,this.maxP],{value:this.P, step:step, label:`${this.id}-P`})]}
`;
}
randomizeGoal(rng=null) {
const {sqrt,cos,sin,PI,acos,atan2,max} = Math;
const random = (rng === null) ? d3.randomUniform(0,1) : d3.randomUniform.source(rng)(0,1);
const {id,r1,r2} = this, r12 = r1 + r2;
// Generate a random point uniformly distributed within a circle of radius r1+r2.
const goalR = r12 * sqrt(random()), goalPhi = 2 * PI * random();
const goalX = goalR * cos(goalPhi), goalY = goalR * sin(goalPhi);
// Convert to angles.
const Rsq = goalR * goalR, r1sq = r1 * r1, r2sq = r2 * r2;
const s = (Rsq - r2sq + r1sq) / (2 * goalR);
const a = sqrt(max(0, r1sq - s * s));
const beta = atan2(a, s);
// Calculate T in the range (-180,+180).
let goalT = (goalPhi - beta) * 180 / PI;
if(goalT > 180) goalT -= 360;
// Calculate P in the range (0,180).
const goalP = acos((Rsq - r1sq - r2sq) / (2 * r1 * r2)) * 180 / PI;
return {id, T:goalT, P:goalP};
}
toString() {
return `${this.id}(T=${Robot.angleFormat(this.T)},P=${Robot.angleFormat(this.P)})`;
}
}
Insert cell
Insert cell
Insert cell
Insert cell
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