import React, { useState, useMemo, useRef, useEffect } from 'react';
import { Target, Plus, Minus, FileUp, Trash2, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, CheckCircle2, Crosshair, UploadCloud, X, List } from 'lucide-react';
// --- Firebase SDKのインポート ---
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from 'firebase/auth';
import { getFirestore, collection, doc, setDoc, deleteDoc, onSnapshot } from 'firebase/firestore';
// --- Firebase初期化 ---
let app, auth, db;
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
try {
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : {};
app = initializeApp(firebaseConfig);
auth = getAuth(app);
db = getFirestore(app);
} catch (error) {
console.error("Firebase initialization error:", error);
}
// デバウンス関数(Firestoreへの過剰な書き込みを防ぐ)
const debounce = (func, wait) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
};
const App = () => {
// iOS (PWA) 用のメタタグを動的に追加
useEffect(() => {
const metaTags = [
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
{ name: 'apple-mobile-web-app-title', content: '建方キング' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no' }
];
metaTags.forEach(tag => {
let existingTag = document.querySelector(`meta[name="${tag.name}"]`);
if (!existingTag) {
const meta = document.createElement('meta');
meta.name = tag.name;
meta.content = tag.content;
document.head.appendChild(meta);
} else {
existingTag.content = tag.content;
}
});
}, []);
// --- State管理 ---
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [projects, setProjects] = useState([]);
const [activeProjectId, setActiveProjectId] = useState(null);
const [projectToDelete, setProjectToDelete] = useState(null);
// UI系State
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [importText, setImportText] = useState("");
const [isEditingProjectName, setIsEditingProjectName] = useState(false);
const [editingName, setEditingName] = useState("");
const fileInputRef = useRef(null);
// --- 認証 (Auth Before Queries) ---
useEffect(() => {
if (!auth) return;
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("Authentication failed:", error);
setIsLoading(false);
}
};
initAuth();
const unsubscribe = onAuthStateChanged(auth, (usr) => {
setUser(usr);
setIsLoading(false);
});
return () => unsubscribe();
}, []);
// --- Firestoreからのデータ購読 ---
useEffect(() => {
if (!user || !db) return;
// RULE 1: 厳密なパスを使用
const projectsRef = collection(db, 'artifacts', appId, 'users', user.uid, 'projects');
const unsubscribe = onSnapshot(projectsRef, (snapshot) => {
const loadedProjects = snapshot.docs.map(doc => doc.data());
// RULE 2: メモリ上でソート(Firestoreの複雑なクエリを避ける)
loadedProjects.sort((a, b) => b.updatedAt - a.updatedAt);
setProjects(loadedProjects);
}, (error) => {
console.error("Firestore Snapshot Error:", error);
});
return () => unsubscribe();
}, [user]);
// --- Firestore 保存ロジック (Debounce) ---
const saveToFirestore = useRef(
debounce((uid, projId, data) => {
if (!db) return;
const projectRef = doc(db, 'artifacts', appId, 'users', uid, 'projects', projId);
setDoc(projectRef, data).catch(e => console.error("Save Error:", e));
}, 800) // 0.8秒間操作がなければクラウドに保存
).current;
// 現在選択されているプロジェクトを取得
const activeProject = useMemo(() => projects.find(p => p.id === activeProjectId), [projects, activeProjectId]);
// プロジェクトのデータを更新する共通関数
const updateProjectData = (updates) => {
if (!activeProjectId || !user) return;
const currentProject = projects.find(p => p.id === activeProjectId);
if (!currentProject) return;
const updatedProject = { ...currentProject, ...updates, updatedAt: Date.now() };
// 1. ローカルStateを即座に更新 (UIにすぐ反映させる)
setProjects(prev => prev.map(p => p.id === activeProjectId ? updatedProject : p));
// 2. バックグラウンドでクラウドへ保存
saveToFirestore(user.uid, activeProjectId, updatedProject);
};
// --- プロジェクト操作 ---
const createProject = async () => {
if (!user || !db) return;
const newId = Date.now().toString();
const newProject = {
id: newId,
name: `新規現場 ${new Date().toLocaleDateString()}`,
updatedAt: Date.now(),
columns: [{ id: 1, name: '1節-A1', designX: 100.0000, designY: 200.0000, measuredX: 100.0000, measuredY: 200.0000 }],
activeColumnId: 1,
xAxisIs: 'N',
tolerance: 5.0000,
perfectThreshold: 1.0000,
stepUnit: 0.001
};
// 新規作成時はすぐにFirestoreに保存
await setDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'projects', newId), newProject);
setActiveProjectId(newId);
};
const confirmDeleteProject = async (id) => {
if (!user || !db) return;
// UI上から先に消す
setProjects(prev => prev.filter(p => p.id !== id));
if (activeProjectId === id) setActiveProjectId(null);
// クラウド上から削除
await deleteDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'projects', id));
setProjectToDelete(null);
};
// --- アクティブ現場のデータ取得と操作 ---
const columns = activeProject?.columns || [];
const activeColumnId = activeProject?.activeColumnId || (columns[0]?.id);
const activeColumn = useMemo(() => columns.find(c => c.id === activeColumnId) || columns[0], [columns, activeColumnId]);
const xAxisIs = activeProject?.xAxisIs || 'N';
const tolerance = activeProject?.tolerance || 5.0;
const perfectThreshold = activeProject?.perfectThreshold || 1.0;
const stepUnit = activeProject?.stepUnit || 0.001;
const setXAxisIs = (val) => updateProjectData({ xAxisIs: val });
const setStepUnit = (val) => updateProjectData({ stepUnit: val });
const setActiveColumnId = (id) => updateProjectData({ activeColumnId: id });
// 実測値入力更新
const updateActiveColumn = (field, value) => {
if (!activeColumn) return;
const val = value === "" ? "" : parseFloat(value);
const newColumns = columns.map(c => c.id === activeColumn.id ? { ...c, [field]: val } : c);
updateProjectData({ columns: newColumns });
};
// +/- ボタンでの調整
const adjustValue = (field, delta) => {
if (!activeColumn) return;
const currentVal = parseFloat(activeColumn[field]) || 0;
const newVal = (Math.round(currentVal * 10000) + Math.round(delta * 10000)) / 10000;
updateActiveColumn(field, newVal);
};
// --- 現場名の編集 ---
const startEditName = () => {
if (!activeProject) return;
setEditingName(activeProject.name);
setIsEditingProjectName(true);
};
const commitEditName = () => {
if (editingName.trim()) {
updateProjectData({ name: editingName.trim() });
}
setIsEditingProjectName(false);
};
// --- モニター計算 ---
const stats = useMemo(() => {
if (!activeColumn) return null;
// 空文字の場合は0として計算しクラッシュ回避
const mX = parseFloat(activeColumn.measuredX) || 0;
const dX = parseFloat(activeColumn.designX) || 0;
const mY = parseFloat(activeColumn.measuredY) || 0;
const dY = parseFloat(activeColumn.designY) || 0;
const dx = (mX - dX) * 1000;
const dy = (mY - dY) * 1000;
let labels = { top: '', bottom: '', right: '', left: '' };
let fixTextX = ""; let fixTextY = "";
if (xAxisIs === 'N') {
labels = { top: '北 (X+)', bottom: '南 (X-)', right: '東 (Y+)', left: '西 (Y-)' };
fixTextX = -dx > 0 ? "北" : "南"; fixTextY = -dy > 0 ? "東" : "西";
} else if (xAxisIs === 'S') {
labels = { top: '南 (X+)', bottom: '北 (X-)', right: '西 (Y+)', left: '東 (Y-)' };
fixTextX = -dx > 0 ? "南" : "北"; fixTextY = -dy > 0 ? "西" : "東";
} else if (xAxisIs === 'E') {
labels = { top: '東 (X+)', bottom: '西 (X-)', right: '南 (Y+)', left: '北 (Y-)' };
fixTextX = -dx > 0 ? "東" : "西"; fixTextY = -dy > 0 ? "南" : "北";
} else if (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) <= tolerance && Math.abs(dy) <= tolerance,
isPerfect: Math.abs(dx) <= perfectThreshold && Math.abs(dy) <= perfectThreshold
};
}, [activeColumn, xAxisIs, tolerance, perfectThreshold]);
const formatWithSign = (val, dec = 1) => (val > 0 ? "+" : (val < 0 ? "" : "+")) + val.toFixed(dec);
// --- インポート/エクスポート ---
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 || `柱-${columns.length + index + 1}`, designX: x, designY: y, measuredX: x, measuredY: y });
}
});
if (newPillars.length > 0) {
updateProjectData({
columns: [...columns, ...newPillars],
activeColumnId: newPillars[0].id
});
setImportText(""); setIsImportModalOpen(false);
}
};
const exportToSIMA = () => {
let content = "Z00,*** SIMA測量データ By HO_CADpao ***,\r\n";
content += `G00, 01,無題0(Pao標準テンプレート)(Zone03),\r\n`;
content += "Z00,----- 座標データ -----\r\n";
content += "A00,\r\n";
columns.forEach((col, idx) => {
const mX = parseFloat(col.measuredX) || 0;
const mY = parseFloat(col.measuredY) || 0;
content += `A01, ${(idx + 1).toString().padStart(4, ' ')}, ${col.name}, ${mX.toFixed(4)}, ${mY.toFixed(4)}, 0.000,\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 = `${activeProject?.name || '建方実測値'}_${new Date().getTime()}.sim`;
a.click();
URL.revokeObjectURL(url);
};
// --- ロード中の画面 ---
if (isLoading) {
return (
);
}
// --- 【画面1】現場一覧画面 ---
if (!activeProjectId) {
return (
{projects.length === 0 ? (
保存された現場はありません
右上のボタンから新しく作成してください
) : (
projects.map(p => (
setActiveProjectId(p.id)}>
{p.name}
最終保存: {new Date(p.updatedAt).toLocaleString([], {year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit'})}
))
)}
{/* 削除確認モーダル */}
{projectToDelete && (
現場を削除しますか?
この現場のデータは完全に消去され、
元に戻すことはできません。
)}
);
}
// --- 【画面2】現場詳細 (計測) 画面 ---
return (
{/* ヘッダー */}
{isEditingProjectName ? (
setEditingName(e.target.value)} className="flex-1 bg-slate-800 text-white px-4 py-2 rounded-xl outline-none font-black text-base border-2 border-blue-500" />
) : (
{activeProject?.name}
最終保存: {new Date(activeProject?.updatedAt).toLocaleTimeString()}
)}
{/* タイトル中央揃えのためのダミー */}
現場のX軸プラス(上)の方角を指定
{['N','S','E','W'].map(d => {
const labels = { 'N':'北','S':'南','E':'東','W':'西' };
return (
);
})}
{/* モニター */}
{activeColumn?.name || "---"}
{stats?.labels.top}
{stats?.labels.bottom}
{stats?.labels.right}
{stats?.labels.left}
{stats && (
)}
X軸ズレ (上下方向)
tolerance ? 'text-red-500' : 'text-blue-600'}`}>{formatWithSign(stats?.dx || 0, 1)}mm
Y軸ズレ (左右方向)
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)
北方向 X軸入力設計: {(parseFloat(activeColumn?.designX) || 0).toFixed(4)}
東方向 Y軸入力設計: {(parseFloat(activeColumn?.designY) || 0).toFixed(4)}
{/* 精度管理ログ */}
精度管理ログ
| 点名 | 設計座標(m) | ズレ(mm) | |
{columns.map(col => {
const mX = parseFloat(col.measuredX) || 0;
const dX = parseFloat(col.designX) || 0;
const mY = parseFloat(col.measuredY) || 0;
const dY = parseFloat(col.designY) || 0;
const dx = (mX - dX) * 1000;
const dy = (mY - dY) * 1000;
const isP = Math.abs(dx) <= perfectThreshold && Math.abs(dy) <= perfectThreshold;
return (
setActiveColumnId(col.id)} className={`transition-all ${activeColumnId === col.id ? 'active-site-card' : ''}`}>
| {col.name} |
X:{dX.toFixed(4)}
Y:{dY.toFixed(4)}
|
tolerance ? 'text-red-500' : (isP ? 'text-green-600' : 'text-blue-600')}`}>
X:{formatWithSign(dx, 1)}
tolerance ? 'text-red-500' : (isP ? 'text-green-600' : 'text-blue-600')}`}>
Y:{formatWithSign(dy, 1)}
|
|
);
})}
{/* SIMAインポートモーダル */}
{isImportModalOpen && (
SIMAデータ読込
)}
);
};
export default App;