diff --git a/diary/static/diary/css/detail.css b/diary/static/diary/css/detail.css index b1ca107..25fad0a 100644 --- a/diary/static/diary/css/detail.css +++ b/diary/static/diary/css/detail.css @@ -37,7 +37,7 @@ 첫 번째 숫자를 키우면: → 콘텐츠가 아래로 내려갑니다. */ - padding: 100px 0 100px; + padding: 20px 0 20px; /* width 안에 padding과 border까지 포함하도록 합니다. diff --git a/diary/static/diary/css/diary.css b/diary/static/diary/css/diary.css index ab0ad27..32e8ebf 100644 --- a/diary/static/diary/css/diary.css +++ b/diary/static/diary/css/diary.css @@ -3,22 +3,13 @@ ========================================================= */ .diary-list-container { - /* - 페이지 콘텐츠의 최대 너비입니다. - - 화면이 1280px 이상이면 최대 1200px, - 그보다 좁으면 좌우에 각각 40px 여백을 남깁니다. - */ + /* 페이지 콘텐츠 최대 너비 */ width: min(1200px, calc(100% - 80px)); - /* - 컨테이너를 화면 가로 중앙에 배치합니다. - */ + /* 화면 가로 중앙 배치 */ margin: 0 auto; - /* - 위쪽 28px, 아래쪽 50px의 내부 여백입니다. - */ + /* 위아래 내부 여백 */ padding: 28px 0 50px; } @@ -27,27 +18,40 @@ 2. 전체 / 감정별 / 장소별 탭 ========================================================= */ -.diary-tabs-wrapper { +.diary-tabs-row { /* - 탭 묶음을 가로 중앙에 배치합니다. + 탭과 점 세 개 버튼을 같은 줄에 배치 + 탭은 화면 중앙 유지 + 점 세 개 버튼의 위치 기준 역할 */ + position: relative; + display: flex; + align-items: center; justify-content: center; width: 100%; - /* - 탭과 아래 콘텐츠 사이의 간격입니다. - */ + /* 탭과 콘텐츠 사이 간격 */ margin-bottom: 12px; } -.diary-tabs { +.diary-tabs-wrapper { /* - 각 탭 버튼을 한 줄로 배치합니다. - 내용만큼만 너비를 차지합니다. + 탭 자체만 중앙 배치 + width: 100% 제거하여 오른쪽 버튼이 탭 위치에 영향 주지 않도록 함 */ + display: flex; + justify-content: center; + + width: auto; + margin-bottom: 0; +} + + +.diary-tabs { + /* 탭 한 줄 배치 */ display: inline-flex; align-items: center; gap: 4px; @@ -64,9 +68,7 @@ .diary-tab { - /* - a 태그 또는 버튼으로 만들어진 각각의 탭입니다. - */ + /* 각 탭 기본 스타일 */ padding: 10px 18px; border: 0; @@ -83,9 +85,7 @@ .diary-tab.active { - /* - 현재 선택된 탭입니다. - */ + /* 현재 선택된 탭 */ background: #fff4cb; color: #222222; @@ -98,50 +98,31 @@ ========================================================= */ .diary-list-header { - /* - 전체 기록 제목, 기록 개수, 필터를 - 왼쪽부터 가로 한 줄로 배치합니다. - - 화면: - 전체 기록 7개 최신순 기간 설정 - */ + /* 제목과 필터 가로 배치 */ display: flex; align-items: center; justify-content: flex-start; - /* - 제목·개수 묶음과 필터 사이의 간격입니다. - - 값을 키우면 필터가 제목에서 더 멀어집니다. - */ + /* 제목과 필터 사이 간격 */ gap: 18px; - /* - 헤더와 카드 목록 사이의 간격입니다. - */ + /* 카드 목록과의 간격 */ margin-bottom: 24px; } .diary-list-heading { - /* - '전체 기록'과 '7개'를 같은 줄에 배치합니다. - */ + /* 제목과 기록 개수 한 줄 배치 */ display: flex; align-items: center; gap: 10px; - /* - 남는 공간을 불필요하게 차지하지 않습니다. - */ flex: 0 0 auto; } .diary-list-title { - /* - h1 태그의 브라우저 기본 여백을 제거합니다. - */ + /* h1 기본 여백 제거 */ margin: 0; color: #222222; @@ -152,10 +133,19 @@ } +.diary-list-description { + /* 제목 아래 설명 문구 */ + margin: 8px 0 0; + + color: #8b857d; + + font-size: 14px; + line-height: 1.5; +} + + .diary-list-count { - /* - 기록 개수를 작은 캡슐 모양으로 표시합니다. - */ + /* 기록 개수 캡슐 */ padding: 5px 10px; border-radius: 999px; @@ -167,9 +157,6 @@ font-weight: 700; line-height: 1; - /* - '7'과 '개'가 서로 다른 줄로 나뉘지 않게 합니다. - */ white-space: nowrap; } @@ -180,31 +167,21 @@ .diary-filter-form, .diary-list-controls { - /* - 최신순 선택창과 기간 설정 버튼을 - 같은 줄에 배치합니다. - */ + /* 필터 버튼 가로 배치 */ display: flex; align-items: center; gap: 8px; - /* - 기간 설정 팝업의 위치 기준이 됩니다. - */ + /* 기간 설정창 위치 기준 */ position: relative; - /* - 필터 영역이 남은 가로 공간 전체를 차지하지 않습니다. - */ flex: 0 0 auto; } .diary-sort-select, .period-toggle-button { - /* - 최신순 선택창과 기간 설정 버튼의 공통 스타일입니다. - */ + /* 필터 공통 스타일 */ min-height: 42px; padding: 0 16px; @@ -214,9 +191,6 @@ background: #ffffff; color: #333333; - /* - 페이지에서 사용하는 글꼴을 그대로 상속합니다. - */ font: inherit; cursor: pointer; @@ -226,10 +200,6 @@ .diary-sort-select:focus, .period-toggle-button:focus, .period-filter input:focus { - /* - 키보드나 마우스로 요소를 선택했을 때 - 노란색 외곽선을 표시합니다. - */ /* outline: 2px solid #e9c767; outline-offset: 2px; @@ -238,9 +208,7 @@ .period-toggle-button.active { - /* - 기간 설정 창이 열려 있을 때의 버튼입니다. - */ + /* 기간 설정 활성 상태 */ border-color: #e1bd55; background: #fff7d9; @@ -250,57 +218,22 @@ /* ========================================================= - 기간 설정 팝업 + 5. 기간 설정 팝업 ========================================================= */ .period-filter { - /* - 최신순 선택창과 기간 설정 버튼을 감싸는 - .diary-filter-form을 기준으로 배치됩니다. - - 기존에는 필터 아래쪽에 표시되었지만, - 이제는 필터의 오른쪽에 나란히 표시합니다. - */ + /* 필터 폼 기준 절대 배치 */ position: absolute; z-index: 50; - /* - top: 0 - → 최신순·기간 설정 버튼과 날짜 선택창의 - 위쪽 높이를 똑같이 맞춥니다. - - 값을 키우면 날짜 선택창이 아래로 내려가고, - 음수로 바꾸면 위로 올라갑니다. - */ top: 0; - - /* - left: calc(100% + 16px) - → 필터 폼의 오른쪽 끝에서 16px 떨어진 위치에 - 날짜 선택창을 표시합니다. - - 16px을 크게 하면 기간 설정 버튼과 더 멀어지고, - 작게 하면 버튼과 더 가까워집니다. - */ left: calc(100% + 16px); - - /* - 기존의 right: 0을 해제합니다. - - right: 0이 남아 있으면 - left와 right가 동시에 적용되어 - 너비나 위치가 예상과 다르게 계산될 수 있습니다. - */ right: auto; display: flex; align-items: flex-end; gap: 10px; - /* - 시작일·종료일·버튼이 들어갈 최소 너비입니다. - 현재 화면에서는 약 500px 정도가 적당합니다. - */ min-width: 500px; padding: 18px; @@ -315,18 +248,13 @@ .period-filter[hidden] { - /* - hidden 속성이 있을 때 완전히 숨깁니다. - */ + /* hidden 상태 제거 */ display: none; } .period-input-group { - /* - 시작일 또는 종료일의 label과 input을 - 위아래로 배치합니다. - */ + /* 라벨과 날짜 입력창 세로 배치 */ display: flex; flex-direction: column; gap: 7px; @@ -354,9 +282,7 @@ .period-separator { - /* - 시작일과 종료일 사이의 물결표입니다. - */ + /* 시작일과 종료일 구분 */ padding-bottom: 10px; color: #999999; @@ -365,9 +291,7 @@ .period-apply-button, .period-reset-button { - /* - 적용 버튼과 초기화 버튼의 공통 스타일입니다. - */ + /* 적용 / 초기화 공통 스타일 */ display: inline-flex; align-items: center; justify-content: center; @@ -405,9 +329,7 @@ ========================================================= */ .diary-grid { - /* - 기본 화면에서 한 줄에 카드 5개를 배치합니다. - */ + /* 한 줄에 카드 5개 배치 */ display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 22px; @@ -417,9 +339,6 @@ .diary-card { - /* - 카드가 grid 칸보다 커지는 것을 방지합니다. - */ min-width: 0; overflow: hidden; @@ -440,9 +359,7 @@ .diary-card:hover { - /* - 마우스를 올리면 카드가 살짝 위로 이동합니다. - */ + /* hover 시 카드 이동 */ transform: translateY(-5px); box-shadow: @@ -452,9 +369,7 @@ .diary-card-link { - /* - 카드 전체 영역을 클릭할 수 있게 합니다. - */ + /* 카드 전체 클릭 영역 */ display: block; height: 100%; @@ -469,16 +384,11 @@ ========================================================= */ .diary-card-image-wrapper { - /* - 카드 이미지 영역입니다. - */ position: relative; width: 100%; - /* - 이미지 영역의 가로:세로 비율을 4:3으로 유지합니다. - */ + /* 이미지 4:3 비율 유지 */ aspect-ratio: 4 / 3; overflow: hidden; @@ -493,10 +403,7 @@ width: 100%; height: 100%; - /* - 사진이 이미지 영역을 빈틈없이 채우도록 합니다. - 넘치는 부분은 잘립니다. - */ + /* 이미지 영역 채우기 */ object-fit: cover; transition: transform 0.3s ease; @@ -504,17 +411,13 @@ .diary-card:hover .diary-card-image { - /* - 카드에 마우스를 올리면 사진이 살짝 확대됩니다. - */ + /* hover 시 이미지 확대 */ transform: scale(1.04); } .diary-card-image-placeholder { - /* - 사진이 없을 때 보이는 대체 영역입니다. - */ + /* 이미지 없음 영역 */ display: flex; align-items: center; justify-content: center; @@ -534,9 +437,7 @@ ========================================================= */ .diary-card-content { - /* - 날짜, 기록 내용, 장소 줄을 위아래로 배치합니다. - */ + /* 날짜 / 내용 / 장소 세로 배치 */ display: flex; flex-direction: column; @@ -556,9 +457,7 @@ .diary-card-title { - /* - 기록 내용을 최대 두 줄까지만 보여줍니다. - */ + /* 기록 내용 최대 두 줄 제한 */ display: -webkit-box; min-height: 44px; @@ -584,35 +483,20 @@ ========================================================= */ .diary-card-bottom-row { - /* - 장소는 왼쪽, - 감정 아이콘은 오른쪽에 배치합니다. - */ + /* 장소 왼쪽 / 감정 오른쪽 */ display: flex; align-items: center; justify-content: space-between; - /* - 장소 이름과 감정 아이콘 사이의 최소 간격입니다. - */ gap: 12px; - /* - 제목과 장소 줄 사이의 간격입니다. - */ margin-top: 12px; } .diary-card-location { - /* - 장소 영역이 남은 공간을 사용합니다. - */ flex: 1; - /* - flex 자식의 글자가 정상적으로 줄어들 수 있게 합니다. - */ min-width: 0; margin: 0; @@ -630,23 +514,14 @@ .diary-card-location--empty { - /* - 장소 정보가 없을 때 문구를 더 연하게 표시합니다. - */ + /* 장소 없음 스타일 */ color: #aaa397; } .diary-card-emotion { - /* - 기존의 absolute 배치를 해제하고, - 장소 오른쪽에서 일반적인 요소로 배치합니다. - */ position: static; - /* - 장소 이름이 길어져도 아이콘이 작아지지 않게 합니다. - */ flex-shrink: 0; display: flex; @@ -670,9 +545,7 @@ ========================================================= */ .diary-empty { - /* - 빈 화면 안내가 grid 전체 열을 차지하게 합니다. - */ + /* grid 전체 사용 */ grid-column: 1 / -1; padding: 80px 20px; @@ -736,286 +609,540 @@ ========================================================= */ .emotion-page-header { - /* - 왼쪽의 제목과 오른쪽의 이번 달 감정 요약을 - 양쪽에 나누어 배치합니다. - */ + /* 감정별 제목 영역 */ display: flex; align-items: flex-start; justify-content: space-between; - gap: 30px; - margin-bottom: 30px; + margin-bottom: 12px; } -.emotion-month-summary { +.emotion-page-heading { + /* 제목과 설명 세로 배치 */ display: flex; - align-items: center; - gap: 10px; + flex-direction: column; +} - padding: 12px 18px; - border: 1px solid #eadfce; - border-radius: 14px; +/* ========================================================= + 12. 감정 도넛 그래프 전체 영역 + ========================================================= */ - background: #ffffff; +.emotion-chart-section { + display: flex; + align-items: center; + justify-content: center; - font-size: 13px; + width: 100%; + + min-height: 360px; } -.emotion-month-summary strong { - color: #d98d36; +/* ========================================================= + 13. 감정 도넛 그래프 wrapper + ========================================================= */ - font-size: 18px; -} +.emotion-chart-wrapper { + position: relative; + /* 참고 이미지에 가까운 크기 */ + width: 360px; + max-width: 100%; -.top-emotion-list { - /* - 공동 1위 감정이 여러 개일 경우 - 한 줄 또는 여러 줄로 자연스럽게 표시 - */ - display: flex; - align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - gap: 4px; + aspect-ratio: 1 / 1; } -.top-emotion-name { - color: #d98d36; - white-space: nowrap; +/* ========================================================= + 14. SVG 감정 도넛 그래프 + ========================================================= */ + +.emotion-chart { + display: block; + + width: 100%; + height: 100%; + + overflow: visible; } -.top-emotion-separator { - color: #b7aa97; +/* ========================================================= + 15. 도넛 그래프 배경 + ========================================================= */ + +.emotion-chart-background { + /* 도넛 뒤 기본 원 */ + fill: none; + + stroke: #f2eee7; + stroke-width: 52; + + pointer-events: none; } -.emotion-bubble-area { - /* - 감정 조약돌들의 absolute 배치 기준입니다. - */ - position: relative; +/* 그래프 안쪽 원 */ - width: 100%; - min-height: 560px; +.emotion-chart-inner-circle { + fill: #fffdfa; - border-radius: 24px; + stroke: #eee5da; + stroke-width: 2; + + pointer-events: none; - /*background: #ffffff;*/ + filter: + drop-shadow( + 0 2px 4px rgba(80, 65, 50, 0.08) + ); } -.emotion-bubble { - position: absolute; +/* ========================================================= + 16. 도넛 그래프 조각 + ========================================================= */ - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; +.emotion-chart-segment { + fill: none; + + /* 참고 이미지와 비슷한 두꺼운 띠 */ + stroke-width: 52; /* - 기존 테두리를 제거 + 끝을 완전한 반원으로 만들지 않음 + + butt + → 참고 이미지처럼 조각 경계가 깔끔함 */ - border: none; + stroke-linecap: butt; /* - 조약돌 모양은 그대로 유지 + 그래프 시작 위치를 + 12시 방향으로 이동 */ - border-radius: 42% 58% 53% 47% / 50% 50% 50% 50%; - - background: rgba(255, 250, 231, 0.95); - color: #333333; + transform: rotate(-90deg); - /* - 테두리 대신 기본 상태에서도 그림자를 표시합니다. + transform-origin: 160px 160px; - 0 → 좌우 이동 없음 - 5px → 아래로 5px 이동 - 14px → 그림자 흐림 정도 - rgba → 검정색, 투명도 12% - */ - box-shadow: 0 5px 14px rgba(0, 0, 0, 0.12); + pointer-events: stroke; - font: inherit; cursor: pointer; + filter: + drop-shadow( + 0 2px 3px rgba(74, 58, 45, 0.06) + ); + transition: - transform 0.2s ease, - box-shadow 0.2s ease; + stroke-width 0.18s ease, + filter 0.18s ease, + opacity 0.18s ease; } -.emotion-bubble:hover { - z-index: 5; +/* 감정 색상 */ - transform: scale(1.08); - - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12); +.emotion-chart-segment--yellow { + stroke: #f9dda0; } -.emotion-bubble-name { - font-size: 20px; - font-weight: 700; +.emotion-chart-segment--blue { + stroke: #bcdcf7; } -.emotion-bubble-count { - color: #8e877c; - - font-size: 15px; +.emotion-chart-segment--pink { + stroke: #f6c9cf; } -/* 감정별 색상 */ +.emotion-chart-segment--purple { + stroke: #cfc2f2; +} -.emotion-bubble--yellow { - /*border-color: #f9d472;*/ - background: #fff7e3; +.emotion-chart-segment--red { + stroke: #f2b6b2; } -.emotion-bubble--blue { - /*border-color: #dae6f3;*/ +.emotion-chart-segment--orange { + stroke: #f7c995; +} + - background: #eff7ff; +.emotion-chart-segment--default { + stroke: #dedede; } -.emotion-bubble--pink { - /*border-color: #f8b3b4;*/ +/* hover 강조 */ - background: #fff2f2; +.emotion-chart-segment:hover { + stroke-width: 57; + + filter: + brightness(1.02) + drop-shadow( + 0 4px 5px rgba(65, 50, 40, 0.11) + ); } -.emotion-bubble--purple { - /*border-color: #9d72ce;*/ +/* ========================================================= + 17. 감정 그래프 중앙 기록 수 + ========================================================= */ + +.emotion-chart-center { + position: absolute; - background: #f8f1ff; -} + top: 50%; + left: 50%; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 9px; -.emotion-bubble--red { - /*border-color: #ce7772;*/ + transform: translate(-50%, -50%); - background: #fff4f1; + pointer-events: none; } -.emotion-bubble--orange { - /*border-color: #cea972;*/ +.emotion-chart-center-label { + color: #776f69; - background: #fff9f1; + font-size: 15px; + font-weight: 600; } -.emotion-bubble--default { - /*border-color: #c5c5c5;*/ +.emotion-chart-center-count { + color: #8b71d1; + + font-size: 34px; + font-weight: 800; - background: #dedede; + line-height: 1; } -/* 기록 개수에 따른 감정 조약돌 크기 */ +/* ========================================================= + 18. 감정 그래프 툴팁 + ========================================================= */ -.emotion-bubble--large { - width: 130px; - height: 130px; -} +.emotion-chart-tooltip { + /* + 그래프 wrapper 기준 배치 + */ + position: absolute; + z-index: 20; + /* + JS에서 마우스 위치에 맞춰 + left / top 값 변경 + */ + left: 0; + top: 0; -.emotion-bubble--medium { - width: 92px; - height: 92px; -} + /* + 툴팁이 마우스 정중앙에 위치하도록 조정 + */ + transform: translate(-50%, -100%); + padding: 9px 13px; -.emotion-bubble--small { - width: 64px; - height: 64px; -} + border: 1px solid #e2ddd4; + border-radius: 10px; + background: rgba(255, 255, 255, 0.96); + color: #37332e; -/* 감정 조약돌의 임시 배치 위치 */ + font-size: 13px; + font-weight: 600; + line-height: 1.3; -.emotion-bubble:nth-child(1) { - top: 30px; - left: 9%; -} + white-space: nowrap; + pointer-events: none; -.emotion-bubble:nth-child(2) { - top: 70px; - left: 25%; + box-shadow: + 0 7px 20px rgba(0, 0, 0, 0.1); } -.emotion-bubble:nth-child(3) { - top: 160px; - left: 15%; +.emotion-chart-tooltip[hidden] { + /* 툴팁 숨김 */ + display: none; } -.emotion-bubble:nth-child(4) { - top: 110px; - left: 44%; -} +/* ========================================================= + 19. 감정 기록 없음 + ========================================================= */ +.emotion-chart-empty { + /* 그래프 아래 빈 기록 안내 */ + margin-top: 12px; -.emotion-bubble:nth-child(5) { - top: 260px; - left: 34%; + text-align: center; } -.emotion-bubble:nth-child(6) { - top: 80px; - left: 68%; -} +.emotion-chart-empty-title { + margin: 0 0 7px; + color: #403c36; -.emotion-bubble:nth-child(7) { - top: 210px; - left: 75%; + font-size: 16px; + font-weight: 700; } -.emotion-bubble:nth-child(8) { - top: 340px; - left: 60%; -} +.emotion-chart-empty-description { + margin: 0; + color: #918a81; -.emotion-bubble:nth-child(9) { - top: 390px; - left: 20%; + font-size: 13px; } +.emotion-summary-board { + width: 100%; -.emotion-bubble:nth-child(10) { - top: 370px; - left: 80%; -} + padding: + 30px + 28px + 26px; + border: 1px solid #eee7dd; + border-radius: 28px; -/* ========================================================= - 12. 감정별 기록 팝업 - ========================================================= */ + background: + rgba(255, 253, 248, 0.82); + + box-shadow: + 0 8px 24px rgba(69, 52, 38, 0.06); +} + +.emotion-card-guide { + display: flex; + align-items: center; + justify-content: center; + + gap: 8px; + + margin: + -2px + 0 + 20px; + + color: #9a9188; + + font-size: 12px; +} + + +.emotion-card-guide-line { + width: 45px; + + border-top: + 2px dotted #ddd1ee; +} + + +.emotion-card-guide-heart { + color: #bda8e1; + + font-size: 10px; +} + +/* ========================================================= + 감정별 기록 카드 목록 + ========================================================= */ + +.emotion-record-card-list { + display: flex; + align-items: stretch; + justify-content: center; + flex-wrap: wrap; + + gap: 14px; + + width: 100%; +} + + +/* 감정 카드 */ + +.emotion-record-card { + display: flex; + align-items: center; + + gap: 10px; + + min-width: 150px; + + padding: + 10px + 14px; + + border: 1px solid #eee7dd; + border-radius: 17px; + + background: #ffffff; + + font: inherit; + + cursor: pointer; + + box-shadow: + 0 4px 10px rgba(70, 55, 40, 0.06); + + transition: + transform 0.18s ease, + box-shadow 0.18s ease; +} + + +.emotion-record-card:hover { + transform: translateY(-3px); + + box-shadow: + 0 8px 16px rgba(70, 55, 40, 0.1); +} + + +/* 감정 이미지 */ + +.emotion-record-card-image { + display: flex; + align-items: center; + justify-content: center; + + width: 48px; + height: 48px; + + flex-shrink: 0; +} + + +.emotion-record-card-image img { + display: block; + + width: 44px; + height: 44px; + + object-fit: contain; +} + + +/* 감정 정보 */ + +.emotion-record-card-info { + display: flex; + flex-direction: column; + align-items: flex-start; + + gap: 4px; +} + + +.emotion-record-card-name { + color: #403b36; + + font-size: 14px; + font-weight: 700; +} + + +.emotion-record-card-count { + font-size: 13px; + font-weight: 700; +} + + +/* 카드별 포인트 색상 */ + +.emotion-record-card--blue { + background: #f5faff; +} + + +.emotion-record-card--blue +.emotion-record-card-count { + color: #67a7df; +} + + +.emotion-record-card--pink { + background: #fff7f8; +} + + +.emotion-record-card--pink +.emotion-record-card-count { + color: #e58ea0; +} + + +.emotion-record-card--yellow { + background: #fffdf5; +} + + +.emotion-record-card--yellow +.emotion-record-card-count { + color: #d6aa36; +} + + +.emotion-record-card--purple { + background: #faf7ff; +} + + +.emotion-record-card--purple +.emotion-record-card-count { + color: #9276cf; +} + + +.emotion-record-card--red { + background: #fff7f5; +} + + +.emotion-record-card--red +.emotion-record-card-count { + color: #db827b; +} + + +.emotion-record-card--orange { + background: #fff9f3; +} + + +.emotion-record-card--orange +.emotion-record-card-count { + color: #d99c59; +} + +/* ========================================================= + 20. 감정별 기록 팝업 + ========================================================= */ body.modal-open { - /* - 팝업이 열렸을 때 뒤쪽 페이지가 스크롤되지 않게 합니다. - */ + /* 팝업 뒤 페이지 스크롤 차단 */ overflow: hidden; } @@ -1120,42 +1247,48 @@ body.modal-open { /* ========================================================= - 13. 장소별 기록 화면 및 지도 설정 + 21. 장소별 기록 화면 및 지도 설정 ========================================================= */ .location-map-section { - /* - 지도 설정 툴바와 지도를 위아래로 배치합니다. - */ + /* 지도만 세로 배치 */ display: flex; flex-direction: column; - gap: 10px; width: 100%; } +/* + 기존 지도 위 별도 툴바는 사용하지 않음 + list.html에서 .map-toolbar 영역을 제거한 상태 기준 +*/ .map-toolbar { - /* - 점 세 개 버튼을 오른쪽 끝에 배치합니다. - */ - display: flex; - align-items: center; - justify-content: flex-end; - - width: 100%; + display: none; } .map-display-settings { - /* - 설정 메뉴가 점 세 개 버튼을 기준으로 표시되게 합니다. - */ + /* 설정 메뉴 위치 기준 */ position: relative; z-index: 30; } +/* + 탭 행 안에 있는 지도 설정 버튼 영역 + 탭은 가운데 유지하고 설정 버튼만 오른쪽 끝에 고정 +*/ +.diary-tabs-row > .map-display-settings { + position: absolute; + + top: 50%; + right: 0; + + transform: translateY(-50%); +} + + .map-settings-button { display: flex; align-items: center; @@ -1188,8 +1321,6 @@ body.modal-open { .map-settings-button:hover { transform: translateY(-1px); - /*background: #fff8df;*/ - box-shadow: 0 5px 14px rgba(0, 0, 0, 0.12); } @@ -1201,9 +1332,7 @@ body.modal-open { .map-settings-menu { - /* - 점 세 개 버튼 바로 아래에 표시되는 설정 메뉴입니다. - */ + /* 점 세 개 버튼 아래 배치 */ position: absolute; z-index: 40; @@ -1269,18 +1398,18 @@ body.modal-open { /* ========================================================= - 14. 서울 지도 + 22. 서울 지도 ========================================================= */ .location-map-wrapper { - /* - 서울 지도 SVG의 크기와 가운데 정렬을 담당합니다. - */ + /* 지도 크기 및 중앙 정렬 */ position: relative; width: min(800px, 100%); margin: 0 auto; + + overflow: hidden; } @@ -1293,9 +1422,7 @@ body.modal-open { .seoul-map path { - /* - 모든 서울 구역 path의 기본 스타일입니다. - */ + /* 서울 구역 기본 스타일 */ fill: #ffffff; stroke: #777777; stroke-width: 1; @@ -1309,18 +1436,13 @@ body.modal-open { .seoul-map path:focus { - /* - SVG 구역을 클릭했을 때 - 기본 브라우저 외곽선을 제거합니다. - */ + /* SVG 기본 포커스 제거 */ outline: none; } .seoul-map path.has-record { - /* - 기록이 있는 구역에 적용됩니다. - */ + /* 기록 있는 구역 */ stroke-width: 2; cursor: pointer; @@ -1328,20 +1450,13 @@ body.modal-open { .seoul-map path.has-record:hover { - /* - 기록이 있는 구역에 마우스를 올렸을 때 - 살짝 어두워지고 테두리가 굵어집니다. - */ - /*stroke-width: 3;*/ - + /* 기록 있는 구역 hover */ filter: brightness(0.94); } .district-labels { - /* - 구 이름 텍스트가 지도 클릭을 방해하지 않게 합니다. - */ + /* 지도 라벨 클릭 방해 제거 */ pointer-events: none; } @@ -1358,16 +1473,12 @@ body.modal-open { text-anchor: middle; dominant-baseline: middle; - /* - 흰색 외곽선을 먼저 그리고 - 그 위에 글자를 표시합니다. - */ paint-order: stroke; } /* ========================================================= - 15. 장소별 기록 팝업 + 23. 장소별 기록 팝업 ========================================================= */ .location-modal { @@ -1470,115 +1581,544 @@ body.modal-open { /* ========================================================= - 16. 반응형 스타일 + 24. 반응형 스타일 + ========================================================= */ + + +/* ========================================================= + 1200px 이하 ========================================================= */ -/* - 화면 너비가 1200px 이하일 때 - 카드 개수를 한 줄에 4개로 줄입니다. -*/ @media (max-width: 1200px) { + + /* 전체 기록 카드 한 줄 4개 */ .diary-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } -/* - 화면 너비가 900px 이하일 때 - 카드와 팝업 목록을 한 줄에 3개로 줄입니다. -*/ +/* ========================================================= + 900px 이하 + ========================================================= */ + @media (max-width: 900px) { + + /* + 전체 기록 카드와 팝업 카드 + 한 줄에 3개 배치 + */ .diary-grid, .emotion-modal-grid, .location-modal-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + + + /* + 감정 도넛 그래프 크기 축소 + + 기존 360px보다 조금 작게 조정 + 화면이 줄어들기 시작하는 구간에서 + 그래프가 과하게 커 보이지 않도록 함 + */ + .emotion-chart-wrapper { + width: min(340px, 100%); + } + + + /* + 감정 카드가 너무 길게 한 줄로 늘어나는 것 방지 + + 데스크톱에서는 flex이지만 + 900px 이하부터 grid로 변경 + */ + .emotion-record-card-list { + display: grid; + + grid-template-columns: + repeat(3, minmax(0, 1fr)); + + gap: 12px; + } + + + /* + 기존 min-width: 150px 제거 + + grid 칸 너비에 맞춰 + 카드가 자연스럽게 줄어들도록 함 + */ + .emotion-record-card { + min-width: 0; + width: 100%; + } } -/* - 화면 너비가 760px 이하일 때 - 전체 기록 제목과 필터를 위아래로 배치합니다. -*/ +/* ========================================================= + 760px 이하 + ========================================================= */ + @media (max-width: 760px) { + + /* 페이지 좌우 여백 축소 */ .diary-list-container { - /* - 작은 화면에서는 좌우 여백을 각각 20px로 줄입니다. - */ width: calc(100% - 40px); } + + /* ----------------------------------------------------- + 상단 탭 / 지도 설정 버튼 + ----------------------------------------------------- */ + + .diary-tabs-row > .map-display-settings { + right: 0; + } + + + .diary-tabs-row .map-settings-button { + width: 38px; + height: 38px; + + font-size: 22px; + } + + + /* ----------------------------------------------------- + 전체 기록 헤더 + ----------------------------------------------------- */ + .diary-list-header { + /* + 제목과 필터를 + 세로 방향으로 변경 + */ align-items: flex-start; flex-direction: column; + gap: 14px; } + .diary-filter-form, .diary-list-controls { width: 100%; } + + /* ----------------------------------------------------- + 기간 설정 팝업 + ----------------------------------------------------- */ + .period-filter { /* - 작은 화면에서는 오른쪽에 표시할 공간이 부족하므로 - 다시 필터 버튼 아래쪽에 표시합니다. + 데스크톱에서는 오른쪽에 열리지만 + 작은 화면에서는 아래쪽에 표시 */ top: calc(100% + 10px); - /* - 필터 폼의 왼쪽에 날짜창을 맞춥니다. - */ left: 0; right: auto; + flex-wrap: wrap; + + min-width: min( + 500px, + calc(100vw - 40px) + ); + } + + + /* ----------------------------------------------------- + 감정별 화면 + ----------------------------------------------------- */ + + .emotion-page-header { /* - 날짜 입력창과 버튼이 공간이 부족할 때 - 다음 줄로 내려가도록 합니다. + 제목 영역 세로 배치 */ - flex-wrap: wrap; + flex-direction: column; + + gap: 10px; + } + + .emotion-summary-board { /* - 모바일 화면보다 날짜창이 커지지 않게 제한합니다. + 감정별 전체 보드의 + 좌우 여백 축소 */ - min-width: min(500px, calc(100vw - 40px)); + padding: + 24px + 18px + 22px; + + border-radius: 22px; + } + + + .emotion-chart-section { + /* + 기존 min-height: 450px 제거 + + 모바일에서 도넛 아래에 + 불필요한 빈 공간이 생기지 않도록 함 + */ + min-height: 300px; + } + + + .emotion-chart-wrapper { + /* + 화면 너비에 맞춰 + 도넛 그래프 자동 축소 + */ + width: min(300px, 78vw); + } + + + .emotion-chart-center-count { + font-size: 30px; + } + + + .emotion-chart-center-label { + font-size: 13px; + } + + + /* + 감정 카드 + 한 줄 2개 배치 + */ + .emotion-record-card-list { + display: grid; + + grid-template-columns: + repeat(2, minmax(0, 1fr)); + + gap: 12px; + + width: 100%; + } + + + .emotion-record-card { + /* + 기존 최소 너비 제거 + + 화면 크기에 따라 + 카드가 자연스럽게 축소됨 + */ + min-width: 0; + width: 100%; + + padding: + 10px + 12px; + } + + + .emotion-record-card-image { + width: 42px; + height: 42px; + } + + + .emotion-record-card-image img { + width: 38px; + height: 38px; + } + + + .emotion-card-guide { + margin-bottom: 16px; } } -/* - 화면 너비가 650px 이하일 때 - 카드 및 팝업 목록을 한 줄에 2개로 줄입니다. -*/ +/* ========================================================= + 650px 이하 + ========================================================= */ + @media (max-width: 650px) { + + /* ----------------------------------------------------- + 전체 기록 카드 + ----------------------------------------------------- */ + .diary-grid, .emotion-modal-grid, .location-modal-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); + /* + 전체 기록 및 팝업 카드는 + 한 줄 2개 + */ + grid-template-columns: + repeat(2, minmax(0, 1fr)); + gap: 14px; } + .diary-card-content { min-height: 118px; + padding: 12px; } + .diary-card-title { font-size: 14px; } - .emotion-page-header { - flex-direction: column; + + /* ----------------------------------------------------- + 감정별 도넛 그래프 + ----------------------------------------------------- */ + + .emotion-chart-section { + /* + 760px 구간보다 + 조금 더 작게 축소 + */ + min-height: 270px; + } + + + .emotion-chart-wrapper { + /* + 기존 350px 제거 + + 760px에서 300px로 줄였는데 + 다시 350px이 되면서 커지던 문제 수정 + */ + width: min(270px, 76vw); + } + + + .emotion-chart-background, + .emotion-chart-segment { + /* + 작은 화면에서 + 도넛 띠 두께 축소 + */ + stroke-width: 40; + } + + + .emotion-chart-segment:hover { + stroke-width: 44; + } + + + .emotion-chart-center-count { + font-size: 27px; + } + + + .emotion-chart-center-label { + font-size: 12px; + } + + + /* ----------------------------------------------------- + 감정별 카드 + ----------------------------------------------------- */ + + .emotion-summary-board { + padding: + 20px + 14px + 18px; + + border-radius: 18px; + } + + + /* + 650px 이하에서도 + 일단 두 줄 배치 유지 + + 너무 빨리 한 줄 하나로 바뀌지 않도록 함 + */ + .emotion-record-card-list { + grid-template-columns: + repeat(2, minmax(0, 1fr)); + + gap: 10px; } - .emotion-bubble-area { - min-height: 720px; + + .emotion-record-card { + gap: 8px; + + min-height: 62px; + + padding: + 9px + 10px; } + + .emotion-record-card-image { + width: 38px; + height: 38px; + } + + + .emotion-record-card-image img { + width: 35px; + height: 35px; + } + + + .emotion-record-card-name { + font-size: 13px; + } + + + .emotion-record-card-count { + font-size: 12px; + } + + + /* ----------------------------------------------------- + 팝업 + ----------------------------------------------------- */ + .emotion-modal-content, .location-modal-content { width: calc(100% - 28px); + padding: 20px; } +} + + +/* ========================================================= + 480px 이하 + ========================================================= */ + +@media (max-width: 480px) { + + /* 페이지 좌우 여백 추가 축소 */ + .diary-list-container { + width: calc(100% - 24px); + } + + + /* ----------------------------------------------------- + 감정별 도넛 그래프 + ----------------------------------------------------- */ + + .emotion-chart-section { + min-height: 240px; + } + + + .emotion-chart-wrapper { + width: min(235px, 72vw); + } + + + .emotion-chart-background, + .emotion-chart-segment { + /* + 아주 작은 화면에서 + 도넛 띠 추가 축소 + */ + stroke-width: 35; + } + + + .emotion-chart-segment:hover { + stroke-width: 38; + } + + + .emotion-chart-center-count { + font-size: 24px; + } + + + .emotion-chart-center-label { + font-size: 11px; + } + + + /* ----------------------------------------------------- + 감정별 보드 + ----------------------------------------------------- */ + + .emotion-summary-board { + padding: + 18px + 12px + 16px; + + border-radius: 16px; + } + + + .emotion-card-guide { + gap: 6px; + + margin-bottom: 14px; + + font-size: 11px; + } + + + .emotion-card-guide-line { + width: 30px; + } + + + /* + 아주 작은 모바일에서는 + 감정 카드 한 줄 하나씩 배치 + */ + .emotion-record-card-list { + grid-template-columns: 1fr; + + gap: 9px; + } + + + .emotion-record-card { + min-height: 58px; + + padding: + 8px + 12px; + } + + + .emotion-record-card-image { + width: 40px; + height: 40px; + } + + + .emotion-record-card-image img { + width: 36px; + height: 36px; + } + + + /* ----------------------------------------------------- + 전체 기록 카드 + ----------------------------------------------------- */ + + .diary-grid, + .emotion-modal-grid, + .location-modal-grid { + /* + 아주 작은 화면에서는 + 기록 카드도 한 줄 하나씩 표시 + */ + grid-template-columns: 1fr; + } } \ No newline at end of file diff --git a/diary/static/diary/js/diary.js b/diary/static/diary/js/diary.js index 2808cd0..442fd57 100644 --- a/diary/static/diary/js/diary.js +++ b/diary/static/diary/js/diary.js @@ -3,7 +3,7 @@ document.addEventListener("DOMContentLoaded", () => { /* ========================================================= - 1. 지도 감정 색상 정보 + 1. 감정 색상 정보 ========================================================= */ const emotionColors = { @@ -34,8 +34,21 @@ document.addEventListener("DOMContentLoaded", () => { /* - 지도에서 '구별로 다른 색' 모드를 선택했을 때 - 각 구에 순서대로 적용할 색상 목록입니다. + 감정별 도넛 그래프 색상 + */ + const emotionChartColors = { + red: "#ef9a9a", + blue: "#9fc5e8", + yellow: "#f8dc7d", + pink: "#efb2c7", + orange: "#f9c25c", + purple: "#b3b2db", + default: "#d4d4d4", + }; + + + /* + 구별 색상 모드용 색상 목록 */ const districtColors = [ "#f3b7b7", @@ -60,38 +73,15 @@ document.addEventListener("DOMContentLoaded", () => { 2. 지도 설정 요소 ========================================================= */ - /* - 지도 오른쪽 위의 점 세 개 버튼입니다. - */ const mapSettingsButton = document.querySelector("#map-settings-button"); - - /* - 점 세 개 버튼을 눌렀을 때 열리는 - 지도 색상 설정 메뉴입니다. - */ const mapSettingsMenu = document.querySelector("#map-settings-menu"); - - /* - 지도 색상 방식을 선택하는 버튼들입니다. - - 예: - - 같은 색으로 통일 - - 가장 많은 감정 색 - - 가장 최근 감정 색 - - 방문 횟수에 따른 농도 - - 구별로 다른 색 - */ const mapModeButtons = document.querySelectorAll("[data-map-mode]"); - - /* - Django가 HTML에 넣어둔 구별 기록 데이터입니다. - */ const districtDataElements = document.querySelectorAll( "#district-map-data [data-district]" @@ -127,7 +117,10 @@ document.addEventListener("DOMContentLoaded", () => { function getDistrictMapData() { return Array.from(districtDataElements).map((element) => ({ district: element.dataset.district.trim(), - count: Number(element.dataset.count), + + count: Number( + element.dataset.count + ), dominantEmotion: element.dataset.dominantEmotion.trim(), @@ -147,15 +140,10 @@ document.addEventListener("DOMContentLoaded", () => { .querySelectorAll(".seoul-map path") .forEach((path) => { - /* - JavaScript로 직접 넣었던 fill 색상을 제거합니다. - */ + // JavaScript fill 제거 path.style.fill = ""; - - /* - 기록이 있는 구에 붙었던 클래스를 제거합니다. - */ + // 기록 표시 클래스 제거 path.classList.remove("has-record"); }); } @@ -167,46 +155,34 @@ document.addEventListener("DOMContentLoaded", () => { function applyMapMode(mode) { - /* - 새로운 색상을 적용하기 전에 - 이전 지도 색상을 먼저 초기화합니다. - */ + // 기존 지도 색상 초기화 clearDistrictStyles(); - /* - HTML에 담긴 구별 데이터를 배열로 가져옵니다. - */ - const mapData = getDistrictMapData(); - + // 구별 기록 데이터 가져오기 + const mapData = + getDistrictMapData(); - /* - 방문 횟수 농도를 계산하기 위해 - 가장 방문 횟수가 많은 구의 기록 수를 구합니다. - 기록이 없을 때 0으로 나누는 문제를 막기 위해 - 최소값을 1로 설정합니다. - */ + // 최대 방문 횟수 계산 const maximumCount = Math.max( - ...mapData.map((district) => district.count), + ...mapData.map( + (district) => district.count + ), 1 ); mapData.forEach((district, index) => { - /* - district.district와 같은 id를 가진 - 서울 지도 SVG path를 찾습니다. - - 예: - district.district가 "성북구"라면 - id="성북구"인 path를 찾습니다. - */ + // 구 이름과 같은 SVG path 탐색 const path = - document.getElementById(district.district); + document.getElementById( + district.district + ); + // SVG path가 없는 경우 제외 if (!path) { console.warn( `SVG에서 구를 찾지 못했습니다: ${district.district}` @@ -216,74 +192,67 @@ document.addEventListener("DOMContentLoaded", () => { } - /* - 기록이 있는 구라는 표시를 추가합니다. - */ + // 기록 있는 구 표시 path.classList.add("has-record"); - /* - 모든 기록된 구를 같은 색으로 표시합니다. - */ + // 동일 색상 모드 if (mode === "uniform") { path.style.fill = "#dce9b7"; } - /* - 해당 구에서 가장 많이 기록된 감정의 색상을 사용합니다. - */ + // 최다 감정 색상 모드 if (mode === "dominant") { path.style.fill = - emotionColors[district.dominantEmotion] + emotionColors[ + district.dominantEmotion + ] || "#d4d4d4"; } - /* - 해당 구에서 가장 최근에 기록한 감정의 색상을 사용합니다. - */ + // 최근 감정 색상 모드 if (mode === "latest") { path.style.fill = - emotionColors[district.latestEmotion] + emotionColors[ + district.latestEmotion + ] || "#d4d4d4"; } - /* - 방문 횟수가 많을수록 색을 진하게 표시합니다. - */ + // 방문 횟수 농도 모드 if (mode === "intensity") { + const minimumOpacity = 0.05; const ratio = - district.count / maximumCount; + district.count + / maximumCount; const opacity = minimumOpacity - + ratio * (1 - minimumOpacity); + + ratio + * (1 - minimumOpacity); path.style.fill = `rgba(245, 196, 80, ${opacity})`; } - /* - 각 구에 서로 다른 색상을 순서대로 적용합니다. - */ + // 구별 다른 색상 모드 if (mode === "rainbow") { path.style.fill = districtColors[ - index % districtColors.length + index + % districtColors.length ]; } }); - /* - 현재 선택한 지도 색상 방식 버튼에만 - active 클래스를 추가합니다. - */ + // 선택 버튼 active 처리 mapModeButtons.forEach((button) => { button.classList.toggle( "active", @@ -292,11 +261,11 @@ document.addEventListener("DOMContentLoaded", () => { }); - /* - 새로고침한 뒤에도 선택한 지도 색상 방식이 - 유지되도록 브라우저 저장소에 저장합니다. - */ - localStorage.setItem("diaryMapMode", mode); + // 선택 모드 저장 + localStorage.setItem( + "diaryMapMode", + mode + ); } @@ -306,31 +275,26 @@ document.addEventListener("DOMContentLoaded", () => { function openLocationModal(locationName) { - /* - 장소 팝업 요소가 없는 페이지에서는 - 아래 코드를 실행하지 않습니다. - */ - if (!locationModal || !locationModalName) { + // 팝업 요소 없는 경우 종료 + if ( + !locationModal + || !locationModalName + ) { return; } - /* - 선택한 장소와 일치하는 기록의 개수입니다. - */ let visibleRecordCount = 0; - /* - 팝업 제목에 선택한 구 이름을 넣습니다. - */ - locationModalName.textContent = locationName; + // 팝업 장소명 설정 + locationModalName.textContent = + locationName; - /* - 해당 구에 작성된 기록만 표시합니다. - */ + // 선택한 장소 기록만 표시 locationModalRecords.forEach((record) => { + const recordLocation = record.dataset.recordLocation.trim(); @@ -345,26 +309,21 @@ document.addEventListener("DOMContentLoaded", () => { }); - /* - 일치하는 기록이 없다면 - 빈 기록 안내 문구를 표시합니다. - */ + // 기록 없음 문구 처리 if (locationModalEmpty) { locationModalEmpty.hidden = visibleRecordCount !== 0; } - /* - 장소별 기록 팝업을 엽니다. - */ + // 팝업 표시 locationModal.hidden = false; - /* - 팝업이 열린 동안 뒤쪽 페이지 스크롤을 막습니다. - */ - document.body.classList.add("modal-open"); + // 배경 스크롤 차단 + document.body.classList.add( + "modal-open" + ); } @@ -373,28 +332,40 @@ document.addEventListener("DOMContentLoaded", () => { ========================================================= */ function closeLocationModal() { + + // 팝업 없는 경우 종료 if (!locationModal) { return; } + + // 팝업 숨김 locationModal.hidden = true; - document.body.classList.remove("modal-open"); + + // 배경 스크롤 복구 + document.body.classList.remove( + "modal-open" + ); } /* ========================================================= - 9. 기록이 있는 서울 구역 클릭 이벤트 + 9. 기록 있는 서울 구역 클릭 ========================================================= */ districtDataElements.forEach((element) => { + const districtName = element.dataset.district.trim(); const districtPath = - document.getElementById(districtName); + document.getElementById( + districtName + ); + // SVG path 없는 경우 제외 if (!districtPath) { console.warn( `클릭 이벤트를 연결할 구를 찾지 못했습니다: ${districtName}` @@ -404,97 +375,92 @@ document.addEventListener("DOMContentLoaded", () => { } - /* - tabindex는 넣지 않습니다. - - SVG를 클릭했을 때 직사각형 포커스 테두리가 - 나타나는 문제를 방지합니다. - */ - districtPath.addEventListener("click", () => { - openLocationModal(districtName); - }); + // 장소 팝업 열기 + districtPath.addEventListener( + "click", + () => { + openLocationModal( + districtName + ); + } + ); }); /* ========================================================= - 10. 장소별 기록 팝업 닫기 버튼 + 10. 장소 팝업 닫기 ========================================================= */ - locationModalCloseButtons.forEach((button) => { - button.addEventListener( - "click", - closeLocationModal - ); - }); + locationModalCloseButtons.forEach( + (button) => { + + button.addEventListener( + "click", + closeLocationModal + ); + } + ); /* ========================================================= - 11. 지도 점 세 개 설정 메뉴 + 11. 지도 설정 메뉴 ========================================================= */ - if (mapSettingsButton && mapSettingsMenu) { + if ( + mapSettingsButton + && mapSettingsMenu + ) { - /* - 점 세 개 버튼을 누르면 - 지도 설정 메뉴를 열거나 닫습니다. - */ - mapSettingsButton.addEventListener("click", (event) => { + // 점 세 개 버튼 클릭 + mapSettingsButton.addEventListener( + "click", + (event) => { - /* - 버튼 클릭이 document 클릭 이벤트까지 - 전달되는 것을 막습니다. + // document 클릭 이벤트 전달 차단 + event.stopPropagation(); - 이 코드가 없으면 메뉴를 열자마자 - 아래 document 클릭 이벤트가 실행되어 - 다시 닫힐 수 있습니다. - */ - event.stopPropagation(); + // 변경 후 열림 상태 계산 + const willOpen = + mapSettingsMenu.hidden; - /* - 현재 메뉴가 숨겨져 있다면 true입니다. - */ - const willOpen = mapSettingsMenu.hidden; + // 메뉴 열기/닫기 + mapSettingsMenu.hidden = + !willOpen; - /* - 숨겨져 있었다면 열고, - 열려 있었다면 닫습니다. - */ - mapSettingsMenu.hidden = !willOpen; + // 접근성 상태 변경 + mapSettingsButton.setAttribute( + "aria-expanded", + String(willOpen) + ); + } + ); - /* - 접근성 상태도 메뉴 상태와 맞춰 변경합니다. - */ - mapSettingsButton.setAttribute( - "aria-expanded", - String(willOpen) - ); - }); + // 메뉴 내부 클릭 전파 차단 + mapSettingsMenu.addEventListener( + "click", + (event) => { + event.stopPropagation(); + } + ); - /* - 설정 메뉴 내부를 클릭했을 때는 - document 클릭 이벤트로 전달되지 않게 합니다. - */ - mapSettingsMenu.addEventListener("click", (event) => { - event.stopPropagation(); - }); + // 메뉴 바깥 클릭 시 닫기 + document.addEventListener( + "click", + () => { - /* - 지도 설정 메뉴 바깥을 클릭하면 - 설정 메뉴를 닫습니다. - */ - document.addEventListener("click", () => { - mapSettingsMenu.hidden = true; + mapSettingsMenu.hidden = true; - mapSettingsButton.setAttribute( - "aria-expanded", - "false" - ); - }); + mapSettingsButton.setAttribute( + "aria-expanded", + "false" + ); + } + ); } @@ -503,92 +469,97 @@ document.addEventListener("DOMContentLoaded", () => { ========================================================= */ mapModeButtons.forEach((button) => { - button.addEventListener("click", () => { - /* - 클릭한 버튼의 data-map-mode 값을 가져옵니다. - */ - const selectedMode = - button.dataset.mapMode; + button.addEventListener( + "click", + () => { + // 선택 모드 가져오기 + const selectedMode = + button.dataset.mapMode; - /* - 선택한 방식으로 지도 색상을 변경합니다. - */ - applyMapMode(selectedMode); + // 지도 색상 변경 + applyMapMode( + selectedMode + ); - /* - 색상 방식 선택 후 설정 메뉴를 닫습니다. - */ - if (mapSettingsMenu) { - mapSettingsMenu.hidden = true; - } - if (mapSettingsButton) { - mapSettingsButton.setAttribute( - "aria-expanded", - "false" - ); + // 설정 메뉴 닫기 + if (mapSettingsMenu) { + mapSettingsMenu.hidden = true; + } + + + // 접근성 상태 변경 + if (mapSettingsButton) { + mapSettingsButton.setAttribute( + "aria-expanded", + "false" + ); + } } - }); + ); }); /* ========================================================= - 13. 페이지 진입 시 저장된 지도 색상 방식 적용 + 13. 저장된 지도 색상 적용 ========================================================= */ if (districtDataElements.length > 0) { + const savedMapMode = - localStorage.getItem("diaryMapMode") + localStorage.getItem( + "diaryMapMode" + ) || "dominant"; - applyMapMode(savedMapMode); + + applyMapMode( + savedMapMode + ); } /* ========================================================= - 14. 전체 기록 정렬 및 기간 설정 요소 + 14. 전체 기록 필터 요소 ========================================================= */ - /* - 최신순·과거순 선택창입니다. - */ const sortSelect = document.querySelector("#diary-sort"); - - /* - 최신순과 기간 설정을 포함하는 전체 폼입니다. - */ const filterForm = - document.querySelector("#diary-filter-form"); - + document.querySelector( + "#diary-filter-form" + ); - /* - 기간 설정창을 여는 버튼입니다. - */ const periodToggleButton = - document.querySelector("#period-toggle-button"); - + document.querySelector( + "#period-toggle-button" + ); - /* - 시작일·종료일·적용·초기화가 들어 있는 - 실제 기간 설정창입니다. - */ const periodFilter = - document.querySelector("#period-filter"); + document.querySelector( + "#period-filter" + ); /* ========================================================= - 15. 최신순·과거순 선택 시 자동 제출 + 15. 최신순·과거순 변경 ========================================================= */ - if (sortSelect && filterForm) { - sortSelect.addEventListener("change", () => { - filterForm.submit(); - }); + if ( + sortSelect + && filterForm + ) { + + sortSelect.addEventListener( + "change", + () => { + filterForm.submit(); + } + ); } @@ -598,34 +569,26 @@ document.addEventListener("DOMContentLoaded", () => { function openPeriodFilter() { - /* - 현재 페이지에 기간 설정 요소가 없다면 - 함수를 종료합니다. - */ - if (!periodToggleButton || !periodFilter) { + // 필수 요소 없는 경우 종료 + if ( + !periodToggleButton + || !periodFilter + ) { return; } - /* - hidden 속성을 제거해 기간 설정창을 표시합니다. - */ + // 기간 설정창 표시 periodFilter.hidden = false; - /* - 기간 설정 버튼에 active 클래스를 추가합니다. - - CSS의 - .period-toggle-button.active - 스타일이 적용됩니다. - */ - periodToggleButton.classList.add("active"); + // 버튼 활성화 + periodToggleButton.classList.add( + "active" + ); - /* - 스크린 리더에 설정창이 열렸다는 것을 전달합니다. - */ + // 접근성 상태 변경 periodToggleButton.setAttribute( "aria-expanded", "true" @@ -639,30 +602,26 @@ document.addEventListener("DOMContentLoaded", () => { function closePeriodFilter() { - /* - 현재 페이지에 기간 설정 요소가 없다면 - 함수를 종료합니다. - */ - if (!periodToggleButton || !periodFilter) { + // 필수 요소 없는 경우 종료 + if ( + !periodToggleButton + || !periodFilter + ) { return; } - /* - hidden 속성을 추가해 기간 설정창을 숨깁니다. - */ + // 기간 설정창 숨김 periodFilter.hidden = true; - /* - 버튼의 활성화 스타일을 제거합니다. - */ - periodToggleButton.classList.remove("active"); + // 버튼 활성화 제거 + periodToggleButton.classList.remove( + "active" + ); - /* - 스크린 리더에 설정창이 닫혔다는 것을 전달합니다. - */ + // 접근성 상태 변경 periodToggleButton.setAttribute( "aria-expanded", "false" @@ -674,88 +633,69 @@ document.addEventListener("DOMContentLoaded", () => { 18. 기간 설정 버튼 클릭 ========================================================= */ - if (periodToggleButton && periodFilter) { - periodToggleButton.addEventListener("click", () => { + if ( + periodToggleButton + && periodFilter + ) { - /* - 버튼을 누르기 전에 기간 설정창이 - 숨겨져 있었는지 확인합니다. + periodToggleButton.addEventListener( + "click", + () => { - true - → 현재 닫혀 있으므로 열어야 합니다. + const shouldOpen = + periodFilter.hidden; - false - → 현재 열려 있으므로 닫아야 합니다. - */ - const shouldOpen = periodFilter.hidden; - - if (shouldOpen) { - openPeriodFilter(); - } else { - closePeriodFilter(); + if (shouldOpen) { + openPeriodFilter(); + } else { + closePeriodFilter(); + } } - }); + ); /* ===================================================== - 19. 기간 설정창 바깥 클릭 시 닫기 + 19. 기간 설정창 바깥 클릭 ===================================================== */ - document.addEventListener("click", (event) => { - - /* - 실제로 클릭된 HTML 요소입니다. - */ - const clickedElement = event.target; - - - /* - 클릭한 위치가 기간 설정 버튼 내부인지 확인합니다. + document.addEventListener( + "click", + (event) => { - 버튼의 글자 부분을 클릭해도 true가 됩니다. - */ - const clickedToggleButton = - periodToggleButton.contains(clickedElement); + const clickedElement = + event.target; - /* - 클릭한 위치가 기간 설정창 내부인지 확인합니다. + // 기간 설정 버튼 클릭 여부 + const clickedToggleButton = + periodToggleButton.contains( + clickedElement + ); - 다음 요소를 클릭했을 때 true가 됩니다. - - 시작 날짜 입력창 - - 종료 날짜 입력창 - - 적용 버튼 - - 초기화 버튼 - - 기간 설정창의 빈 공간 - */ - const clickedInsidePeriodFilter = - periodFilter.contains(clickedElement); + // 기간 설정창 내부 클릭 여부 + const clickedInsidePeriodFilter = + periodFilter.contains( + clickedElement + ); - /* - 기간 설정 버튼도 아니고, - 기간 설정창 내부도 아니라면 - 팝업 바깥을 클릭한 것입니다. - */ - if ( - !clickedToggleButton - && !clickedInsidePeriodFilter - ) { - closePeriodFilter(); + // 외부 클릭 시 닫기 + if ( + !clickedToggleButton + && !clickedInsidePeriodFilter + ) { + closePeriodFilter(); + } } - }); + ); } - - /* ========================================================= + /* ========================================================= 20. 감정별 기록 팝업 요소 ========================================================= */ - const emotionButtons = - document.querySelectorAll(".emotion-bubble"); - const modal = document.querySelector("#emotion-modal"); @@ -776,34 +716,61 @@ document.addEventListener("DOMContentLoaded", () => { 21. 감정별 기록 팝업 열기 ========================================================= */ - function openEmotionModal(emotionId, emotionName) { - - /* - 감정별 기록 팝업 요소가 없다면 - 함수를 실행하지 않습니다. - */ - if (!modal || !modalName || !modalEmpty) { + function openEmotionModal( + emotionIds, + emotionNames + ) { + + // 필수 요소 없는 경우 종료 + if ( + !modal + || !modalName + || !modalEmpty + ) { return; } + // 단일 ID도 배열로 변환 + const normalizedIds = + Array.isArray(emotionIds) + ? emotionIds.map(String) + : [String(emotionIds)]; + + + // 감정명도 배열로 변환 + const normalizedNames = + Array.isArray(emotionNames) + ? emotionNames + : [emotionNames]; + + let visibleRecordCount = 0; - /* - 팝업 제목에 선택한 감정 이름을 넣습니다. - */ - modalName.textContent = emotionName; + // 팝업 제목 설정 + modalName.textContent = + normalizedNames.join(" · "); - /* - 선택한 감정과 일치하는 기록만 표시합니다. - */ + // 선택 감정 그룹 기록 표시 modalRecords.forEach((record) => { + + const recordEmotion = + String( + record.dataset.recordEmotion + ); + + const isMatched = - record.dataset.recordEmotion === emotionId; + normalizedIds.includes( + recordEmotion + ); + + + record.hidden = + !isMatched; - record.hidden = !isMatched; if (isMatched) { visibleRecordCount += 1; @@ -811,22 +778,19 @@ document.addEventListener("DOMContentLoaded", () => { }); - /* - 표시할 기록이 없다면 빈 안내 문구를 표시합니다. - */ - modalEmpty.hidden = visibleRecordCount !== 0; + // 기록 없음 문구 처리 + modalEmpty.hidden = + visibleRecordCount !== 0; - /* - 감정별 기록 팝업을 표시합니다. - */ + // 팝업 표시 modal.hidden = false; - /* - 팝업 뒤쪽 페이지의 스크롤을 막습니다. - */ - document.body.classList.add("modal-open"); + // 배경 스크롤 차단 + document.body.classList.add( + "modal-open" + ); } @@ -835,93 +799,477 @@ document.addEventListener("DOMContentLoaded", () => { ========================================================= */ function closeEmotionModal() { + + // 팝업 없는 경우 종료 if (!modal) { return; } + + // 팝업 숨김 modal.hidden = true; - document.body.classList.remove("modal-open"); + + // 배경 스크롤 복구 + document.body.classList.remove( + "modal-open" + ); } /* ========================================================= - 23. 감정 조약돌 클릭 + 23. 감정 도넛 그래프 요소 ========================================================= */ - emotionButtons.forEach((button) => { - button.addEventListener("click", () => { - openEmotionModal( - button.dataset.emotionId, - button.dataset.emotionName - ); - }); - }); + const emotionChart = + document.querySelector( + "#emotion-chart" + ); + + const emotionChartSegments = + document.querySelector( + "#emotion-chart-segments" + ); + + const emotionChartWrapper = + document.querySelector( + "#emotion-chart-wrapper" + ); + + const emotionChartTooltip = + document.querySelector( + "#emotion-chart-tooltip" + ); + + const emotionChartDataItems = + document.querySelectorAll( + "#emotion-chart-data .emotion-chart-data-item" + ); /* ========================================================= - 24. 감정 팝업 닫기 버튼 및 배경 클릭 + 24. 색상 그룹별 감정 데이터 생성 ========================================================= */ - closeButtons.forEach((button) => { - button.addEventListener( - "click", - closeEmotionModal + function getEmotionChartGroups() { + + const groupedData = {}; + + + emotionChartDataItems.forEach( + (element) => { + + const emotionId = + element.dataset.emotionId.trim(); + + const emotionName = + element.dataset.emotionName.trim(); + + const emotionCount = + Number( + element.dataset.emotionCount + ); + + const colorGroup = + element.dataset.emotionColorGroup.trim(); + + + // 잘못된 데이터 제외 + if ( + !emotionId + || !emotionName + || emotionCount <= 0 + ) { + return; + } + + + // 색상 그룹 최초 생성 + if (!groupedData[colorGroup]) { + + groupedData[colorGroup] = { + colorGroup, + count: 0, + emotionIds: [], + emotionNames: [], + }; + } + + + // 기록 횟수 합산 + groupedData[colorGroup].count += + emotionCount; + + + // 감정 ID 추가 + if ( + !groupedData[ + colorGroup + ].emotionIds.includes( + emotionId + ) + ) { + groupedData[ + colorGroup + ].emotionIds.push( + emotionId + ); + } + + + // 감정 이름 추가 + if ( + !groupedData[ + colorGroup + ].emotionNames.includes( + emotionName + ) + ) { + groupedData[ + colorGroup + ].emotionNames.push( + emotionName + ); + } + } ); - }); + + + return Object.values( + groupedData + ); + } /* ========================================================= - 25. Escape 키로 열린 요소 닫기 + 25. 도넛 그래프 툴팁 표시 ========================================================= */ - document.addEventListener("keydown", (event) => { + function showEmotionChartTooltip( + emotionNames, + event + ) { - /* - 누른 키가 Escape가 아니라면 - 아래 코드를 실행하지 않습니다. - */ - if (event.key !== "Escape") { + // 필수 요소 없는 경우 종료 + if ( + !emotionChartTooltip + || !emotionChartWrapper + ) { return; } - /* - 감정별 기록 팝업이 열려 있다면 닫습니다. - */ - if (modal && !modal.hidden) { - closeEmotionModal(); + // 감정 이름 표시 + emotionChartTooltip.textContent = + emotionNames.join(" · "); + + + // 툴팁 표시 + emotionChartTooltip.hidden = false; + + + const wrapperRect = + emotionChartWrapper.getBoundingClientRect(); + + + const tooltipX = + event.clientX + - wrapperRect.left; + + const tooltipY = + event.clientY + - wrapperRect.top; + + + // 마우스 위쪽 배치 + emotionChartTooltip.style.left = + `${tooltipX}px`; + + emotionChartTooltip.style.top = + `${tooltipY - 18}px`; + } + + + /* ========================================================= + 26. 도넛 그래프 툴팁 제거 + ========================================================= */ + + function hideEmotionChartTooltip() { + + if (!emotionChartTooltip) { + return; } + emotionChartTooltip.hidden = true; + } + + + /* ========================================================= + 27. SVG 도넛 그래프 생성 + ========================================================= */ + + function renderEmotionChart() { + + // 그래프 없는 경우 종료 + if ( + !emotionChart + || !emotionChartSegments + ) { + return; + } + + + // 기존 조각 제거 + emotionChartSegments.innerHTML = ""; + + + const chartGroups = + getEmotionChartGroups(); + + + // 데이터 없는 경우 종료 + if (chartGroups.length === 0) { + return; + } + + + // 전체 기록 수 계산 + const totalCount = + chartGroups.reduce( + (sum, group) => + sum + group.count, + 0 + ); + + + if (totalCount <= 0) { + return; + } + + + // SVG 원 정보 + const centerX = 160; + const centerY = 160; + const radius = 112; + + + // 전체 원 둘레 + const circumference = + 2 * Math.PI * radius; + + /* - 장소별 기록 팝업이 열려 있다면 닫습니다. + 조각 사이 간격 + + 값 증가 + → 조각 사이 흰색 간격 증가 */ - if (locationModal && !locationModal.hidden) { - closeLocationModal(); - } + const segmentGap = 5; /* - 지도 점 세 개 설정 메뉴가 열려 있다면 닫습니다. + 12시 방향 시작 + + 기본 circle 시작점은 오른쪽이므로 + rotate(-90deg) 적용 */ - if (mapSettingsMenu && !mapSettingsMenu.hidden) { - mapSettingsMenu.hidden = true; + let currentLength = 0; - if (mapSettingsButton) { - mapSettingsButton.setAttribute( - "aria-expanded", - "false" + + chartGroups.forEach((group) => { + + const ratio = + group.count + / totalCount; + + + // 실제 그룹 길이 + const segmentLength = + circumference * ratio; + + + // 흰색 간격 제외 + const visibleLength = + Math.max( + segmentLength + - segmentGap, + 1 ); - } - } - /* - 기간 설정창이 열려 있다면 닫습니다. - */ - if (periodFilter && !periodFilter.hidden) { - closePeriodFilter(); + const segment = + document.createElementNS( + "http://www.w3.org/2000/svg", + "circle" + ); + + + segment.setAttribute( + "cx", + centerX + ); + + segment.setAttribute( + "cy", + centerY + ); + + segment.setAttribute( + "r", + radius + ); + + + segment.classList.add( + "emotion-chart-segment" + ); + + + segment.classList.add( + `emotion-chart-segment--${group.colorGroup}` + ); + + + /* + 현재 조각만 표시 + + visibleLength + → 보이는 부분 + + 나머지 + → 숨겨지는 부분 + */ + segment.style.strokeDasharray = + `${visibleLength} ${ + circumference - visibleLength + }`; + + + /* + 조각 시작 위치 이동 + */ + segment.style.strokeDashoffset = + `${-currentLength}`; + + + // hover 데이터 저장 + segment.dataset.emotionIds = + group.emotionIds.join(","); + + segment.dataset.emotionNames = + group.emotionNames.join("|"); + + + // hover 툴팁 표시 + segment.addEventListener( + "mouseenter", + (event) => { + + showEmotionChartTooltip( + group.emotionNames, + event + ); + } + ); + + + // 툴팁 위치 이동 + segment.addEventListener( + "mousemove", + (event) => { + + showEmotionChartTooltip( + group.emotionNames, + event + ); + } + ); + + + // 툴팁 제거 + segment.addEventListener( + "mouseleave", + hideEmotionChartTooltip + ); + + + /* + 색상 조각 클릭 + + 해당 그룹에 포함된 모든 대표 감정 기록 표시 + */ + segment.addEventListener( + "click", + () => { + + hideEmotionChartTooltip(); + + + openEmotionModal( + group.emotionIds, + group.emotionNames + ); + } + ); + + + emotionChartSegments.appendChild( + segment + ); + + + // 다음 조각 시작 위치 계산 + currentLength += + segmentLength; + }); + } + + + /* ========================================================= + 28. 도넛 그래프 생성 + ========================================================= */ + + renderEmotionChart(); + + + /* ========================================================= + 29. 감정 카드 클릭 + ========================================================= */ + + const emotionRecordCards = + document.querySelectorAll( + "[data-emotion-card]" + ); + + + emotionRecordCards.forEach( + (card) => { + + card.addEventListener( + "click", + () => { + + openEmotionModal( + card.dataset.emotionId, + card.dataset.emotionName + ); + } + ); } + ); + + + /* ========================================================= + 30. 감정 팝업 닫기 + ========================================================= */ + + closeButtons.forEach((button) => { + + button.addEventListener( + "click", + closeEmotionModal + ); }); -}); \ No newline at end of file + +}); \ No newline at end of file diff --git a/diary/templates/diary/list.html b/diary/templates/diary/list.html index 39224df..44b11e5 100644 --- a/diary/templates/diary/list.html +++ b/diary/templates/diary/list.html @@ -3,18 +3,28 @@ {% extends "base.html" %} {% load static %} -{% block title %}다이어리 목록 | HeartMark{% endblock %} + +{% block title %} +다이어리 목록 | HeartMark +{% endblock %} + {% block extra_css %} {% comment %} -담당 C의 감정 아이콘 스타일을 불러옵니다. +담당 C의 감정 아이콘 스타일을 불러옴 {% endcomment %} - + {% comment %} -다이어리 목록 페이지 전용 스타일을 불러옵니다. +다이어리 목록 페이지 전용 스타일을 불러옴 {% endcomment %} - + {% endblock %} @@ -22,32 +32,117 @@
- -
- {% include "diary/partials/diary_tab.html" %} + + +
+ + +
+ {% include "diary/partials/diary_tab.html" %} +
+ + + + {% if selected_tab == "location" %} +
+ + + + + + + + +
+ {% endif %} +
-
-
-
-

- 감정별 기록 -

- -

- 감정의 크기는 이번 달 기록 횟수를 나타내요. -

-
- -
- - 가장 많이 기록한 감정 - - - {% if top_emotions %} - - {% for emotion in top_emotions %} - - {{ emotion.name }} - - - {% if not forloop.last %} - - · - - {% endif %} - {% endfor %} - - {% else %} - - 아직 기록 없음 - - {% endif %} -
-
+ {% comment %} + 감정별 기록 탭의 전체 HTML은 + emotion_list.html에서 관리 -
- {% for emotion in recorded_emotions %} - - - {% empty %} -

- 표시할 감정이 없어요. -

- {% endfor %} -
-
+ {% include "diary/partials/emotion_list.html" %} \ No newline at end of file diff --git a/diary/templates/diary/partials/emotion_modal.html b/diary/templates/diary/partials/emotion_modal.html new file mode 100644 index 0000000..2c49de4 --- /dev/null +++ b/diary/templates/diary/partials/emotion_modal.html @@ -0,0 +1,74 @@ + + \ No newline at end of file diff --git a/diary/templates/diary/partials/seoul_map.html b/diary/templates/diary/partials/seoul_map.html index 51812ca..45f1250 100644 --- a/diary/templates/diary/partials/seoul_map.html +++ b/diary/templates/diary/partials/seoul_map.html @@ -1,6 +1,6 @@ diff --git a/diary/views.py b/diary/views.py index 2272300..3b29f63 100644 --- a/diary/views.py +++ b/diary/views.py @@ -7,7 +7,7 @@ from django.shortcuts import get_object_or_404, render from django.utils import timezone -# 담당 C가 만든 실제 기록 모델을 가져옵니다. +# 담당 C가 만든 실제 기록 모델을 가져옴 from records.models import Record @@ -39,9 +39,58 @@ } +# ============================================================ +# 감정 번호별 색상 그룹 +# ============================================================ + +""" +감정별 기록의 도넛 그래프에서는 +감정 하나마다 다른 색을 사용하는 것이 아니라, + +비슷한 색상 계열의 감정을 +하나의 그래프 조각으로 묶어서 표시 + +예: +사랑, 짝사랑 +→ pink 그룹 + +기쁨, 만족, 신남, 행운, 맛있어! +→ yellow 그룹 + +중요: +여기서는 감정 이름이 아니라 +감정 번호를 기준으로 그룹을 지정 + +같은 이름의 감정이 여러 번호에 존재하더라도 +번호 기준으로 안정적으로 처리할 수 있음 +""" +EMOTION_COLOR_GROUP_MAP = { + 1: "blue", + 2: "pink", + 3: "purple", + 4: "purple", + 5: "yellow", + 6: "blue", + 7: "pink", + 8: "red", + 9: "orange", + 10: "blue", + 11: "red", + 12: "yellow", + 13: "blue", + 14: "orange", + 15: "yellow", + 16: "yellow", + 17: "red", + 18: "yellow", + 19: "purple", + 20: "purple", +} + + def get_emotion_name(emotion_number): """ - 감정 번호를 화면에 표시할 감정 이름으로 변환합니다. + 감정 번호를 화면에 표시할 감정 이름으로 변환 예: 1 → "슬픔" @@ -50,11 +99,14 @@ def get_emotion_name(emotion_number): try: # JSONField 값이나 폼 값이 문자열일 수도 있으므로 - # 비교하기 전에 정수로 변환합니다. - emotion_number = int(emotion_number) + # 비교하기 전에 정수로 변환 + emotion_number = int( + emotion_number, + ) except (TypeError, ValueError): - # 값이 없거나 숫자로 바꿀 수 없다면 빈 문자열을 반환합니다. + # 값이 없거나 숫자로 바꿀 수 없다면 + # 빈 문자열을 반환 return "" return EMOTION_NAME_MAP.get( @@ -63,36 +115,88 @@ def get_emotion_name(emotion_number): ) +def get_emotion_color_group(emotion_number): + """ + 감정 번호를 감정별 그래프에서 사용할 + 색상 그룹 이름으로 변환 + + 예: + 2 → pink + 11 → red + 16 → yellow + """ + + try: + emotion_number = int( + emotion_number, + ) + + except (TypeError, ValueError): + return "default" + + return EMOTION_COLOR_GROUP_MAP.get( + emotion_number, + "default", + ) + + @login_required def diary_list(request): """ 현재 로그인한 사용자가 작성한 실제 Record를 조회하여 - 전체 / 감정별 / 장소별 목록 페이지에 전달합니다. + 전체 / 감정별 / 장소별 목록 페이지에 전달 """ # ======================================================== # 1. 현재 선택한 탭과 필터값 # ======================================================== - selected_tab = request.GET.get("tab", "all") - selected_sort = request.GET.get("sort", "latest") + selected_tab = request.GET.get( + "tab", + "all", + ) - selected_start_date = request.GET.get("start_date", "") - selected_end_date = request.GET.get("end_date", "") + selected_sort = request.GET.get( + "sort", + "latest", + ) - if selected_tab not in {"all", "emotion", "location"}: + selected_start_date = request.GET.get( + "start_date", + "", + ) + + selected_end_date = request.GET.get( + "end_date", + "", + ) + + + # 허용하지 않는 탭 이름이 들어오면 + # 전체 기록 탭으로 돌려놓음 + if selected_tab not in { + "all", + "emotion", + "location", + }: selected_tab = "all" + # ======================================================== # 2. 현재 사용자의 실제 기록 조회 # ======================================================== - records_queryset = Record.objects.filter( - user=request.user, - ).select_related( - "place", + records_queryset = ( + Record.objects + .filter( + user=request.user, + ) + .select_related( + "place", + ) ) + # ======================================================== # 3. 시작일 필터 # ======================================================== @@ -109,8 +213,11 @@ def diary_list(request): ) except ValueError: + # 잘못된 날짜 값이 들어오면 + # 필터를 무시 selected_start_date = "" + # ======================================================== # 4. 종료일 필터 # ======================================================== @@ -129,11 +236,13 @@ def diary_list(request): except ValueError: selected_end_date = "" + # ======================================================== # 5. 정렬 # ======================================================== if selected_sort == "oldest": + records_queryset = records_queryset.order_by( "created_at", ) @@ -145,227 +254,506 @@ def diary_list(request): "-created_at", ) - records = list(records_queryset) + + # QuerySet을 리스트로 변환 + # + # 이후 여러 번 반복하면서 + # 감정/장소 데이터를 계산하기 때문에 + # 한 번 리스트로 만들어 사용 + records = list( + records_queryset, + ) + # ======================================================== - # 6. 각 기록에 화면용 감정 이름 추가 + # 6. 각 기록에 화면용 대표 감정 정보 추가 # ======================================================== for record in records: + + # 대표 감정 번호를 + # 사람이 읽을 수 있는 이름으로 바꿈 record.main_emotion_name = get_emotion_name( record.main_emotion, ) + # 지도나 다른 화면에서도 필요할 수 있으므로 + # 대표 감정의 색상 그룹도 record에 임시로 추가 + record.main_emotion_color_group = ( + get_emotion_color_group( + record.main_emotion, + ) + ) + + # ======================================================== - # 7. 감정별 기록 횟수 계산 + # 7. 대표 감정별 기록 횟수 계산 # ======================================================== - main_emotion_numbers = [ - int(record.main_emotion) - for record in records - if record.main_emotion - ] + """ + 여기서 중요한 점: + + record.emotions 전체를 사용하는 것이 아니라 + record.main_emotion만 사용 + + 따라서 사용자가 기록할 때 선택한 감정이 3개여도 + + 대표 감정 1개만 + 감정별 그래프 집계에 포함됨 + """ + + main_emotion_numbers = [] + + for record in records: + try: + emotion_number = int( + record.main_emotion, + ) + except (TypeError, ValueError): + continue + + main_emotion_numbers.append( + emotion_number, + ) + + + # 예: + # + # [16, 16, 12, 11] + # + # → + # + # { + # 16: 2, + # 12: 1, + # 11: 1 + # } emotion_counts = Counter( main_emotion_numbers, ) + + # 모든 감정 정보를 담는 목록 + # + # 기존 기능과의 호환을 위해 + # 기록 횟수가 0인 감정도 emotions에는 남겨둠 emotions = [] for emotion_id, emotion_name in EMOTION_NAME_MAP.items(): + record_count = emotion_counts.get( emotion_id, 0, ) - if record_count >= 5: - size = "large" - - elif record_count >= 2: - size = "medium" - - else: - size = "small" - emotions.append( { "id": emotion_id, + "name": emotion_name, + + # 해당 감정이 대표 감정으로 + # 기록된 횟수 "record_count": record_count, - "size": size, + + # 도넛 그래프에서 사용할 색상 그룹 + "color_group": ( + get_emotion_color_group( + emotion_id, + ) + ), } ) + + # ======================================================== + # 8. 실제로 기록된 대표 감정만 추리기 + # ======================================================== + + """ + 도넛 그래프에는 + 한 번도 기록되지 않은 감정은 나오면 안 됨 + + 따라서 record_count가 1 이상인 감정만 + recorded_emotions에 넣음 + """ + recorded_emotions = [ emotion for emotion in emotions if emotion["record_count"] > 0 ] - # 실제 기록된 감정이 있다면 + + # ======================================================== + # 9. 가장 많이 기록한 대표 감정 계산 + # ======================================================== + + """ + 기존 emotion_modal이나 다른 화면에서 + top_emotions를 사용할 수도 있으므로 + 이 데이터는 유지 + """ + if recorded_emotions: - # 가장 많은 기록 횟수를 구합니다. - # - # 예: - # 예민 1회, 행운 1회 - # → maximum_emotion_count = 1 + maximum_emotion_count = max( emotion["record_count"] for emotion in recorded_emotions ) - # 가장 많은 기록 횟수와 같은 감정을 - # 모두 공동 1위 목록에 넣습니다. - # - # 예: - # 예민 1회, 행운 1회 - # → [예민, 행운] top_emotions = [ emotion for emotion in recorded_emotions - if emotion["record_count"] == maximum_emotion_count + if ( + emotion["record_count"] + == maximum_emotion_count + ) ] else: - # 기록된 대표 감정이 없다면 빈 목록을 전달합니다. top_emotions = [] + # ======================================================== - # 8. 서울 지도용 장소 데이터 계산 + # 10. 도넛 그래프용 색상 그룹 데이터 생성 + # ======================================================== + + """ + 같은 색상 그룹에 속한 감정들을 하나로 합침 + + 예: + + 기쁨 2회 + 만족 1회 + 신남 1회 + + 모두 yellow 그룹이라면 + + yellow: + count = 4 + emotions = ["만족", "신남", "기쁨"] + + 가 됩니다. + + JavaScript는 이 데이터를 사용하여 + 원 그래프 조각 크기를 계산할 수 있음 + """ + + emotion_chart_group_dict = {} + + + for emotion in recorded_emotions: + + color_group = emotion[ + "color_group" + ] + + emotion_name = emotion[ + "name" + ] + + record_count = emotion[ + "record_count" + ] + + + # 해당 색상 그룹이 처음 등장하면 + # 기본 구조를 생성 + if color_group not in emotion_chart_group_dict: + + emotion_chart_group_dict[color_group] = { + "color_group": color_group, + + # 이 색상 그룹에 속한 + # 대표 감정 기록 수의 총합 + "record_count": 0, + + # hover 시 보여줄 감정 이름 목록 + "emotion_names": [], + } + + + # 색상 그룹의 전체 기록 수에 + # 현재 감정 기록 수를 더함 + emotion_chart_group_dict[ + color_group + ]["record_count"] += record_count + + + # 같은 이름을 중복해서 + # 툴팁에 표시하지 않도록 함 + # + # 예: + # 감정 번호 1, 6, 10이 모두 "슬픔"이어도 + # hover에는 "슬픔" 한 번만 표시함 + if ( + emotion_name + not in emotion_chart_group_dict[ + color_group + ]["emotion_names"] + ): + emotion_chart_group_dict[ + color_group + ]["emotion_names"].append( + emotion_name, + ) + + + # 딕셔너리를 템플릿에서 사용하기 쉬운 + # 리스트 형태로 바꿈 + emotion_chart_groups = list( + emotion_chart_group_dict.values() + ) + + + # ======================================================== + # 11. 전체 대표 감정 기록 수 + # ======================================================== + + """ + 현재 Record 하나당 대표 감정이 하나이므로 + 정상적인 데이터라면 records 개수와 같음 + + 그래프 가운데의 + + 전체 기록 + 8개 + + 를 표시할 때 사용할 값 + """ + + total_record_count = sum( + emotion["record_count"] + for emotion in recorded_emotions + ) + + + # ======================================================== + # 12. 서울 지도용 장소 데이터 계산 # ======================================================== district_records = {} + for record in records: + # Record에 연결된 Place가 없으면 - # 지도에 표시할 구 정보를 알 수 없으므로 제외합니다. + # 지도에 표시할 수 없으므로 제외 if not record.place: continue + # Place 모델의 district에는 - # "성북구", "강남구"처럼 서울 자치구 이름이 저장됩니다. + # "성북구", "강남구"와 같은 + # 서울 자치구 이름이 저장됨 district_name = ( record.place.district.strip() if record.place.district else "" ) - # district 값이 비어 있다면 제외합니다. + + # 구 이름이 없다면 제외 if not district_name: continue - emotion_name = record.main_emotion_name - # 대표 감정 정보가 없다면 - # 감정 색상을 계산할 수 없으므로 제외합니다. + emotion_name = ( + record.main_emotion_name + ) + + + # 대표 감정 정보가 없으면 + # 지도 감정 색상을 정할 수 없으므로 제외 if not emotion_name: continue - # 해당 구가 처음 등장했다면 - # 기록을 담을 빈 배열을 만듭니다. + if district_name not in district_records: - district_records[district_name] = [] - district_records[district_name].append( + district_records[ + district_name + ] = [] + + + district_records[ + district_name + ].append( { "emotion_name": emotion_name, - "created_at": record.created_at, + + "created_at": ( + record.created_at + ), } ) district_map_data = [] - for district_name, district_record_list in district_records.items(): - # 해당 구에서 기록된 대표 감정들의 이름 + + for ( + district_name, + district_record_list, + ) in district_records.items(): + + # 해당 구에서 기록된 + # 대표 감정 이름 목록 emotion_names = [ item["emotion_name"] for item in district_record_list ] - # 해당 구에서 가장 많이 기록된 감정 + + # 가장 많이 기록된 감정 계산 emotion_name_counts = Counter( emotion_names, ) - dominant_emotion = emotion_name_counts.most_common(1)[0][0] + dominant_emotion = ( + emotion_name_counts + .most_common(1)[0][0] + ) + - # 해당 구의 가장 최근 기록 + # 가장 최근 기록 계산 latest_record = max( district_record_list, - key=lambda item: item["created_at"], + key=lambda item: item[ + "created_at" + ], ) + district_map_data.append( { # SVG path의 id와 비교할 구 이름 - # 예: "성북구" "district_name": district_name, # 해당 구에서 작성한 기록 수 - "visit_count": len(district_record_list), + "visit_count": len( + district_record_list, + ), # 해당 구에서 가장 많이 기록된 감정 - "dominant_emotion": dominant_emotion, + "dominant_emotion": ( + dominant_emotion + ), - # 해당 구에서 가장 최근에 기록된 감정 - "latest_emotion": latest_record["emotion_name"], + # 해당 구에서 가장 최근 대표 감정 + "latest_emotion": ( + latest_record[ + "emotion_name" + ] + ), } ) + # ======================================================== - # 9. 장소별 기록 횟수 계산 + # 13. 장소별 기록 횟수 계산 # ======================================================== location_counts = Counter( record.place.district for record in records - if record.place and record.place.district + if ( + record.place + and record.place.district + ) ) + locations = [ { "name": place_name, "record_count": count, } - for place_name, count in location_counts.items() + for ( + place_name, + count, + ) in location_counts.items() ] + locations.sort( - key=lambda location: location["record_count"], + key=lambda location: ( + location["record_count"] + ), reverse=True, ) + # ======================================================== - # 10. 템플릿에 데이터 전달 + # 14. 템플릿에 데이터 전달 # ======================================================== context = { + # ---------------------------------------------------- + # 공통 기록 + # ---------------------------------------------------- "records": records, + + + # ---------------------------------------------------- + # 감정별 기록 + # ---------------------------------------------------- + + # 모든 감정 "emotions": emotions, - # 실제 기록 횟수가 1회 이상인 대표 감정만 전달합니다. + # 실제 대표 감정으로 기록된 감정만 "recorded_emotions": recorded_emotions, + # 가장 많이 기록한 대표 감정 "top_emotions": top_emotions, + + # 도넛 그래프용 색상 그룹 데이터 + "emotion_chart_groups": emotion_chart_groups, + + # 도넛 그래프 가운데 표시할 전체 기록 수 + "total_record_count": total_record_count, + + + # ---------------------------------------------------- + # 장소별 기록 + # ---------------------------------------------------- "locations": locations, - "district_map_data": district_map_data, + "district_map_data": ( + district_map_data + ), + + + # ---------------------------------------------------- + # 현재 탭 및 필터 상태 + # ---------------------------------------------------- "selected_tab": selected_tab, + "selected_sort": selected_sort, - "selected_start_date": selected_start_date, - "selected_end_date": selected_end_date, + + "selected_start_date": ( + selected_start_date + ), + + "selected_end_date": ( + selected_end_date + ), "selected_emotion": request.GET.get( "emotion", "", ), + "selected_location": request.GET.get( "location", "", ), } + return render( request, "diary/list.html", @@ -377,53 +765,55 @@ def diary_list(request): def emotion_calendar(request): """ 현재 로그인한 사용자의 기록을 날짜별로 정리하여 - 감정 캘린더 페이지에 전달합니다. + 감정 캘린더 페이지에 전달 같은 날짜에 기록이 여러 개 있다면 - 가장 최근에 작성한 기록의 대표 감정을 사용합니다. + 가장 최근에 작성한 기록의 대표 감정을 사용 """ - # 오늘 날짜를 기준으로 캘린더의 최초 연도와 월을 설정합니다. + # 오늘 날짜를 기준으로 + # 캘린더 최초 연도와 월을 설정 today = timezone.localdate() - # 최신 기록부터 조회합니다. + + # 최신 기록부터 조회 # - # 아래에서 날짜별 데이터에 처음 들어간 기록만 사용하므로 - # 같은 날짜에 여러 기록이 있으면 가장 최신 기록이 남습니다. + # 아래에서 날짜별 데이터에 + # 처음 들어간 기록만 사용하므로 + # 같은 날짜에 여러 기록이 있으면 + # 가장 최신 기록이 남음 records = Record.objects.filter( user=request.user, ).order_by( "-created_at", ) - # JavaScript에 전달할 날짜별 감정 데이터입니다. - # - # 결과 예: - # { - # "2026-08-01": { - # "number": 7, - # "name": "짝사랑", - # "image_name": "emotion-07.png" - # } - # } + + # JavaScript에 전달할 + # 날짜별 감정 데이터 calendar_emotions = {} + for record in records: - # created_at이 시간대 정보를 가진 경우 - # 한국 시간 기준 날짜로 변환합니다. + + # created_at을 + # 한국 시간 기준 날짜로 변환 created_date = timezone.localtime( record.created_at, ).date() + date_key = created_date.strftime( "%Y-%m-%d", ) - # 이미 같은 날짜의 최신 기록이 저장되어 있다면 - # 그보다 오래된 기록은 건너뜁니다. + + # 이미 같은 날짜의 최신 기록이 있다면 + # 오래된 기록은 건너뜀 if date_key in calendar_emotions: continue + try: emotion_number = int( record.main_emotion, @@ -432,95 +822,106 @@ def emotion_calendar(request): except (TypeError, ValueError): continue - calendar_emotions[date_key] = { - # 감정 이미지 번호 + + calendar_emotions[ + date_key + ] = { + # 감정 번호 "number": emotion_number, - # 이미지의 대체 텍스트와 툴팁에 사용할 감정 이름 + # 감정 이름 "name": get_emotion_name( emotion_number, ), - # 실제 records 이미지 폴더의 파일명 + # 감정 이미지 파일명 "image_name": ( f"emotion-{emotion_number:02d}.png" ), - # 날짜 칸이나 감정 이미지를 눌렀을 때 - # 해당 기록 상세 페이지로 이동할 때 사용할 ID + # 상세 페이지 이동용 기록 ID "record_id": record.pk, } + context = { - # 최초 화면에 표시할 연도와 월 + # 최초 화면에 표시할 연도 "calendar_year": today.year, + + # 최초 화면에 표시할 월 "calendar_month": today.month, - # calendar.html의 json_script를 통해 - # JavaScript에 안전하게 전달됩니다. - "calendar_emotions": calendar_emotions, + # 날짜별 대표 감정 데이터 + "calendar_emotions": ( + calendar_emotions + ), } + return render( request, "diary/calendar.html", context, ) + @login_required def diary_detail(request, pk): """ 현재 로그인한 사용자가 작성한 기록 하나를 조회하여 - 다이어리 상세 페이지에 전달합니다. + 다이어리 상세 페이지에 전달 - pk는 기록의 고유 번호입니다. + pk는 기록의 고유 번호 예: /diary/2/ - → id가 2인 Record를 조회합니다. + → id가 2인 Record를 조회 """ - # 다른 사용자의 기록을 주소로 직접 접근하지 못하도록 - # user=request.user 조건을 함께 사용합니다. + # 다른 사용자의 기록을 + # 주소로 직접 접근하지 못하도록 + # user=request.user 조건을 함께 사용 record = get_object_or_404( Record, pk=pk, user=request.user, ) - # 대표 감정 번호 - # 대표 감정 정보입니다. - # - # Record.main_emotion에는 1~20 사이 번호가 저장됩니다. + + # ======================================================== + # 대표 감정 + # ======================================================== + main_emotion_number = int( record.main_emotion, ) + main_emotion = { + # 감정 번호 "number": main_emotion_number, - # diary/views.py 위쪽에 이미 있는 - # get_emotion_name() 함수를 사용합니다. + # 화면에 표시할 감정 이름 "name": get_emotion_name( main_emotion_number, ), - # 실제 감정 이미지 파일명입니다. - # - # 예: - # 1 → emotion-01.png - # 7 → emotion-07.png - # 20 → emotion-20.png + # 실제 감정 이미지 파일명 "image_name": ( f"emotion-{main_emotion_number:02d}.png" ), } - # 대표 감정을 제외한 나머지 감정들을 - # 보조 감정 목록으로 만듭니다. + + # ======================================================== + # 보조 감정 + # ======================================================== + secondary_emotions = [] + for emotion_number in record.emotions: + try: emotion_number = int( emotion_number, @@ -529,39 +930,47 @@ def diary_detail(request, pk): except (TypeError, ValueError): continue - # 대표 감정은 가운데 카드에서 따로 표시하므로 - # 보조 감정 목록에서는 제외합니다. - if emotion_number == main_emotion_number: + + # 대표 감정은 가운데에서 따로 표시하므로 + # 보조 감정 목록에서는 제외 + if ( + emotion_number + == main_emotion_number + ): continue + secondary_emotions.append( { "number": emotion_number, + "name": get_emotion_name( emotion_number, ), + "image_name": ( f"emotion-{emotion_number:02d}.png" ), } ) - # 감정은 최대 3개이므로 보조 감정은 최대 2개입니다. - # - # 첫 번째 보조 감정은 왼쪽, - # 두 번째 보조 감정은 오른쪽에 표시합니다. + + # 감정은 최대 3개이므로 + # 대표 감정 제외 후 보조 감정은 최대 2개 left_emotion = ( secondary_emotions[0] if len(secondary_emotions) >= 1 else None ) + right_emotion = ( secondary_emotions[1] if len(secondary_emotions) >= 2 else None ) + context = { # 기록 전체 정보 "record": record, @@ -576,6 +985,7 @@ def diary_detail(request, pk): "right_emotion": right_emotion, } + return render( request, "diary/detail.html",