ガント
AssociatronやAtraの開発にも使っているFileMaker。
普段公開しているデモはJavaScript、Python、C++だけど、
FileMakerはJavaScriptのテストデータ送り込むための実験室のように使ってます。
僕の昼間の仕事(システム開発)はFMがメインです。
もちろん、AssociatronやAtra開発するときなんかも使ってます。ファイルが乱雑に整理されていないときは、FileMakerでフォルダ管理したりしてますからね。実験って、ログがめちゃくちゃ多いので・・・。何時の、どんなふるまいのログなのか人の頭じゃ思い出せないでしょ・・・JSONファイルの山なんです。
随分と前のログを確認する頻度も高いのでフォルダ管理は必須なんです。(そっちは非公開:そのうち・・)
今回のようなGantもFileMakerで作って管理しちゃう。
あんまり凝ったもの作っても実際使わないから、単純でシンプルなもの。
例えば、普通のソフト開発みたいに
| 1 | 目的の定義 | Define the purpose |
| 2 | 利用者・使用場面の整理 | Identify users and use cases |
| 3 | 要件定義 | Requirements definition |
| 4 | 制約条件の確認 | Confirm constraints |
| 5 | 全体構成設計 | Overall architecture design |
| 6 | データ設計 | Data design |
| 7 | 画面・操作設計 | UI / UX design |
| 8 | 処理フロー設計 | Process flow design |
| 9 | モジュール設計 | Module design |
| 10 | 外部連携設計 | External integration design |
| 11 | エラー・例外設計 | Error and exception handling design |
| 12 | セキュリティ設計 | Security design |
| 13 | ログ・監視設計 | Logging and monitoring design |
| 14 | テスト設計 | Test design |
| 15 | 実装 | Implementation |
| 16 | 単体テスト | Unit testing |
| 17 | 結合テスト | Integration testing |
| 18 | 運用テスト | Operational testing |
| 19 | リリース | Release |
| 20 | 保守・改善 | Maintenance and improvement |
みたいにも使ってるんですけど、これは昼間のシステム開発用。
AssociatronやAtraなどを管理する場合は、dirを並べる
├── .vscode/
│ └── settings.json
├── __pycache__/
│ ├── lab_input.cpython-314.pyc
│ └── main.cpython-314.pyc
├── core/
│ ├── __pycache__/
│ │ ├── __init__.cpython-314.pyc
│ │ ├── action.cpython-314.pyc
│ │ ├── associatron.cpython-314.pyc
│ │ ├── attractor.cpython-314.pyc
│ │ ├── carry.cpython-314.pyc
│ │ ├── clock.cpython-314.pyc
│ │ ├── difference.cpython-314.pyc
│ │ ├── experience.cpython-314.pyc
│ │ ├── field_index.cpython-314.pyc
│ │ ├── field_log.cpython-314.pyc
│ │ ├── initial_state.cpython-314.pyc
│ │ ├── recall.cpython-314.pyc
│ │ └── state.cpython-314.pyc
│ ├── __init__.py
│ ├── action.py
│ ├── associatron.py
│ ├── attractor.py
│ ├── carry.py
│ ├── clock.py
│ ├── difference.py
│ ├── experience.py
│ ├── field_index.py
│ ├── field_log.py
│ ├── initial_state.py
│ ├── recall.py
│ └── state.py
├── data/
│ ├── .gitkeep
│ ├── atra_state.json
│ ├── experience_log.jsonl
│ ├── field_log.jsonl
│ └── view_snapshot.json
├── docs/
│ ├── architecture.mmd
│ ├── camera_portal_tracking_step03.md
│ ├── comment_rules.md
│ ├── design.md
│ └── file_roles.md
├── output/
│ ├── __pycache__/
│ │ ├── __init__.cpython-314.pyc
│ │ └── txtspeech.cpython-314.pyc
│ ├── __init__.py
│ └── txtspeech.py
├── sensors/
│ ├── __pycache__/
│ │ └── __init__.cpython-314.pyc
│ ├── __init__.py
│ ├── camera.py
│ └── microphone.py
├── .gitignore
├── camera_test.py
├── index.html
├── index_portal_camera.html
├── lab_input.py
├── main.py
├── main_camera_portal.py
├── README.md
└── requirements.txt
うちみたいにVS Codeで作業していると、
試しに作ったファイル
一時テスト
古い版
動いた版
修正版
backup
copy
v2
v3
final
final_fixed
みたいに、作業フォルダの中が増殖するんですよね。
なるべく短めに。
scriptで、Webビューアに入れるhtmlだけ貼っておきますね。
g_GanttHTML(グローバル・フィールド)に貼り付けるhtml
---------------html------------------
<html lang="ja"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>工程表</title> <style> /* ========================================================= 工程表(65日固定・PDF複数ページヘッダ繰り返し) - レーン割当:開始日順に「重ならない最上段レーンへ詰める」(完全自動) - ソート:顧客/現場(複数条件) - FileMaker連動:バークリックでTasksを開く(入力はFM側) - FMから受け取るJSON:baseStart/days/customers を __FM_INIT_JSON__ で注入 ========================================================= */ :root{ /* ===================================================== ★ 表示倍率(ここだけ触ればOK) 画面(Webビューア): 100% PDF(印刷) : 100% ===================================================== */ --zoom-screen: 1.08; --zoom-print: 1.00; --label-w: 220px; --row-h: 34px; --day-w: 22px; --grid: #cbd5e1; --major-grid: #64748b; --major-w: 2px; /* PDF用(印刷直前だけ JS で切替) */ --pdf-day-w-a4: 14px; --pdf-day-w-a3: 18px; --pdf-label-w-a4: 190px; --pdf-label-w-a3: 210px; /* ★土日色 */ --sun: #dc2626; /* 赤 */ --sat: #2563eb; /* 青 */ } /* ===== Base ===== */ body{ margin:0; padding:14px; font-family:"Meiryo","メイリオ","Hiragino Kaku Gothic ProN","Yu Gothic",system-ui,sans-serif; font-size:11px; line-height:1.4; background:#f3f4f6; /* ★画面(Webビューア)だけ 110% */ zoom: var(--zoom-screen); } .wrap{ background:#fff; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden; box-shadow:0 1px 8px rgba(0,0,0,.06); } /* ===== Header ===== */ .head{ padding:10px 12px; border-bottom:1px solid #e5e7eb; font-weight:900; display:flex; gap:12px; align-items:center; flex-wrap:wrap; } .head small{ font-weight:700; color:#374151; } .ctrl{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; } input[type="date"], select, input[type="text"], input[type="number"]{ padding:6px 8px; border:1px solid #e5e7eb; border-radius:8px; font-weight:800; background:#fff; } /* ヘッダー入力が無駄に広がらないよう固定 */ #startDate{ width:140px; } #custSortSel{ width:150px; } #siteSortSel{ width:140px; } #paperSel{ width:110px; } /* Buttons */ .btn{ border:1px solid #e5e7eb; border-radius:10px; padding:8px 12px; font-weight:800; cursor:pointer; display:inline-flex; align-items:center; gap:8px; user-select:none; } .btn:active{ transform: translateY(1px); } .btn-gray{ background:#e5e7eb; color:#111827; } .btn-orange{ background:#f97316; border-color:#fb923c; color:#fff; } .btn-blue{ background:#3b82f6; border-color:#60a5fa; color:#fff; } .btn-green{ background:#16a34a; border-color:#22c55e; color:#fff; } .btn-red{ background:#ef4444; border-color:#f87171; color:#fff; } .btn-mini{ padding:6px 8px; border-radius:10px; font-weight:900; font-size:11px; } /* ===== Gantt layout ===== */ .gantt{ display:grid; grid-template-columns: var(--label-w) 1fr; } .leftHead{ background:#f9fafb; border-right:1px solid #e5e7eb; padding:8px 10px; font-weight:900; } .topHead{ background:#f9fafb; border-bottom:1px solid #e5e7eb; overflow:auto; white-space:nowrap; } /* ===== Header cells(border方式)===== */ .days{ display:flex; width:max-content; } /* ★ヘッダを3段にするので高さを増やす */ .day{ width:var(--day-w); flex:0 0 var(--day-w); /* ★日付列を縮ませない */ height:44px; /* ★32→44 */ box-sizing:border-box; border-left:1px solid var(--grid); position:relative; } .day.major{ border-left: var(--major-w) solid var(--major-grid); } /* 上段:月(必要な日だけ表示) */ .day .m{ position:absolute; top:2px; left:4px; font-weight:900; font-size:10px; color:#374151; white-space:nowrap; } /* 中段:日(1〜31)…曜日の上に毎日表示 */ .day .dnum{ position:absolute; top:18px; left:4px; /* ★曜日の上に固定 */ font-weight:900; font-size:11px; color:#111827; line-height:1; white-space:nowrap; } /* 下段:曜日(10pt) */ .day .dow{ position:absolute; top:32px; left:4px; /* ★最下段に固定 */ font-weight:900; font-size:10pt; /* ★指定どおり */ line-height:1; color:#111827; white-space:nowrap; } /* 土日色(曜日と日を色付け) */ .day.sun .dow, .day.sun .dnum{ color: var(--sun); } .day.sat .dow, .day.sat .dnum{ color: var(--sat); } /* ===== Left body ===== */ .left{ border-right:1px solid #e5e7eb; } .section{ height:var(--row-h); display:flex; align-items:center; gap:8px; padding:0 10px; font-weight:900; background:#f3f4f6; border-bottom:1px solid #e5e7eb; } .section .name{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .section .tools{ display:flex; gap:6px; align-items:center; flex:0 0 auto; } /* ===== Right body ===== */ .right{ overflow:auto; } .gridRow{ position:relative; height:var(--row-h); border-bottom:1px solid #eef2f7; width:max-content; } .cells{ display:flex; height:100%; } .cell{ width:var(--day-w); flex:0 0 var(--day-w); /* ★日付列を縮ませない */ height:100%; box-sizing:border-box; border-left:1px solid var(--grid); } .cell.major{ border-left: var(--major-w) solid var(--major-grid); } .bar{ position:absolute; top:6px; height:22px; box-sizing:border-box; /* ★重要:padding込みで日付幅に収める。6/1〜6/2 が 6/3 に食い込まない */ border-radius:6px; color:#fff; font-weight:700; font-size:11px; display:flex; align-items:center; padding:0 6px; /* ★PDFでもはみ出しにくいよう少しだけ詰める */ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:pointer; box-shadow:0 1px 4px rgba(0,0,0,.15); text-shadow:0 1px 2px rgba(0,0,0,.35); z-index:2; } /* ========================================================= ★ check==1 の時だけ comment をバー内に表示(現場名は消さない) - work:白(既存どおり) - comment:黒 / 9pt / メイリオ / バー幅いっぱい / …省略 ========================================================= */ .bar .barName{ flex:0 0 auto; max-width:55%; /* ★現場名が長すぎる時はここで抑える(必要なら調整) */ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .bar .barComment{ flex:1 1 auto; width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:"Meiryo","メイリオ","Hiragino Kaku Gothic ProN","Yu Gothic",system-ui,sans-serif; font-size:9pt; color:#000; text-shadow:none; } /* ===== 印刷(printRoot方式)===== */ @media print{ html, body{ -webkit-print-color-adjust: exact; print-color-adjust: exact; } @page { margin: 6mm; } body{ background:#fff; padding:0; /* ★PDF(印刷)だけ 115% */ zoom: var(--zoom-print); } body[data-printing="1"] .wrap{ display:none ; } body[data-printing="1"] #printRoot{ display:block ; } #printRoot{ display:block; } .pPage{ page-break-after: always; margin:0; } .pPage:last-child{ page-break-after: auto; } .pHead{ padding:6px 0 8px; font-weight:900; display:flex; justify-content:space-between; align-items:flex-end; gap:12px; } .pHead small{ font-weight:700; color:#374151; } .topHead, .right{ overflow: visible ; } } /* PDF用紙切替 */ body[data-paper="A4"]{ --label-w: var(--pdf-label-w-a4); --day-w: var(--pdf-day-w-a4); } body[data-paper="A3"]{ --label-w: var(--pdf-label-w-a3); --day-w: var(--pdf-day-w-a3); } /* 印刷用DOMは通常は非表示 */ #printRoot{ display:none; } </style> </head> <body> <div class="wrap"> <div class="head"> <div> 工程表(65日) <small> </small> </div> <div class="ctrl"> <label style="font-weight:900;">開始日</label> <input type="date" id="startDate" value="2026-01-01"> <label style="font-weight:900; margin-left:6px;">taskソート</label> <select id="custSortSel" title="並び順"> <option value="none">なし(登録順)</option> <option value="custId">ID</option> <option value="custName">task名</option> <option value="custStart">開始日(最早)</option> <option value="custStartThenId">開始日→顧客ID</option> </select> <label style="font-weight:900; margin-left:6px;">workソート</label> <select id="siteSortSel" title="workの並び順(顧客内)"> <option value="none">なし(登録順)</option> <option value="siteName">work名</option> <option value="siteStart">work別開始日</option> <option value="siteStartThenName">work別開始日→work</option> </select> <label style="font-weight:900; margin-left:6px;">PDF</label> <select id="paperSel" title="PDF用紙"> <option value="A4">A4(横)</option> <option value="A3" selected>A3(横)</option> </select> <button id="apply" class="btn btn-blue">反映</button> <button id="pdfBtn" class="btn btn-orange">PDF</button> <span style="width:12px;"></span> <button id="resetData" class="btn btn-red" title="受信データを捨てて初期DATAに戻す(テスト用)">リセット</button> </div> </div> <div class="gantt"> <div class="leftHead">task</div> <div class="topHead" id="topHead"> <div class="days" id="days"></div> </div> <div class="left" id="left"></div> <div class="right" id="right"></div> </div> </div> <div id="printRoot"></div> <script> function fmCall(scriptName, obj){ if(!window.FileMaker) return; FileMaker.PerformScript(scriptName, JSON.stringify(obj)); } const FM_INIT = __FM_INIT_JSON__; const INIT_DATA = [ { customerId: "2", customer: "taskA", color: "#2563eb", sites: [ { taskId: 1, name:"workA", start:"2026-01-10", end:"2026-06-14", comment:"" }, { taskId: 2, name:"workA-2", start:"2026-01-28", end:"2026-06-08", comment:"" } ] }, { customerId: "3", customer: "design", color: "#16a34a", sites: [ { taskId: 3, name:"workBB", start:"2026-06-30", end:"2026-07-05", comment:"" } ] } ]; function deepClone(x){ return JSON.parse(JSON.stringify(x)); } let DATA = deepClone(INIT_DATA); let DAYS = 65; const toDate = s => { const [y,m,d]=String(s).split("-").map(Number); return new Date(y,m-1,d); }; const addDays = (d,n)=>{ const x=new Date(d); x.setDate(x.getDate()+n); return x; }; const diffDays = (a,b)=>Math.round((b-a)/(1000*60*60*24)); const pad2 = n => String(n).padStart(2,"0"); const toISO = d => `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`; const monthJa = d => `${d.getMonth()+1}月`; const dayNum = d => String(d.getDate()); const dowJa = d => ["日","月","火","水","木","金","土"][d.getDay()]; const dayClass = d => (d.getDay()===0 ? "sun" : (d.getDay()===6 ? "sat" : "")); function isMajorDay(i, base){ const d = addDays(base, i); return (i === 0) || (d.getDay() === 1); } function earliestSiteTime(block){ let t = Infinity; for(const s of (block.sites || [])){ if(!s?.start) continue; const st = toDate(s.start).getTime(); if(st < t) t = st; } return t; } function cmpCustIdByValue(A, B){ const a = String(A ?? ""); const b = String(B ?? ""); const isNa = /^\d+$/.test(a), isNb = /^\d+$/.test(b); if(isNa && isNb) return Number(a) - Number(b); return a.localeCompare(b, "ja"); } function getSortedCustomerIndices(){ const mode = document.getElementById("custSortSel").value; const idx = DATA.map((_, i)=>i); const byId = (a,b)=> cmpCustIdByValue(DATA[a].customerId, DATA[b].customerId) || (a-b); const byName = (a,b)=> String(DATA[a].customer ?? "").localeCompare(String(DATA[b].customer ?? ""), "ja") || (a-b); const byStart = (a,b)=> (earliestSiteTime(DATA[a]) - earliestSiteTime(DATA[b])) || (a-b); if(mode === "custId") idx.sort(byId); else if(mode === "custName") idx.sort(byName); else if(mode === "custStart") idx.sort(byStart); else if(mode === "custStartThenId") idx.sort((a,b)=> byStart(a,b) || byId(a,b)); return idx; } function getSortedSiteIndices(block){ const mode = document.getElementById("siteSortSel").value; const sites = (block.sites || []); const idx = sites.map((_, i)=>i); const byName = (a,b)=> String(sites[a]?.name ?? "").localeCompare(String(sites[b]?.name ?? ""), "ja") || (a-b); const byStart = (a,b)=> { const A = sites[a]?.start ? toDate(sites[a].start).getTime() : Infinity; const B = sites[b]?.start ? toDate(sites[b].start).getTime() : Infinity; return (A - B) || (a-b); }; if(mode === "siteName") idx.sort(byName); else if(mode === "siteStart") idx.sort(byStart); else if(mode === "siteStartThenName") idx.sort((a,b)=> byStart(a,b) || byName(a,b)); return idx; } function packLanes(block, base, orderedIdx){ const sitesArr = (block.sites || []); const orderIdx = Array.isArray(orderedIdx) ? orderedIdx : sitesArr.map((_,i)=>i); const items = orderIdx.map((si) => { const s = sitesArr[si]; const st = toDate(s.start); const x0 = diffDays(base, st); const w0 = calcDurationDays(s); return { si, x0, w0, stTime: st.getTime() }; }); const laneEnds = []; const order = []; for(const it of items){ const start = it.x0; const end = it.x0 + it.w0; let lane = -1; for(let i=0;i<laneEnds.length;i++){ if(laneEnds[i] <= start){ lane = i; break; } } if(lane === -1){ lane = laneEnds.length; laneEnds.push(end); }else{ laneEnds[lane] = end; } order.push({ si: it.si, lane }); } return { order, lanesNeeded: laneEnds.length }; } function calcDurationDays(site){ try{ if(site?.start && site?.end){ const sd = toDate(site.start); const ed = toDate(site.end); return Math.max(1, diffDays(sd, ed) + 1); } }catch(e){} return Math.max(1, Number(site?.duration || 1)); } /* 追加:表示範囲に掛かるか判定 */ function isSiteVisible(site, base){ try{ if(!site?.start) return false; const s = toDate(site.start); const x0 = diffDays(base, s); const w0 = calcDurationDays(site); const leftCut = Math.max(0, x0); const rightCut = Math.min(DAYS, x0 + w0); return (rightCut - leftCut) > 0; }catch(e){ return false; } } function buildDaysEl(base){ const daysEl = document.createElement("div"); daysEl.className = "days"; for(let i=0;i<DAYS;i++){ const d = addDays(base, i); const cls = dayClass(d); const cell = document.createElement("div"); cell.className = "day" + (isMajorDay(i, base) ? " major" : "") + (cls ? (" " + cls) : ""); if(i === 0 || d.getDate() === 1){ const m = document.createElement("div"); m.className = "m"; m.textContent = monthJa(d); cell.appendChild(m); } const dn = document.createElement("div"); dn.className = "dnum"; dn.textContent = dayNum(d); cell.appendChild(dn); const dow = document.createElement("div"); dow.className = "dow"; dow.textContent = dowJa(d); cell.appendChild(dow); daysEl.appendChild(cell); } const endCap = document.createElement("div"); endCap.style.width = "1px"; endCap.style.borderLeft = "1px solid var(--grid)"; daysEl.appendChild(endCap); return daysEl; } function render(){ const startStr = document.getElementById("startDate").value; const base = toDate(startStr); /* ★重要:CSS変数(--day-w/--row-h)は body から読む(paper切替が body に付くため) */ const cs = getComputedStyle(document.body); const dayW = parseFloat(cs.getPropertyValue("--day-w")); const rowH = parseFloat(cs.getPropertyValue("--row-h")); const gridW = DAYS * dayW; const daysHost = document.getElementById("days"); const left = document.getElementById("left"); const right = document.getElementById("right"); daysHost.innerHTML = ""; left.innerHTML = ""; right.innerHTML = ""; daysHost.appendChild(buildDaysEl(base)); const custIdx = getSortedCustomerIndices(); custIdx.forEach((bi) => { const block = DATA[bi]; const siteIdx0 = getSortedSiteIndices(block); const siteIdx = siteIdx0.filter(si => { const site = (block.sites || [])[si]; return isSiteVisible(site, base); }); if(siteIdx.length === 0) return; const packed = packLanes(block, base, siteIdx); const lanesUsed = Math.max(1, packed.lanesNeeded); const blockH = lanesUsed * rowH; const sec = document.createElement("div"); sec.className = "section"; sec.style.height = blockH + "px"; const sw = document.createElement("div"); sw.style.width = "28px"; sw.style.height = "28px"; sw.style.borderRadius = "6px"; sw.style.background = block.color || "#2563eb"; sw.style.boxShadow = "0 1px 3px rgba(0,0,0,.15)"; const name = document.createElement("div"); name.className = "name"; name.textContent = block.customer; sec.appendChild(sw); sec.appendChild(name); left.appendChild(sec); const gridWrap = document.createElement("div"); gridWrap.style.width = gridW + "px"; gridWrap.style.height = blockH + "px"; gridWrap.style.position = "relative"; right.appendChild(gridWrap); const laneRows = []; for(let li=0; li<lanesUsed; li++){ const row = document.createElement("div"); row.className = "gridRow"; row.style.width = gridW + "px"; const cells = document.createElement("div"); cells.className = "cells"; for(let i=0;i<DAYS;i++){ const c = document.createElement("div"); c.className = "cell" + (isMajorDay(i, base) ? " major" : ""); cells.appendChild(c); } row.appendChild(cells); gridWrap.appendChild(row); laneRows.push(row); } const laneBySi = new Map(packed.order.map(x => [x.si, x.lane])); siteIdx.forEach((si) => { const site = (block.sites || [])[si]; if(!site) return; const laneIndex = laneBySi.get(si) ?? 0; const row = laneRows[Math.min(laneIndex, laneRows.length - 1)]; const s = toDate(site.start); const x0 = diffDays(base, s); const w0 = calcDurationDays(site); const leftCut = Math.max(0, x0); const rightCut = Math.min(DAYS, x0 + w0); const visibleDays = rightCut - leftCut; if (visibleDays <= 0) return; const bar = document.createElement("div"); bar.className="bar"; bar.style.left = (leftCut * dayW) + "px"; bar.style.width = (visibleDays * dayW) + "px"; bar.style.background = block.color || "#2563eb"; /* ====================================================== ★構造は変えず、バー内文字だけ差し替え workは必ず表示。check==1 の時だけ comment を追加表示。 ====================================================== */ bar.textContent = ""; const spName = document.createElement("span"); spName.className = "barName"; spName.textContent = String(site.name ?? ""); bar.appendChild(spName); if (Number(site.check) === 1) { const txt = String(site.comment ?? "").trim(); if (txt !== "") { bar.appendChild(document.createTextNode("\u00A0")); // ★スペース1個 const spCom = document.createElement("span"); spCom.className = "barComment"; spCom.textContent = txt; bar.appendChild(spCom); } } bar.addEventListener("click", (ev)=>{ ev.stopPropagation(); fmCall("Gantt_OpenTask", { taskId: site.taskId }); }); row.appendChild(bar); }); }); const topHead = document.getElementById("topHead"); topHead.scrollLeft = right.scrollLeft; } function buildPrintPages(){ const root = document.getElementById("printRoot"); root.innerHTML = ""; const startStr = document.getElementById("startDate").value; const base = toDate(startStr); const paper = document.getElementById("paperSel").value; const cs = getComputedStyle(document.body); const rowH = parseFloat(cs.getPropertyValue("--row-h")); const dayW = parseFloat(cs.getPropertyValue("--day-w")); const gridW = DAYS * dayW; const pageLimit = (paper === "A3") ? 1050 : 650; const makePage = (pageNo) => { const page = document.createElement("div"); page.className = "pPage"; const pWrap = document.createElement("div"); page.appendChild(pWrap); const ph = document.createElement("div"); ph.className = "pHead"; ph.innerHTML = ` <div> 工程表(${DAYS}日) <small>開始日 ${startStr} / page ${pageNo}</small> </div> <div style="font-weight:900; color:#111827;">PDF</div> `; pWrap.appendChild(ph); const g = document.createElement("div"); g.className = "gantt"; pWrap.appendChild(g); const lh = document.createElement("div"); lh.className = "leftHead"; lh.textContent = "task"; g.appendChild(lh); const th = document.createElement("div"); th.className = "topHead"; th.appendChild(buildDaysEl(base)); g.appendChild(th); const left = document.createElement("div"); left.className = "left"; g.appendChild(left); const right = document.createElement("div"); right.className = "right"; g.appendChild(right); return { page, left, right, gridW, rowH, dayW }; }; let pageNo = 1; let cur = makePage(pageNo); root.appendChild(cur.page); let usedH = 0; const custIdx = getSortedCustomerIndices(); for(const bi of custIdx){ const block = DATA[bi]; const siteIdx0 = getSortedSiteIndices(block); const siteIdx = siteIdx0.filter(si => { const site = (block.sites || [])[si]; return isSiteVisible(site, base); }); if(siteIdx.length === 0) continue; const packed = packLanes(block, base, siteIdx); const lanesUsed = Math.max(1, packed.lanesNeeded); const blockH = lanesUsed * cur.rowH; if(usedH > 0 && usedH + blockH > pageLimit){ pageNo++; cur = makePage(pageNo); root.appendChild(cur.page); usedH = 0; } usedH += blockH; const sec = document.createElement("div"); sec.className = "section"; sec.style.height = blockH + "px"; const sw = document.createElement("div"); sw.style.width = "28px"; sw.style.height = "28px"; sw.style.borderRadius = "6px"; sw.style.background = block.color || "#2563eb"; sw.style.boxShadow = "0 1px 3px rgba(0,0,0,.15)"; const nm = document.createElement("div"); nm.className = "name"; nm.textContent = block.customer; sec.appendChild(sw); sec.appendChild(nm); cur.left.appendChild(sec); const gridWrap = document.createElement("div"); gridWrap.style.width = cur.gridW + "px"; gridWrap.style.height = blockH + "px"; gridWrap.style.position = "relative"; cur.right.appendChild(gridWrap); const laneRows = []; for(let li=0; li<lanesUsed; li++){ const row = document.createElement("div"); row.className = "gridRow"; row.style.width = cur.gridW + "px"; const cells = document.createElement("div"); cells.className = "cells"; for(let i=0;i<DAYS;i++){ const c = document.createElement("div"); c.className = "cell" + (isMajorDay(i, base) ? " major" : ""); cells.appendChild(c); } row.appendChild(cells); gridWrap.appendChild(row); laneRows.push(row); } const laneBySi = new Map(packed.order.map(x => [x.si, x.lane])); siteIdx.forEach((si)=>{ const site = (block.sites || [])[si]; if(!site) return; const laneIndex = laneBySi.get(si) ?? 0; const row = laneRows[Math.min(laneIndex, laneRows.length - 1)]; const s = toDate(site.start); const x0 = diffDays(base, s); const w0 = calcDurationDays(site); const leftCut = Math.max(0, x0); const rightCut = Math.min(DAYS, x0 + w0); const visibleDays = rightCut - leftCut; if (visibleDays <= 0) return; const bar = document.createElement("div"); bar.className = "bar"; bar.style.left = (leftCut * cur.dayW) + "px"; bar.style.width = (visibleDays * cur.dayW) + "px"; bar.style.background = block.color || "#2563eb"; /* ★印刷側も同じ(work+check==1でcomment追加) */ bar.textContent = ""; const spName = document.createElement("span"); spName.className = "barName"; spName.textContent = String(site.name ?? ""); bar.appendChild(spName); if (Number(site.check) === 1) { const txt = String(site.comment ?? "").trim(); if (txt !== "") { bar.appendChild(document.createTextNode("\u00A0")); // ★スペース1個 const spCom = document.createElement("span"); spCom.className = "barComment"; spCom.textContent = txt; bar.appendChild(spCom); } } row.appendChild(bar); }); } } const originalTitle = document.title; function nowStamp(){ const d = new Date(); const pad2 = n => String(n).padStart(2,"0"); return `${d.getFullYear()}${pad2(d.getMonth()+1)}${pad2(d.getDate())}${pad2(d.getHours())}${pad2(d.getMinutes())}`; } window.addEventListener("beforeprint", ()=>{ document.title = `工程表${nowStamp()}`; }); window.addEventListener("afterprint", ()=>{ document.title = originalTitle; document.body.dataset.printing = "0"; document.getElementById("printRoot").innerHTML = ""; }); document.getElementById("pdfBtn").addEventListener("click", ()=>{ const paper = document.getElementById("paperSel").value; document.body.dataset.paper = paper; /* ★paper切替直後のCSS再計算待ち(ズレ防止) */ requestAnimationFrame(() => { render(); buildPrintPages(); document.body.dataset.printing = "1"; window.print(); setTimeout(()=>{ delete document.body.dataset.paper; render(); }, 0); }); }); document.getElementById("apply").addEventListener("click", render); document.getElementById("custSortSel").addEventListener("change", render); document.getElementById("siteSortSel").addEventListener("change", render); document.getElementById("resetData").addEventListener("click", ()=>{ const ok = confirm("表示を空に戻します(FMデータは変えません)。よろしいですか?"); if(!ok) return; DATA = []; DAYS = 65; render(); }); const topHead = document.getElementById("topHead"); const right = document.getElementById("right"); right.addEventListener("scroll", ()=> topHead.scrollLeft = right.scrollLeft); topHead.addEventListener("scroll", ()=> right.scrollLeft = topHead.scrollLeft); (function applyFmInit(){ try{ if(!FM_INIT || typeof FM_INIT !== "object") { render(); return; } if(FM_INIT.baseStart){ document.getElementById("startDate").value = FM_INIT.baseStart; } if(Number.isFinite(FM_INIT.days)){ DAYS = Number(FM_INIT.days); } if(Array.isArray(FM_INIT.customers)){ DATA = FM_INIT.customers; } }catch(e){ console.error(e); } render(); })(); </script> </body> </html>
----------------------------------
0 件のコメント:
コメントを投稿