クラス共有版(児童の考えをGoogleスライドに集約する機能つき)を
Google Apps Scriptにデプロイするための3つのファイルです。
それぞれの「コピー」ボタンで個別にコピーし、
③ README.txt の手順に沿って script.google.com に貼り付けてください。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>三角形の面積を考えよう</title>
<style>
:root{
--cream:#FBF6EC;
--ink:#2E3440;
--sky:#4F8FBF;
--sky-dark:#376C96;
--clay:#E08A4E;
--clay-dark:#C06A2E;
--leaf:#6FA96B;
--leaf-dark:#4F8A4B;
--gold:#E8B84B;
--gold-dark:#C79626;
--send:#D9634C;
--send-dark:#B5432E;
--panel:#FFFFFF;
--line:#D9D2C2;
--shape-fill:#7FB8DE;
--shape-stroke:#2E5C7A;
--shape-fill-alt:#F4C177;
--shape-stroke-alt:#B4791E;
}
*{box-sizing:border-box;}
html,body{
margin:0; padding:0; height:100%;
font-family:"Hiragino Maru Gothic ProN","Yu Gothic",-apple-system,sans-serif;
background:var(--cream);
color:var(--ink);
overflow:hidden;
}
#app{ width:100vw; height:100vh; position:relative; }
#title-screen{
position:absolute; inset:0;
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:36px;
background:
radial-gradient(circle at 20% 20%, #FFF6E4 0%, transparent 45%),
radial-gradient(circle at 80% 80%, #E9F4FB 0%, transparent 45%),
var(--cream);
}
#title-screen h1{
font-size:clamp(32px, 6vw, 56px);
margin:0;
letter-spacing:.04em;
color:var(--sky-dark);
text-align:center;
text-shadow: 3px 3px 0 #fff;
}
#title-shape{ width:min(60vw,420px); }
#start-btn{
font-size:26px;
font-weight:700;
padding:18px 56px;
border:none;
border-radius:999px;
background:var(--clay);
color:#fff;
box-shadow:0 6px 0 var(--clay-dark);
cursor:pointer;
transition:transform .08s;
}
#start-btn:active{
transform:translateY(6px);
box-shadow:0 0 0 var(--clay-dark);
}
#work-screen{
position:absolute; inset:0;
display:flex; flex-direction:column;
background:var(--cream);
}
header{
padding:14px 24px;
font-size:22px;
font-weight:700;
color:var(--sky-dark);
text-align:center;
background:var(--panel);
border-bottom:3px solid var(--line);
flex:0 0 auto;
}
#canvas-wrap{
flex:1 1 auto;
display:flex;
align-items:center;
justify-content:center;
padding:12px;
min-height:0;
}
#canvas{
width:100%;
height:100%;
max-width:1000px;
background:
linear-gradient(0deg, transparent 24px, rgba(0,0,0,0.045) 25px, transparent 26px),
linear-gradient(90deg, transparent 24px, rgba(0,0,0,0.045) 25px, transparent 26px);
background-size:50px 50px;
background-color:#fff;
border:3px solid var(--line);
border-radius:18px;
touch-action:none;
}
#canvas.cut-mode{ cursor:crosshair; }
.shape{ cursor:grab; }
.shape:active{ cursor:grabbing; }
.shape polygon{
fill:var(--shape-fill);
stroke:var(--shape-stroke);
stroke-width:4;
stroke-linejoin:round;
}
.shape.piece polygon{
fill:var(--shape-fill-alt);
stroke:var(--shape-stroke-alt);
}
.shape.selected polygon{
stroke-width:6;
filter:drop-shadow(0 0 6px rgba(224,138,78,0.7));
}
.cut-marker{
fill:var(--clay-dark);
stroke:#fff;
stroke-width:2;
pointer-events:none;
}
#cut-preview{
stroke:var(--clay-dark);
stroke-width:3;
stroke-dasharray:9,7;
pointer-events:none;
}
#toolbar{
flex:0 0 auto;
display:flex;
gap:22px;
justify-content:center;
flex-wrap:wrap;
padding:14px 16px;
background:var(--panel);
border-top:3px solid var(--line);
}
.tool-group{
display:flex;
gap:10px;
padding:6px 12px;
border-radius:20px;
background:#F7F3EA;
border:2px solid var(--line);
}
.tool-btn{
display:flex; flex-direction:column; align-items:center; gap:2px;
font-size:14px;
font-weight:700;
padding:9px 14px;
border:none;
border-radius:14px;
background:var(--leaf);
color:#fff;
box-shadow:0 4px 0 var(--leaf-dark);
cursor:pointer;
transition: all 0.15s ease-out;
min-width:76px;
}
.tool-btn .icon{ font-size:24px; line-height:1; }
.tool-btn:active{ transform:translateY(4px); box-shadow:0 0 0 var(--leaf-dark); }
.tool-btn.flip-btn{ background:var(--sky); box-shadow:0 4px 0 var(--sky-dark); }
.tool-btn.flip-btn:active{ box-shadow:0 0 0 var(--sky-dark); }
.tool-btn.add-btn{ background:var(--gold); box-shadow:0 4px 0 var(--gold-dark); }
.tool-btn.add-btn:active{ box-shadow:0 0 0 var(--gold-dark); }
.tool-btn.cut-btn{ background:var(--clay); box-shadow:0 4px 0 var(--clay-dark); }
.tool-btn.cut-btn:active{ box-shadow:0 0 0 var(--clay-dark); }
.tool-btn.cut-btn.active{
box-shadow:0 4px 0 var(--clay-dark), 0 0 0 4px rgba(224,138,78,0.45);
}
.tool-btn.note-btn{ background:var(--gold); box-shadow:0 4px 0 var(--gold-dark); }
.tool-btn.note-btn:active{ box-shadow:0 0 0 var(--gold-dark); }
#hint{
position:fixed;
bottom:118px; left:50%; transform:translateX(-50%);
background:rgba(46,52,64,0.85);
color:#fff;
padding:8px 18px;
border-radius:999px;
font-size:14px;
pointer-events:none;
opacity:0;
transition:opacity .3s;
text-align:center;
max-width:80vw;
}
#hint.show{ opacity:1; }
/* ---------- 気づいたこと(常時表示) ---------- */
#notes-section{
flex:0 0 auto;
display:flex;
align-items:center;
gap:14px;
padding:10px 22px;
background:var(--panel);
border-top:3px solid var(--line);
}
#notes-label{
font-size:16px; font-weight:700; color:var(--sky-dark);
white-space:nowrap;
}
#notes-text{
flex:1 1 auto;
min-height:44px;
max-height:110px;
resize:vertical;
font-size:17px; line-height:1.6;
padding:10px 14px; border-radius:12px; border:2px solid var(--line);
font-family:inherit;
background:#FFFDF8;
}
#notes-text:focus{ outline:3px solid var(--gold); border-color:var(--gold-dark); }
#send-section{
flex:0 0 auto;
display:flex;
align-items:center;
gap:12px;
padding:10px 22px 14px;
background:var(--panel);
border-top:1px solid var(--line);
}
#name-input{
width:140px;
font-size:16px;
padding:9px 12px;
border-radius:12px;
border:2px solid var(--line);
font-family:inherit;
background:#FFFDF8;
}
#name-input:focus{ outline:3px solid var(--gold); border-color:var(--gold-dark); }
#send-btn{
display:flex; align-items:center; gap:6px;
font-size:16px; font-weight:700;
padding:10px 20px;
border:none; border-radius:14px;
background:var(--send);
color:#fff;
box-shadow:0 4px 0 var(--send-dark);
cursor:pointer;
}
#send-btn:active{ transform:translateY(4px); box-shadow:0 0 0 var(--send-dark); }
#send-btn:disabled{ opacity:0.6; cursor:default; transform:none; box-shadow:0 4px 0 var(--send-dark); }
#send-status{ font-size:14px; color:var(--sky-dark); font-weight:700; min-width:70px; }
button:focus-visible, [tabindex]:focus-visible, textarea:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
[hidden]{ display:none !important; }
</style>
</head>
<body>
<div id="app">
<section id="title-screen">
<h1>三角形の面積を<br>考えよう!</h1>
<svg id="title-shape" viewBox="0 0 300 160">
<polygon points="150,20 270,140 30,140"
fill="#7FB8DE" stroke="#2E5C7A" stroke-width="6" stroke-linejoin="round"/>
</svg>
<button id="start-btn">はじめる</button>
</section>
<section id="work-screen" hidden>
<header>三角形の面積を考えよう</header>
<div id="canvas-wrap">
<svg id="canvas" viewBox="0 0 900 560" preserveAspectRatio="xMidYMid meet"></svg>
</div>
<div id="toolbar">
<div class="tool-group">
<button class="tool-btn add-btn" data-action="addShape"><span class="icon">+</span>三角形を出す</button>
</div>
<div class="tool-group">
<button class="tool-btn" data-action="rotate"><span class="icon">↻</span>回転</button>
<button class="tool-btn flip-btn" data-action="flipH"><span class="icon">⇆</span>左右反転</button>
<button class="tool-btn flip-btn" data-action="flipV"><span class="icon">⇅</span>上下反転</button>
</div>
<div class="tool-group">
<button class="tool-btn cut-btn" data-action="freeCut"><span class="icon">✂</span>自由に切る</button>
</div>
</div>
<div id="notes-section">
<span id="notes-label">気づいたこと</span>
<textarea id="notes-text" placeholder="気づいたことを書いてみよう"></textarea>
</div>
<div id="send-section">
<input id="name-input" type="text" placeholder="なまえ" maxlength="20">
<button id="send-btn">📤 みんなに送る</button>
<span id="send-status"></span>
</div>
</section>
<div id="hint">図形をつかんで動かしてみよう</div>
<canvas id="capture-canvas" width="1800" height="1120" style="display:none;"></canvas>
</div>
<script>
(function(){
const svgNS = 'http://www.w3.org/2000/svg';
const titleScreen = document.getElementById('title-screen');
const workScreen = document.getElementById('work-screen');
const startBtn = document.getElementById('start-btn');
const canvas = document.getElementById('canvas');
const hint = document.getElementById('hint');
const cutBtn = document.querySelector('[data-action="freeCut"]');
const notesText = document.getElementById('notes-text');
const nameInput = document.getElementById('name-input');
const sendBtn = document.getElementById('send-btn');
const sendStatus = document.getElementById('send-status');
const captureCanvas = document.getElementById('capture-canvas');
const NAME_KEY = 'triangle-name';
nameInput.value = safeGet(NAME_KEY) || '';
startBtn.addEventListener('click', () => {
titleScreen.hidden = true;
workScreen.hidden = false;
showHint('図形をつかんで動かしてみよう', 3000);
});
function showHint(text, duration){
hint.textContent = text;
hint.classList.add('show');
if(duration){
clearTimeout(showHint._t);
showHint._t = setTimeout(() => hint.classList.remove('show'), duration);
}
}
// ---------------- 気づいたことメモ ----------------
const NOTES_KEY = 'triangle-notes';
function safeGet(key){ try{ return localStorage.getItem(key); } catch(e){ return null; } }
function safeSet(key, val){ try{ localStorage.setItem(key, val); } catch(e){} }
let saveTimer = null;
function scheduleSave(){
clearTimeout(saveTimer);
saveTimer = setTimeout(() => safeSet(NOTES_KEY, notesText.value), 400);
}
notesText.value = safeGet(NOTES_KEY) || '';
notesText.addEventListener('input', scheduleSave);
let idCounter = 1;
const state = {
shapes: [
{
id: 'shape0',
points: [
{x:-120, y:60}, {x:100, y:60}, {x:40, y:-70}
],
cx: 450, cy: 280,
angle: 0, flipx: 1, flipy: 1,
isPiece: false
}
],
selectedId: 'shape0',
cutMode: false,
cutPoints: []
};
function getShape(id){
return state.shapes.find(s => s.id === id);
}
function render(){
state.shapes.forEach(shape => {
let g = document.getElementById(shape.id);
if(!g){
g = document.createElementNS(svgNS, 'g');
g.setAttribute('id', shape.id);
g.setAttribute('class', 'shape' + (shape.isPiece ? ' piece' : ''));
const poly = document.createElementNS(svgNS, 'polygon');
g.appendChild(poly);
canvas.appendChild(g);
attachShapeEvents(g);
}
const poly = g.querySelector('polygon');
poly.setAttribute('points', shape.points.map(p => `${p.x},${p.y}`).join(' '));
updateTransform(shape);
g.classList.toggle('selected', shape.id === state.selectedId);
});
Array.from(canvas.querySelectorAll('.shape')).forEach(g => {
if(!state.shapes.find(s => s.id === g.id)) g.remove();
});
}
function updateTransform(shape){
const g = document.getElementById(shape.id);
if(!g) return;
g.setAttribute('transform',
`translate(${shape.cx} ${shape.cy}) rotate(${shape.angle}) scale(${shape.flipx} ${shape.flipy})`);
}
function selectShape(id){
state.selectedId = id;
document.querySelectorAll('#canvas .shape').forEach(g => {
g.classList.toggle('selected', g.id === id);
});
}
function getSelectedShape(){
return getShape(state.selectedId) || state.shapes[0];
}
function svgPoint(evt){
const pt = canvas.createSVGPoint();
pt.x = evt.clientX;
pt.y = evt.clientY;
const ctm = canvas.getScreenCTM().inverse();
return pt.matrixTransform(ctm);
}
// ---------------- ドラッグ ----------------
let dragging = null;
let dragOffset = {x:0, y:0};
function attachShapeEvents(g){
g.addEventListener('pointerdown', (evt) => {
if(state.cutMode) return;
g.setPointerCapture(evt.pointerId);
const shape = getShape(g.id);
selectShape(shape.id);
const p = svgPoint(evt);
dragging = shape;
dragOffset.x = p.x - shape.cx;
dragOffset.y = p.y - shape.cy;
hint.classList.remove('show');
});
g.addEventListener('pointermove', (evt) => {
if(!dragging || dragging.id !== g.id) return;
const p = svgPoint(evt);
let nx = p.x - dragOffset.x;
let ny = p.y - dragOffset.y;
nx = Math.max(40, Math.min(860, nx));
ny = Math.max(40, Math.min(520, ny));
dragging.cx = nx;
dragging.cy = ny;
updateTransform(dragging);
});
g.addEventListener('pointerup', endDrag);
g.addEventListener('pointercancel', endDrag);
}
function endDrag(){ dragging = null; }
// ---------------- 幾何計算 ----------------
function lineSide(p, a, b){
return (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
}
function intersectLines(p1, p2, a, b){
const x1=p1.x, y1=p1.y, x2=p2.x, y2=p2.y;
const x3=a.x, y3=a.y, x4=b.x, y4=b.y;
const denom = (x1-x2)*(y3-y4) - (y1-y2)*(x3-x4);
if(Math.abs(denom) < 1e-9) return {x:x2, y:y2};
const t = ((x1-x3)*(y3-y4) - (y1-y3)*(x3-x4)) / denom;
return { x: x1 + t*(x2-x1), y: y1 + t*(y2-y1) };
}
function clipPolygon(points, a, b, keepPositive){
const n = points.length;
const out = [];
for(let i=0;i<n;i++){
const cur = points[i];
const prev = points[(i - 1 + n) % n];
const curSide = lineSide(cur, a, b);
const prevSide = lineSide(prev, a, b);
const curIn = keepPositive ? curSide >= 0 : curSide <= 0;
const prevIn = keepPositive ? prevSide >= 0 : prevSide <= 0;
if(curIn){
if(!prevIn) out.push(intersectLines(prev, cur, a, b));
out.push(cur);
} else if(prevIn){
out.push(intersectLines(prev, cur, a, b));
}
}
return out;
}
function polygonCentroid(points){
let area = 0, cx = 0, cy = 0;
const n = points.length;
for(let i=0;i<n;i++){
const p1 = points[i], p2 = points[(i+1) % n];
const cross = p1.x * p2.y - p2.x * p1.y;
area += cross;
cx += (p1.x + p2.x) * cross;
cy += (p1.y + p2.y) * cross;
}
area *= 0.5;
if(Math.abs(area) < 1e-6){
let ax=0, ay=0;
points.forEach(p => { ax += p.x; ay += p.y; });
return { x: ax/points.length, y: ay/points.length };
}
cx /= (6*area);
cy /= (6*area);
return { x: cx, y: cy };
}
function makePieceFromLocalPoints(shape, localPts){
const centroid = polygonCentroid(localPts);
const shifted = localPts.map(p => ({x: p.x - centroid.x, y: p.y - centroid.y}));
const sx = centroid.x * shape.flipx;
const sy = centroid.y * shape.flipy;
const rad = shape.angle * Math.PI / 180;
const rx = Math.cos(rad)*sx - Math.sin(rad)*sy;
const ry = Math.sin(rad)*sx + Math.cos(rad)*sy;
return {
id: 'shape' + (idCounter++),
points: shifted,
cx: shape.cx + rx,
cy: shape.cy + ry,
angle: shape.angle,
flipx: shape.flipx,
flipy: shape.flipy,
isPiece: true
};
}
function worldToLocal(shape, world){
const dx = world.x - shape.cx;
const dy = world.y - shape.cy;
const rad = -shape.angle * Math.PI / 180;
const rx = Math.cos(rad)*dx - Math.sin(rad)*dy;
const ry = Math.sin(rad)*dx + Math.cos(rad)*dy;
return { x: rx * shape.flipx, y: ry * shape.flipy };
}
function cutShapeByLine(shape, a, b){
const pts = shape.points;
const partA = clipPolygon(pts, a, b, true);
const partB = clipPolygon(pts, a, b, false);
if(partA.length < 3 || partB.length < 3) return null;
return [makePieceFromLocalPoints(shape, partA), makePieceFromLocalPoints(shape, partB)];
}
// ---------------- 自由に切るモード ----------------
function startCutMode(){
const shape = getSelectedShape();
if(!shape){
showHint('先に切りたい図形をえらんでね', 2500);
return;
}
state.cutMode = true;
state.cutPoints = [];
canvas.classList.add('cut-mode');
cutBtn.classList.add('active');
showHint('切りたい線の1つ目の点をタップ');
}
function endCutMode(){
state.cutMode = false;
state.cutPoints = [];
canvas.classList.remove('cut-mode');
cutBtn.classList.remove('active');
clearCutMarkers();
}
function clearCutMarkers(){
canvas.querySelectorAll('.cut-marker, #cut-preview').forEach(el => el.remove());
}
function addCutMarker(p){
const c = document.createElementNS(svgNS, 'circle');
c.setAttribute('class', 'cut-marker');
c.setAttribute('cx', p.x);
c.setAttribute('cy', p.y);
c.setAttribute('r', 9);
canvas.appendChild(c);
}
function updateCutPreview(a, b){
let line = document.getElementById('cut-preview');
if(!line){
line = document.createElementNS(svgNS, 'line');
line.setAttribute('id', 'cut-preview');
canvas.appendChild(line);
}
line.setAttribute('x1', a.x); line.setAttribute('y1', a.y);
line.setAttribute('x2', b.x); line.setAttribute('y2', b.y);
}
canvas.addEventListener('pointerdown', (evt) => {
if(!state.cutMode) return;
const p = svgPoint(evt);
state.cutPoints.push(p);
addCutMarker(p);
if(state.cutPoints.length === 1){
showHint('2つ目の点をタップ');
} else if(state.cutPoints.length === 2){
const [wA, wB] = state.cutPoints;
const shape = getSelectedShape();
const localA = worldToLocal(shape, wA);
const localB = worldToLocal(shape, wB);
const pieces = cutShapeByLine(shape, localA, localB);
endCutMode();
if(!pieces){
showHint('その線では切れませんでした。図形を通る線をえらんでね', 3000);
return;
}
state.shapes = state.shapes.filter(s => s.id !== shape.id);
state.shapes.push(...pieces);
selectShape(pieces[0].id);
render();
}
});
canvas.addEventListener('pointermove', (evt) => {
if(state.cutMode && state.cutPoints.length === 1){
updateCutPreview(state.cutPoints[0], svgPoint(evt));
}
});
function addShapeCopy(){
const newShape = {
id: 'shape' + (idCounter++),
points: [ {x:-120, y:60}, {x:100, y:60}, {x:40, y:-70} ],
cx: 200 + Math.random() * 500,
cy: 150 + Math.random() * 260,
angle: 0, flipx: 1, flipy: 1,
isPiece: false
};
state.shapes.push(newShape);
selectShape(newShape.id);
render();
}
// ---------------- ツールバー ----------------
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.action;
if(action === 'addShape'){
addShapeCopy();
return;
}
if(action === 'freeCut'){
if(state.cutMode){ endCutMode(); }
else { startCutMode(); }
return;
}
const shape = getSelectedShape();
if(!shape) return;
if(action === 'rotate'){
shape.angle = (shape.angle + 15) % 360;
updateTransform(shape);
} else if(action === 'flipH'){
shape.flipx *= -1;
updateTransform(shape);
} else if(action === 'flipV'){
shape.flipy *= -1;
updateTransform(shape);
}
});
});
render();
function notifyUser(text, duration){
if(typeof showHint === 'function'){ showHint(text, duration); return; }
sendStatus.textContent = text;
setTimeout(() => { sendStatus.textContent = ''; }, duration || 2500);
}
// ---------------- スライドに送る ----------------
function captureShapeImage(callback){
const clone = canvas.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clone.setAttribute('width', '900');
clone.setAttribute('height', '560');
clone.querySelectorAll('.cut-marker, #cut-preview').forEach(el => el.remove());
clone.querySelectorAll('.shape').forEach(el => el.classList.remove('selected'));
const svgString = new XMLSerializer().serializeToString(clone);
const svgData = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgString);
const img = new Image();
img.onload = function(){
const ctx = captureCanvas.getContext('2d');
ctx.clearRect(0, 0, captureCanvas.width, captureCanvas.height);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, captureCanvas.width, captureCanvas.height);
ctx.drawImage(img, 0, 0, captureCanvas.width, captureCanvas.height);
const dataUrl = captureCanvas.toDataURL('image/png');
callback(dataUrl.split(',')[1]);
};
img.onerror = function(){ callback(null); };
img.src = svgData;
}
sendBtn.addEventListener('click', () => {
const name = nameInput.value.trim();
if(!name){
notifyUser('なまえを入力してね', 2500);
nameInput.focus();
return;
}
safeSet(NAME_KEY, name);
sendBtn.disabled = true;
sendStatus.textContent = '送信中…';
captureShapeImage(function(base64){
if(!base64){
sendStatus.textContent = '';
notifyUser('画像の作成に失敗しました', 2500);
sendBtn.disabled = false;
return;
}
if(typeof google !== 'undefined' && google.script && google.script.run){
google.script.run
.withSuccessHandler(function(){
sendStatus.textContent = '送りました!';
sendBtn.disabled = false;
setTimeout(() => { sendStatus.textContent = ''; }, 2500);
})
.withFailureHandler(function(){
sendStatus.textContent = '';
notifyUser('送信に失敗しました。もう一度試してね', 3000);
sendBtn.disabled = false;
})
.sendToSlide({ name: name, notes: notesText.value, imageBase64: base64 });
} else {
sendStatus.textContent = '';
notifyUser('この機能はGoogleスライド版として配布したときだけ使えます', 3200);
sendBtn.disabled = false;
}
});
});
})();
</script>
</body>
</html>