```react
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { Target, Plus, Minus, FileUp, Trash2, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, CheckCircle2, Crosshair, CloudUpload, X, List, ChevronLeft, FolderGit2 } from 'lucide-react';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
import { getFirestore, collection, doc, setDoc, onSnapshot, deleteDoc } from 'firebase/firestore';
// --- Firebase Initialization ---
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : {};
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
export default function App() {
const [user, setUser] = useState(null);
const [sites, setSites] = useState([]);
const [view, setView] = useState('list'); // 'list' | 'detail'
const [currentSiteId, setCurrentSiteId] = useState(null);
// PWA Meta tags injection
useEffect(() => {
const addMeta = (name, content) => {
let meta = document.querySelector(`meta[name="${name}"]`);
if (!meta) {
meta = document.createElement('meta');
meta.name = name;
document.head.appendChild(meta);
}
meta.content = content;
};
addMeta("apple-mobile-web-app-capable", "yes");
addMeta("apple-mobile-web-app-status-bar-style", "black-translucent");
addMeta("viewport", "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no");
// Disable scrollbar globally
const style = document.createElement('style');
style.innerHTML = `
body { -webkit-tap-highlight-color: transparent; background-color: #f1f5f9; }
.no-scrollbar::-webkit-scrollbar { display: none; }
input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { opacity: 1; }
.font-mono-precision { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
`;
document.head.appendChild(style);
}, []);
// Authentication
useEffect(() => {
const initAuth = async () => {
try {
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
await signInWithCustomToken(auth, __initial_auth_token);
} else {
await signInAnonymously(auth);
}
} catch (error) {
console.error("Auth Error:", error);
}
};
initAuth();
const unsubscribe = onAuthStateChanged(auth, setUser);
return () => unsubscribe();
}, []);
// Fetch Sites Data
useEffect(() => {
if (!user) return;
const sitesRef = collection(db, 'artifacts', appId, 'users', user.uid, 'sites');
const unsubscribe = onSnapshot(sitesRef, (snapshot) => {
const loadedSites = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
// JS側で降順ソート (Firebaseのインデックス制約を回避)
loadedSites.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
setSites(loadedSites);
}, (error) => console.error("Fetch Error:", error));
return () => unsubscribe();
}, [user]);
const createNewSite = async () => {
if (!user) return;
const newSite = {
id: `site_${Date.now()}`,
name: `新規現場 ${new Date().toLocaleDateString()}`,
createdAt: Date.now(),
xAxisIs: 'N',
tolerance: 5.0,
perfectThreshold: 1.0,
activeColumnId: 1,
columns: [
{ id: 1, name: '1節-A1', designX: 100.0000, designY: 200.0000, measuredX: 100.0000, measuredY: 200.0000 }
]
};
try {
await setDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'sites', newSite.id), newSite);
setCurrentSiteId(newSite.id);
setView('detail');
} catch (e) { console.error("Create Error:", e); }
};
const deleteSite = async (siteId, e) => {
e.stopPropagation();
if (!user) return;
try {
await deleteDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'sites', siteId));
} catch (e) { console.error("Delete Error:", e); }
};
if (!user) {
return
認証中...
;
}
if (view === 'list') {
return (
保存済みデータ
{sites.length === 0 && (
データがありません
)}
{sites.map(site => (
{ setCurrentSiteId(site.id); setView('detail'); }}
className="bg-white p-5 rounded-[2rem] shadow-sm border border-slate-200 flex items-center justify-between active:bg-slate-50 cursor-pointer group transition-all">
{site.name}
{new Date(site.createdAt).toLocaleString()}更新 • {site.columns?.length || 0}点
))}
);
}
const currentSite = sites.find(s => s.id === currentSiteId);
if (!currentSite) {
setView('list');
return null;
}
return (
setView('list')}
/>
);
}
// --- Detail View Component ---
function SiteDetail({ site, user, onBack }) {
// ローカルステートで編集状態を管理し、変更があれば自動保存する
const [localSite, setLocalSite] = useState(site);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [importText, setImportText] = useState("");
const [stepUnit, setStepUnit] = useState(0.001);
const fileInputRef = useRef(null);
const saveTimeoutRef = useRef(null);
// 親のデータが変更されたらローカルも同期(他のデバイスでの変更検知用)
useEffect(() => {
setLocalSite(site);
}, [site.id]);
// ローカルステートが変更されたらFirestoreへ自動保存(Debounce)
const updateSite = useCallback((newSiteData) => {
setLocalSite(newSiteData);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(async () => {
try {
await setDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'sites', newSiteData.id), newSiteData);
} catch (e) { console.error("Auto-Save Error:", e); }
}, 500); // 500msの遅延で自動保存
}, [user.uid]);
const activeColumn = useMemo(() => localSite.columns.find(c => c.id === localSite.activeColumnId) || localSite.columns[0], [localSite.columns, localSite.activeColumnId]);
const stats = useMemo(() => {
if (!activeColumn) return null;
const dx = (activeColumn.measuredX - activeColumn.designX) * 1000;
const dy = (activeColumn.measuredY - activeColumn.designY) * 1000;
let labels = { top: '', bottom: '', right: '', left: '' };
let fixTextX = ""; let fixTextY = "";
if (localSite.xAxisIs === 'N') {
labels = { top: '北 (X+)', bottom: '南 (X-)', right: '東 (Y+)', left: '西 (Y-)' };
fixTextX = -dx > 0 ? "北" : "南"; fixTextY = -dy > 0 ? "東" : "西";
} else if (localSite.xAxisIs === 'S') {
labels = { top: '南 (X+)', bottom: '北 (X-)', right: '西 (Y+)', left: '東 (Y-)' };
fixTextX = -dx > 0 ? "南" : "北"; fixTextY = -dy > 0 ? "西" : "東";
} else if (localSite.xAxisIs === 'E') {
labels = { top: '東 (X+)', bottom: '西 (X-)', right: '南 (Y+)', left: '北 (Y-)' };
fixTextX = -dx > 0 ? "東" : "西"; fixTextY = -dy > 0 ? "南" : "北";
} else if (localSite.xAxisIs === 'W') {
labels = { top: '西 (X+)', bottom: '東 (X-)', right: '北 (Y+)', left: '南 (Y-)' };
fixTextX = -dx > 0 ? "西" : "東"; fixTextY = -dy > 0 ? "北" : "南";
}
return {
dx, dy, fixX: -dx, fixY: -dy,
fixTextX, fixTextY, labels,
isOk: Math.abs(dx) <= localSite.tolerance && Math.abs(dy) <= localSite.tolerance,
isPerfect: Math.abs(dx) <= localSite.perfectThreshold && Math.abs(dy) <= localSite.perfectThreshold
};
}, [activeColumn, localSite.xAxisIs, localSite.tolerance, localSite.perfectThreshold]);
const formatWithSign = (val, dec = 1) => (val > 0 ? "+" : (val < 0 ? "" : "+")) + val.toFixed(dec);
const handleSiteNameChange = (e) => {
updateSite({ ...localSite, name: e.target.value });
};
const setXAxisIs = (axis) => {
updateSite({ ...localSite, xAxisIs: axis });
};
const setActiveId = (id) => {
updateSite({ ...localSite, activeColumnId: id });
};
const updateActiveColumn = (field, value) => {
const val = value === "" ? 0 : parseFloat(value);
const newColumns = localSite.columns.map(c => c.id === (localSite.activeColumnId || localSite.columns[0].id) ? { ...c, [field]: val } : c);
updateSite({ ...localSite, columns: newColumns });
};
const adjustValue = (field, delta) => {
const target = activeColumn;
if (!target) return;
const newVal = (Math.round(target[field] * 10000) + Math.round(delta * 10000)) / 10000;
updateActiveColumn(field, newVal);
};
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => setImportText(event.target.result);
reader.readAsText(file, 'Shift-JIS'); // 日本語対応
}
};
const handleImport = () => {
const lines = importText.split('\n').filter(l => l.trim() !== "");
const newPillars = [];
lines.forEach((line, index) => {
const parts = line.split(',').map(p => p.trim());
let name, x, y;
if (parts[0] === 'A01' && parts.length >= 5) {
name = parts[2]; x = parseFloat(parts[3]); y = parseFloat(parts[4]);
} else {
const spaceParts = line.split(/[\t, ]+/).map(p => p.trim());
if (spaceParts.length >= 3) { name = spaceParts[0]; x = parseFloat(spaceParts[1]); y = parseFloat(spaceParts[2]); }
}
if (!isNaN(x) && !isNaN(y)) {
newPillars.push({ id: Date.now() + index, name: name || `柱-${localSite.columns.length + index + 1}`, designX: x, designY: y, measuredX: x, measuredY: y });
}
});
if (newPillars.length > 0) {
updateSite({ ...localSite, columns: [...localSite.columns, ...newPillars], activeColumnId: newPillars[0].id });
setImportText(""); setIsImportModalOpen(false);
}
};
const addColumn = () => {
const newCol = { id: Date.now(), name: `柱-${localSite.columns.length+1}`, designX: 0, designY: 0, measuredX: 0, measuredY: 0 };
updateSite({ ...localSite, columns: [...localSite.columns, newCol], activeColumnId: newCol.id });
};
const deleteColumn = (id) => {
if (localSite.columns.length <= 1) return;
const filtered = localSite.columns.filter(c => c.id !== id);
updateSite({ ...localSite, columns: filtered, activeColumnId: (localSite.activeColumnId === id ? filtered[0].id : localSite.activeColumnId) });
};
const exportToSIMA = () => {
let content = "Z00,*** 建方キング Pro エクスポート ***,\r\n";
content += `G00, 01,建方実測データ,\r\n`;
content += "Z00,----- 座標データ -----\r\n";
content += "A00,\r\n";
localSite.columns.forEach((col, idx) => {
content += `A01,${(idx + 1).toString().padStart(5, ' ')}, ${col.name}, ${col.measuredX.toFixed(4)}, ${col.measuredY.toFixed(4)}, 0.0000,\r\n`;
});
content += "A99,\r\n";
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${localSite.name}_実測値_${new Date().getTime()}.sim`;
a.click();
URL.revokeObjectURL(url);
};
return (
{/* ヘッダー */}
現場のX軸プラス(上)の方角を指定
{['N','S','E','W'].map(d => {
const labels = { 'N':'北','S':'南','E':'東','W':'西' };
return (
);
})}
{/* モニター */}
updateActiveColumn('name', e.target.value)}
className="text-2xl font-black text-slate-800 italic tracking-tighter truncate w-full text-center outline-none bg-slate-50 rounded-xl py-1 focus:ring-2 focus:ring-blue-400"
/>
{/* ラベル回転 */}
{stats?.labels.top}
{stats?.labels.bottom}
{stats?.labels.right}
{stats?.labels.left}
{stats && (
)}
X軸ズレ (上下方向)
localSite.tolerance ? 'text-red-500' : 'text-blue-600'}`}>{formatWithSign(stats?.dx || 0, 1)}mm
Y軸ズレ (左右方向)
localSite.tolerance ? 'text-red-500' : 'text-blue-600'}`}>{formatWithSign(stats?.dy || 0, 1)}mm
{/* 指示パネル */}
{stats?.isPerfect ? '完全合格' : (stats?.isOk ? '精度内 (ゼロへ追い込み中)' : '建ち直し指示が必要です')}
{stats?.isPerfect ? (
固定OK
) : (
{Math.abs(stats?.fixX || 0) > 0.0001 && (
{stats?.fixTextX}へ
{Math.abs(stats?.fixX || 0).toFixed(1)}mm
)}
{Math.abs(stats?.fixY || 0) > 0.0001 && (
{stats?.fixTextY}へ
{Math.abs(stats?.fixY || 0).toFixed(1)}mm
)}
)}
{/* 実測入力 */}
実測座標入力 (m)
{/* 精度管理ログ */}
精度管理ログ
| 点名 | 設計座標(m) | ズレ(mm) | |
{localSite.columns.map(col => {
const dx = (col.measuredX - col.designX) * 1000;
const dy = (col.measuredY - col.designY) * 1000;
const isP = Math.abs(dx) <= localSite.perfectThreshold && Math.abs(dy) <= localSite.perfectThreshold;
const isActive = localSite.activeColumnId === col.id;
return (
setActiveId(col.id)} className={`transition-all cursor-pointer ${isActive ? 'border-l-[12px] border-l-blue-600 bg-blue-50' : 'hover:bg-slate-50'}`}>
| {col.name} |
X:{col.designX.toFixed(4)}
Y:{col.designY.toFixed(4)}
|
localSite.tolerance ? 'text-red-500' : (isP ? 'text-green-600' : 'text-blue-600')}`}>
X:{formatWithSign(dx, 1)}
localSite.tolerance ? 'text-red-500' : (isP ? 'text-green-600' : 'text-blue-600')}`}>
Y:{formatWithSign(dy, 1)}
|
|
);
})}
{/* SIMAインポートモーダル */}
{isImportModalOpen && (
SIMAデータ読込
)}
);
}
```