Merge pull request 'api, 확대축소' (#741) from dev into dev-deploy

Reviewed-on: #741
This commit is contained in:
ysCha 2026-03-31 10:55:57 +09:00
commit 9f7e0ede08
6 changed files with 219 additions and 40 deletions

View File

@ -7,7 +7,10 @@ import { atom, useRecoilState, useRecoilValue } from 'recoil'
// API 요청을 저장할 모듈 레벨 변수 (Hook 외부)
let roofMaterialPromise = null;
// 전역 Promise 캐시
const roofMaterialPromiseCache = new Map()
let cacheKey = 'default'
/**
* 마스터 컨트롤러
* @returns
@ -22,24 +25,117 @@ export function useMasterController() {
* 지붕재 목록 조회
* @returns
*/
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const getRoofMaterialList = async () => {
// 1. 이미 진행 중이거나 완료된 Promise가 있으면 그것을 반환
if (roofMaterialPromise) {
return roofMaterialPromise;
if (roofMaterialPromiseCache.has(cacheKey)) {
console.log('🔄 getRoofMaterialList: Returning cached promise')
return roofMaterialPromiseCache.get(cacheKey)
}
// 2. 처음 호출될 때 Promise를 생성하여 변수에 할당
roofMaterialPromise = get({ url: '/api/v1/master/getRoofMaterialList' })
.then((res) => {
return res;
})
.catch((error) => {
// 에러 발생 시 다음 호출을 위해 초기화
roofMaterialPromise = null;
throw error;
});
console.log('🚀 getRoofMaterialList: Creating new promise')
return roofMaterialPromise;
const MAX_RETRY = 15 // 15번으로 증가 (일본 지역 대응)
const RETRY_DELAY = 2000 // 2초 간격
const MAX_BACKOFF = 10000 // 최대 10초까지 지수 백오프
const fetchWithRetry = async () => {
let lastError = null
let retryCount = 0
for (let attempt = 0; attempt < MAX_RETRY; attempt++) {
try {
// 지수 백오프 적용: 2초, 4초, 8초, ... 최대 10초
const backoffDelay = Math.min(RETRY_DELAY * Math.pow(2, attempt), MAX_BACKOFF)
if (attempt > 0) {
console.log(`⏳ Waiting ${backoffDelay/1000}s before retry ${attempt + 1}/${MAX_RETRY}`)
await delay(backoffDelay)
}
console.log(`🌐 API Request ${attempt + 1}/${MAX_RETRY}: /api/v1/master/getRoofMaterialList`)
const res = await get({ url: '/api/v1/master/getRoofMaterialList' })
console.log('✅ getRoofMaterialList: Success on attempt', attempt + 1)
// 재시도 중이었으면 성공 메시지 표시
if (retryCount > 0) {
swalFire({
icon: 'success',
title: getMessage('server.restored.title'),
text: `${getMessage('server.restored.text')} (${retryCount}${getMessage('server.retries')})`,
timer: 3000, // 메시지 표시 시간 증가
showConfirmButton: false
})
}
return res
} catch (error) {
lastError = error
retryCount++
// 500 계열만 기다렸다가 재시도
const status = error?.response?.status
if (status && status >= 500) {
console.log(`⚠️ getRoofMaterialList: 500 error on attempt ${attempt + 1}, retrying...`)
// 첫 번째 500 에러에만 메시지 표시
if (attempt === 0) {
swalFire({
icon: 'warning',
title: getMessage('server.error.title'),
text: getMessage('server.error.text'),
timer: 4000, // 경고 메시지 시간 증가
showConfirmButton: false
})
}
continue
}
// 네트워크 에러(ECONNRESET, ETIMEDOUT 등)도 재시도
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
console.log(`🌐 Network error on attempt ${attempt + 1}:`, error.code)
if (attempt === 0) {
swalFire({
icon: 'warning',
title: getMessage('network.error.title'),
text: getMessage('network.error.text'),
timer: 4000,
showConfirmButton: false
})
}
continue
}
// 500이나 네트워크 에러가 아니면 즉시 실패
console.log(`❌ getRoofMaterialList: Non-retryable error on attempt ${attempt + 1}:`, error)
throw error
}
}
// 모든 재시도 실패 시
console.log('💥 getRoofMaterialList: All retries failed')
swalFire({
icon: 'error',
title: getMessage('server.failed.title'),
text: `${getMessage('server.failed.text')} (${MAX_RETRY}${getMessage('server.retries')})`,
confirmButtonText: getMessage('common.ok')
})
throw lastError
}
const promise = fetchWithRetry()
.finally(() => {
// 성공/실패 모두 끝나면 캐시에서 제거
console.log('🏁 getRoofMaterialList: Promise resolved, removing from cache')
roofMaterialPromiseCache.delete(cacheKey)
})
// 캐시에 Promise 저장
roofMaterialPromiseCache.set(cacheKey, promise)
return promise
}
/**

View File

@ -153,12 +153,12 @@ export function useCanvasSetting(executeEffect = true) {
return
}
/** 초 1회만 실행하도록 처리 */
addRoofMaterials()
// Roof materials are now initialized in Header.jsx to avoid duplicate calls
}, [executeEffect])
/**
* 지붕재 초기세팅
* 지붕재 초기세팅 (fallback - primary initialization is in Header.jsx)
* This function is only called when roof materials are needed but not yet loaded
*/
const addRoofMaterials = async () => {
if (roofMaterials.length !== 0) {
@ -320,12 +320,26 @@ export function useCanvasSetting(executeEffect = true) {
// objectNo가 전달되면 사용, 아니면 Recoil state의 correntObjectNo 사용
const targetObjectNo = objectNo || correntObjectNo
// 지붕재 데이터가 없으면 먼저 로드
// 지붕재 데이터가 없으면 Header.jsx에서 로드될 때까지 기다림
let materials = roofMaterials
if (!materials || materials.length === 0) {
logger.log('Waiting for roofMaterials to be loaded...')
materials = await addRoofMaterials()
logger.log('roofMaterials loaded:', materials)
logger.log('Waiting for roofMaterials to be loaded from Header.jsx...')
// 최대 2초간 기다림 (20번 × 100ms)
let waitCount = 0
while ((!materials || materials.length === 0) && waitCount < 20) {
await new Promise(resolve => setTimeout(resolve, 100))
materials = roofMaterials
waitCount++
}
if (!materials || materials.length === 0) {
logger.log('roofMaterials still not loaded after 2 seconds, proceeding without them')
// 비상시에만 addRoofMaterials 호출 (fallback)
materials = await addRoofMaterials()
logger.log('roofMaterials loaded via fallback:', materials)
} else {
logger.log('roofMaterials loaded from Header.jsx:', materials)
}
}
try {

View File

@ -86,15 +86,21 @@ export function useAxios(lang = '') {
.get(url, option)
.then((res) => res.data)
.catch((error) => {
console.error('🚨 GET Error:', {
url,
method: 'GET',
timestamp: new Date().toISOString(),
errorMessage: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
config: error.config
})
// 500 에러는 재시도되므로 로깅을 줄임
const status = error?.response?.status
if (status === 500) {
console.warn('🔄 Server Error (500): Will retry...', url)
} else {
console.error('🚨 GET Error:', {
url,
method: 'GET',
timestamp: new Date().toISOString(),
errorMessage: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
config: error.config
})
}
throw error
})
}

View File

@ -15,6 +15,7 @@ export function useCanvasEvent() {
const [currentObject, setCurrentObject] = useRecoilState(currentObjectState)
const canvasSize = useRecoilValue(canvasSizeState)
const [canvasZoom, setCanvasZoom] = useRecoilState(canvasZoomState)
const [shouldCenter, setShouldCenter] = useState(false) // 중앙 정렬 플래그 추가
const lengthTextOption = useRecoilValue(fontSelector('lengthText'))
const currentMenu = useRecoilValue(currentMenuState)
const { changeCorridorDimensionText } = useText()
@ -23,6 +24,17 @@ export function useCanvasEvent() {
useEffect(() => {
canvas?.setZoom(canvasZoom / 100)
}, [canvasZoom])
// canvasZoom이 100으로 변경되고 shouldCenter가 true일 때만 중앙 정렬 실행
useEffect(() => {
if (canvasZoom === 100 && canvas && shouldCenter) {
setTimeout(() => {
zoomToAllObjects()
canvas.renderAll()
setShouldCenter(false) // 실행 후 플래그 초기화
}, 100)
}
}, [canvasZoom, shouldCenter])
useEffect(() => {
attachDefaultEventOnCanvas()
@ -416,15 +428,35 @@ export function useCanvasEvent() {
}
const handleZoomClear = () => {
setShouldCenter(true) // 중앙 정렬 플래그 설정
setCanvasZoom(100)
zoomToAllObjects()
canvas.renderAll()
}
const zoomToAllObjects = () => {
const objects = canvas.getObjects().filter((obj) => obj.visible)
if (objects.length === 0) return
const allObjects = canvas.getObjects()
// 주요 도형 객체만 필터링 (roof, arrow 등)
const objects = allObjects.filter((obj) => {
// 주요 도형 객체만 포함
return obj.name === 'roof' ||
obj.name === 'arrow' ||
obj.name === 'module' ||
obj.name === 'dormer' ||
obj.name === 'object'
})
console.log('🔍 All objects:', allObjects.length)
console.log('🔍 Main objects for centering:', objects.length)
console.log('🔍 Main object details:', objects.map(obj => ({
name: obj.name,
type: obj.type,
visible: obj.visible,
id: obj.id
})))
if (objects.length === 0) {
console.log('❌ No main objects found for centering')
return
}
let minX = Infinity,
minY = Infinity
@ -439,11 +471,24 @@ export function useCanvasEvent() {
maxY = Math.max(maxY, bounds.top + bounds.height)
})
const centerX = (minX + maxX) / 2
const centerY = (minY + maxY) / 2
const centerPoint = new fabric.Point(centerX, centerY)
canvas.zoomToPoint(centerPoint, 1)
const objectsCenterX = (minX + maxX) / 2
const objectsCenterY = (minY + maxY) / 2
// 화면 중앙 좌표 (메뉴 높이 고려하여 Y 위치 조정)
const screenCenterX = canvas.getWidth() / 2
const menuHeightOffset = 80 // 위쪽 메뉴 높이만큼 위로 조정
const screenCenterY = canvas.getHeight() / 2 - menuHeightOffset
// 객체 중심을 화면 중앙으로 이동
const deltaX = screenCenterX - objectsCenterX
const deltaY = screenCenterY - objectsCenterY
// canvas viewport transform 조정
const viewportTransform = canvas.viewportTransform
viewportTransform[4] += deltaX
viewportTransform[5] += deltaY
canvas.setViewportTransform(viewportTransform)
canvas.renderAll()
}

View File

@ -567,6 +567,15 @@
"common.finish": "完了",
"common.ok": "確認",
"common.cancel": "キャンセル",
"server.error.title": "サーバーエラー",
"server.error.text": "サーバーで一時的な問題が発生しました。自動的に再試行します...",
"server.restored.title": "サーバー接続復旧",
"server.restored.text": "サーバー接続が復旧しました",
"server.failed.title": "サーバー接続失敗",
"server.failed.text": "サーバー接続に失敗しました。しばらくしてから再度お試しください",
"server.retries": "回再試行",
"network.error.title": "ネットワーク接続不安定",
"network.error.text": "インターネット接続が不安定です。自動的に再試行します...",
"commons.west": "西",
"commons.east": "東",
"commons.south": "南",

View File

@ -567,6 +567,15 @@
"common.finish": "완료",
"common.ok": "확인",
"common.cancel": "취소",
"server.error.title": "서버 오류",
"server.error.text": "서버에 일시적인 문제가 발생했습니다. 자동으로 재시도합니다...",
"server.restored.title": "서버 연결 복구",
"server.restored.text": "서버 연결이 복구되었습니다",
"server.failed.title": "서버 연결 실패",
"server.failed.text": "서버 연결에 실패했습니다. 잠시 후 다시 시도해주세요",
"server.retries": "회 재시도",
"network.error.title": "네트워크 연결 불안",
"network.error.text": "인터넷 연결이 불안정합니다. 자동으로 재시도합니다...",
"commons.west": "서",
"commons.east": "동",
"commons.south": "남",