<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Link Leg Calculator</title>
<style>
:root {
color-scheme: light;
--bg:
#f4f6f8;
--panel:
#ffffff;
--ink:
#1c2430;
--muted: #657184;
--line:
#d6dde6;
--accent:
#0f766e;
--accent-weak:
#d8f3ee;
--danger:
#b42318;
--shadow: 0 12px 32px rgba(28, 36, 48, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", "Yu Gothic UI", Meiryo, sans-serif;
color: var(--ink);
background: var(--bg);
}
.app {
display: grid;
grid-template-columns: 340px minmax(0, 988px);
justify-content: center;
min-height: 100vh;
gap: 18px;
padding: 18px;
}
aside {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 22px;
overflow: auto;
box-shadow: var(--shadow);
}
main {
display: grid;
grid-template-rows: auto 1fr;
min-width: 0;
gap: 16px;
}
h1 {
margin: 0 0 6px;
font-size: 22px;
line-height: 1.25;
letter-spacing: 0;
}
.note {
margin: 0 0 18px;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.controls {
display: grid;
gap: 14px;
}
.control {
display: grid;
gap: 6px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.control label {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
font-weight: 650;
}
.control span {
color: var(--muted);
font-weight: 500;
}
.row {
display: grid;
grid-template-columns: 1fr 94px;
gap: 10px;
align-items: center;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
input[type="number"] {
width: 100%;
min-height: 34px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 8px;
color: var(--ink);
font: inherit;
text-align: right;
background:
#fff;
}
.segmented {
display: grid;
grid-template-columns: 1fr 1fr;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.segmented button {
min-height: 36px;
border: 0;
background:
#fff;
color: var(--ink);
font: inherit;
cursor: pointer;
}
.segmented button button {
border-left: 1px solid var(--line);
}
.segmented
button.active {
background: var(--accent);
color: white;
font-weight: 700;
}
.result {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.metric {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
box-shadow: var(--shadow);
}
.metric div {
color: var(--muted);
font-size: 12px;
margin-bottom: 5px;
}
.metric strong {
display: block;
font-size: 20px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.stage {
height: 728px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
overflow: hidden;
position: relative;
}
.view-tools {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 6px;
z-index: 2;
}
.view-tools button {
width: 34px;
height: 34px;
border: 1px solid var(--line);
border-radius: 6px;
background: rgba(255, 255, 255, 0.92);
color: var(--ink);
font: 700 16px/1 "Segoe UI", sans-serif;
cursor: pointer;
box-shadow: 0 3px 10px rgba(28, 36, 48, 0.12);
}
.view-tools button:hover {
background:
#fff;
border-color: var(--accent);
}
canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
user-select: none;
touch-action: none;
}
canvas.dragging {
cursor: grabbing;
}
.status {
min-height: 22px;
margin-top: 14px;
color: var(--danger);
font-size: 13px;
line-height: 1.45;
}
.formula {
margin-top: 16px;
padding: 12px;
border-radius: 8px;
background: var(--accent-weak);
color:
#143f3a;
font-size: 13px;
line-height: 1.55;
}
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
padding: 12px;
}
aside {
border: 1px solid var(--line);
}
.result {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stage {
height: 598px;
}
}
</style>
</head>
<body>
<div class="app">
<aside>
<h1>Link Leg Calculator</h1>
<p class="note">L3 と L4 は同じ直線上の棒として計算します。固定点 A=(x,y)、原点 O=(0,0)、入力角 theta から足先 P=(X,Y) を求めます。</p>
<div class="controls" id="controls"></div>
<div class="control" style="margin-top:14px;">
<label>交点の向き <span>L2/L3 の2解</span></label>
<div class="segmented">
<button id="sideA" type="button" class="active">解 A</button>
<button id="sideB" type="button">解 B</button>
</div>
</div>
<div class="formula">
B=(L1 cos theta, L1 sin theta)<br>
C は A 中心 L2、B 中心 L3 の円交点<br>
P = B (B - C) / L3 * L4
</div>
<div class="status" id="status"></div>
</aside>
<main>
<section class="result">
<div class="metric"><div>X</div><strong id="outX">0.000</strong></div>
<div class="metric"><div>Y</div><strong id="outY">0.000</strong></div>
</section>
<section class="stage">
<div class="view-tools">
<button id="zoomIn" type="button" title="拡大"> </button>
<button id="zoomOut" type="button" title="縮小">-</button>
<button id="resetView" type="button" title="表示位置をリセット">0</button>
</div>
<canvas id="canvas"></canvas>
</section>
</main>
</div>
<script>
const params = {
L1: { label: "L1(点O-B間)", min: 0.1, max: 300, step: 0.1, value: 120 },
L2: { label: "L2(点A-C間)", min: 0.1, max: 300, step: 0.1, value: 135 },
L3: { label: "L3(点C-B間)", min: 0.1, max: 300, step: 0.1, value: 72 },
L4: { label: "L4(点B-P間)", min: 0.1, max: 300, step: 0.1, value: 170 },
x: { label: "固定点 x", min: -250, max: 250, step: 0.1, value: -42 },
y: { label: "固定点 y", min: -250, max: 250, step: 0.1, value: 128 },
theta: { label: "theta", min: -180, max: 180, step: 0.1, value: -132, unit: "deg" },
rotation: { label: "全体回転", min: -180, max: 180, step: 0.1, value: 0, unit: "deg" }
};
const state = {
side: 1,
scale: 2.6,
panX: 0,
panY: 0,
dragging: false,
dragMode: null,
dragStartX: 0,
dragStartY: 0,
panStartX: 0,
panStartY: 0
};
const controls = document.getElementById("controls");
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const statusEl = document.getElementById("status");
const outputs = {
X: document.getElementById("outX"),
Y: document.getElementById("outY")
};
function createControl(key, spec) {
const wrap = document.createElement("div");
wrap.className = "control";
const label = document.createElement("label");
label.htmlFor = key;
label.innerHTML = `${spec.label}<span id="${key}Value"></span>`;
const row = document.createElement("div");
row.className = "row";
const range = document.createElement("input");
range.type = "range";
range.id = key;
range.min = spec.min;
range.max = spec.max;
range.step = spec.step;
range.value = spec.value;
const number = document.createElement("input");
number.type = "number";
number.id = `${key}Number`;
number.min = spec.min;
number.max = spec.max;
number.step = spec.step;
number.value = spec.value;
function setValue(raw) {
const value = Number(raw);
if (!Number.isFinite(value)) return;
setParamValue(key, value, false);
update();
}
range.addEventListener("input", () => setValue(range.value));
number.addEventListener("input", () => setValue(number.value));
row.append(range, number);
wrap.append(label, row);
controls.append(wrap);
}
Object.entries(params).forEach(([key, spec]) => createControl(key, spec));
function setParamValue(key, value, shouldUpdate = true) {
const spec = params[key];
const min = Number(spec.min);
const max = Number(spec.max);
const clamped = Math.min(max, Math.max(min, value));
params[key].value = clamped;
const range = document.getElementById(key);
const number = document.getElementById(`${key}Number`);
if (range) range.value = String(clamped);
if (number) number.value = String(clamped);
if (shouldUpdate) update();
}
document.getElementById("sideA").addEventListener("click", () => {
state.side = 1;
document.getElementById("sideA").classList.add("active");
document.getElementById("sideB").classList.remove("active");
update();
});
document.getElementById("sideB").addEventListener("click", () => {
state.side = -1;
document.getElementById("sideB").classList.add("active");
document.getElementById("sideA").classList.remove("active");
update();
});
document.getElementById("zoomIn").addEventListener("click", () => {
setScale(state.scale * 1.15);
});
document.getElementById("zoomOut").addEventListener("click", () => {
setScale(state.scale / 1.15);
});
document.getElementById("resetView").addEventListener("click", () => {
state.scale = 2.6;
state.panX = 0;
state.panY = 0;
update();
});
canvas.addEventListener("pointerdown", event => {
state.dragging = true;
state.dragMode = getPointerTarget(event);
state.dragStartX = event.clientX;
state.dragStartY = event.clientY;
state.panStartX = state.panX;
state.panStartY = state.panY;
canvas.classList.add("dragging");
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointermove", event => {
if (!state.dragging) return;
if (state.dragMode === "pointA") {
const point = eventToWorld(event);
const localPoint = rotatePoint(point, -value("rotation"));
setParamValue("x", localPoint.x, false);
setParamValue("y", localPoint.y, false);
} else {
state.panX = state.panStartX event.clientX - state.dragStartX;
state.panY = state.panStartY event.clientY - state.dragStartY;
}
update();
});
canvas.addEventListener("pointerup", event => {
state.dragging = false;
state.dragMode = null;
canvas.classList.remove("dragging");
canvas.releasePointerCapture(event.pointerId);
});
canvas.addEventListener("pointercancel", () => {
state.dragging = false;
state.dragMode = null;
canvas.classList.remove("dragging");
});
document.addEventListener("keydown", event => {
if (event.key === " " || event.key === "=") {
setScale(state.scale * 1.15);
} else if (event.key === "-" || event.key === "_") {
setScale(state.scale / 1.15);
}
});
function setScale(nextScale) {
state.scale = Math.min(8, Math.max(0.4, nextScale));
update();
}
function eventToCanvas(event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY -
rect.top,
rect
};
}
function eventToWorld(event) {
const pointer = eventToCanvas(event);
const offsetX = pointer.rect.width / 2 state.panX;
const offsetY = pointer.rect.height / 2 state.panY;
return {
x: (pointer.x - offsetX) / state.scale,
y: (offsetY - pointer.y) / state.scale
};
}
function pointToCanvas(point) {
const rect = canvas.getBoundingClientRect();
return {
x: rect.width / 2 state.panX point.x * state.scale,
y: rect.height / 2 state.panY - point.y * state.scale
};
}
function getPointerTarget(event) {
const result = solve();
const pointer = eventToCanvas(event);
const pointA = pointToCanvas(result.A);
const grabRadius = 16;
const distance = Math.hypot(pointer.x - pointA.x, pointer.y - pointA.y);
return distance <= grabRadius ? "pointA" : "pan";
}
function value(key) {
return params[key].value;
}
function rotatePoint(point, angleDeg) {
const angle = angleDeg * Math.PI / 180;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return {
x: point.x * cos - point.y * sin,
y: point.x * sin point.y * cos
};
}
function rotateResult(result) {
const angleDeg = value("rotation");
if (angleDeg === 0) return result;
const rotated = { ...result };
["O", "A", "B", "C", "P"].forEach(key => {
if (result[key]) {
rotated[key] = rotatePoint(result[key], angleDeg);
}
});
return rotated;
}
function solve(thetaOverride) {
const L1 = value("L1");
const L2 = value("L2");
const L3 = value("L3");
const L4 = value("L4");
const A = { x: value("x"), y: value("y") };
const thetaDeg = thetaOverride ?? value("theta");
const theta = thetaDeg * Math.PI / 180;
const O = { x: 0, y: 0 };
const B = {
x: L1 * Math.cos(theta),
y: L1 * Math.sin(theta)
};
const dx = B.x - A.x;
const dy = B.y - A.y;
const d = Math.hypot(dx, dy);
if (d > L2 L3) {
return rotateResult({ ok: false, message: "L2 と L3 が届きません。L2 L3 を大きくするか theta を変えてください。", O, A, B });
}
if (d < Math.abs(L2 - L3)) {
return rotateResult({ ok: false, message: "片方の円がもう片方の内側にあり、交点がありません。", O, A, B });
}
if (d === 0) {
return rotateResult({ ok: false, message: "A と B が同じ位置です。この条件では交点の向きが決まりません。", O, A, B });
}
const a = (L2 * L2 - L3 * L3 d * d) / (2 * d);
const h2 = Math.max(0, L2 * L2 - a * a);
const h = Math.sqrt(h2);
const ux = dx / d;
const uy = dy / d;
const base = {
x: A.x a * ux,
y: A.y a * uy
};
const C = {
x: base.x state.side * h * -uy,
y: base.y state.side * h * ux
};
const vx = (B.x - C.x) / L3;
const vy = (B.y - C.y) / L3;
const P = {
x: B.x vx * L4,
y: B.y vy * L4
};
return rotateResult({ ok: true, O, A, B, C, P });
}
function solveTrajectory() {
const points = [];
const minTheta = Number(params.theta.min);
const maxTheta = Number(params.theta.max);
const step = 1;
for (let theta = minTheta; theta <= maxTheta; theta = step) {
const result = solve(theta);
if (result.ok) {
points.push(result.P);
} else if (points.length && points[points.length - 1] !== null) {
points.push(null);
}
}
return points;
}
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function drawPoint(toCanvas, point, label, color) {
const p = toCanvas(point);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(p.x, p.y, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "
#1c2430";
ctx.font = "13px Segoe UI, sans-serif";
ctx.fillText(label, p.x 9, p.y - 9);
}
function drawLine(toCanvas, a, b, color, width) {
const pa = toCanvas(a);
const pb = toCanvas(b);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pb.x, pb.y);
ctx.stroke();
}
function drawTrajectory(toCanvas, points) {
ctx.strokeStyle = "
#e11d48";
ctx.lineWidth = 2;
ctx.setLineDash([6, 5]);
ctx.beginPath();
let drawing = false;
points.forEach(point => {
if (!point) {
drawing = false;
return;
}
const p = toCanvas(point);
if (!drawing) {
ctx.moveTo(p.x, p.y);
drawing = true;
} else {
ctx.lineTo(p.x, p.y);
}
});
ctx.stroke();
ctx.setLineDash([]);
}
function draw(result) {
resizeCanvas();
const rect = canvas.getBoundingClientRect();
ctx.clearRect(0, 0, rect.width, rect.height);
const scale = state.scale;
const offsetX = rect.width / 2 state.panX;
const offsetY = rect.height / 2 state.panY;
const toCanvas = p => ({ x: offsetX p.x * scale, y: offsetY - p.y * scale });
const worldLeft = -offsetX / scale;
const worldRight = (rect.width - offsetX) / scale;
const worldTop = offsetY / scale;
const worldBottom = -(rect.height - offsetY) / scale;
ctx.strokeStyle = "
#eef1f5";
ctx.lineWidth = 1;
for (let x = Math.ceil(worldLeft / 50) * 50; x <= worldRight; x = 50) {
drawLine(toCanvas, { x, y: worldBottom }, { x, y: worldTop }, "
#eef1f5", 1);
}
for (let y = Math.ceil(worldBottom / 50) * 50; y <= worldTop; y = 50) {
drawLine(toCanvas, { x: worldLeft, y }, { x: worldRight, y }, "
#eef1f5", 1);
}
drawLine(toCanvas, { x: worldLeft, y: 0 }, { x: worldRight, y: 0 }, "
#cfd7e3", 1);
drawLine(toCanvas, { x: 0, y: worldBottom }, { x: 0, y: worldTop }, "
#cfd7e3", 1);
drawTrajectory(toCanvas, solveTrajectory());
drawLine(toCanvas, result.O, result.B, "#111827", 7);
if (result.ok) {
drawLine(toCanvas, result.A, result.C, "#111827", 7);
drawLine(toCanvas, result.C, result.B, "
#0f766e", 7);
drawLine(toCanvas, result.B, result.P, "
#0f766e", 7);
drawPoint(toCanvas, result.C, "C", "#111827");
drawPoint(toCanvas, result.P, "P", "
#b42318");
}
drawPoint(toCanvas, result.O, "O", "#111827");
drawPoint(toCanvas, result.A, "A", "#111827");
drawPoint(toCanvas, result.B, "B", "#111827");
}
function update() {
Object.entries(params).forEach(([key, spec]) => {
const valueLabel = document.getElementById(`${key}Value`);
valueLabel.textContent = `${Number(spec.value).toFixed(2)} ${spec.unit || ""}`;
});
const result = solve();
statusEl.textContent = result.ok ? "" : result.message;
outputs.X.textContent = result.ok ? result.P.x.toFixed(3) : "-";
outputs.Y.textContent = result.ok ? result.P.y.toFixed(3) : "-";
draw(result);
}
window.addEventListener("resize", update);
update();
</script>
</body>
</html>