شامل کارهای چند نشست موازی که تا اینجا فقط روی سرور بود: - نقشهٔ دفتر مرکزی در فوتر (LazyOfficeMap) + MapViewport/LazyMaps/mapProjects - صفحهٔ مدیریت مقالات (AdminArticlesPage) + میانبر ویرایش پیشنویس در وبلاگ - مهاجرت ۰۰۰۴: updated_at مقالات/پروژهها - chatbot/Dockerfile: نصب دومرحلهای torch (کاربردی برای PyPI فیلترشده) - seed: خالیکردن آمارهای دموی story - docker-compose: اصلاح پسوند @file میانافزارهای Traefik - حذف فایل موقت frontend/.tmp-style.json و اصلاحات جزئی UI با این کامیت، آنچه روی سرور اجرا میشود Exactly همان چیزی است که در مخزن ثبت شده.
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
/**
|
||
* مختصات پروژههای گروه ساختمانی آرامش — برگرفته از شیت «Sheet1.csv».
|
||
*
|
||
* دو نکتهی دادهای که در همانجا حل میشوند:
|
||
* ۱) ستونهای neshan_url و google_maps_url در شیت فقط برچسب بودند
|
||
* («Open in Neshan») و نشانی واقعی نداشتند؛ پس پیوندها از روی مختصات
|
||
* ساخته میشوند که الگوی تثبیتشدهی هر دو سرویس است.
|
||
* ۲) ستون توضیحات خالی است؛ متن پیشفرض در لایهی رابط کاربری جایگزین میشود.
|
||
*/
|
||
|
||
import type { Project, ProjectCardItem } from './site'
|
||
|
||
export type MapProject = {
|
||
id: number
|
||
name: string
|
||
/** سال شمسی — ممکن است خالی باشد */
|
||
year: string
|
||
/** پروژهی در جریان (ستون new شیت) */
|
||
isNew: boolean
|
||
lat: number
|
||
lng: number
|
||
description: string
|
||
}
|
||
|
||
/** مرکز پیشفرض نقشه — اصفهان */
|
||
export const ISFAHAN_CENTER: [number, number] = [51.6675, 32.6539]
|
||
|
||
/**
|
||
* مختصات دفتر مرکزی (اصفهان، خیابان ملاصدرا شمالی) — همان نقطهای که سمت
|
||
* بکاند در اسکیمای LocalBusiness بهعنوان geo/hasMap میآید (seo_pages.py:
|
||
* OFFICE_GEO). دو مقدار باید همیشه یکی بمانند؛ تغییر یعنی ویرایش هر دو جا.
|
||
*/
|
||
export const OFFICE_LOCATION = { lat: 32.61764688216492, lng: 51.666306342927214 }
|
||
|
||
type CsvRow = { name: string; year: string; isNew: boolean; lat: number; lng: number; description: string }
|
||
|
||
/**
|
||
* ستونهای برچسبی شیت حذف شدهاند (نشانی واقعی نداشتند) و ستون توضیحات به
|
||
* description نگاشت شده است. ترتیب: name,year,new,lat,lng,description
|
||
*/
|
||
const RAW_CSV = `name,year,new,lat,lng,description
|
||
پروژه سروش,1381,no,32.6662842,51.701735
|
||
چهرازی-زیتون,1383,no,32.6700428,51.69985
|
||
جعفریان,1384,no,32.6669259,51.701085
|
||
بهار,1385,no,32.6468963,51.6899119
|
||
حسن مرادی,1385,no,32.6704278,51.6985567
|
||
کوثر- هشت بهشت غربی,1386,no,32.6522044,51.6856474
|
||
صبا,1387,no,32.6541363,51.6847828
|
||
شریف واقفی - بهشت,1387,no,32.6500402,51.6919823
|
||
پروژه- فردوس,1388,no,32.6614576,51.706429
|
||
ارم,1388,no,32.6563712,51.710694
|
||
سینا,1388,no,32.6469299,51.6855403
|
||
مسجد سید,1389,no,32.6669953,51.6557085
|
||
هفده شهریور,1389,no,32.7249998,51.6543819
|
||
آرامش رفیع زاده,1390,no,32.6467107,51.6873398
|
||
باهنر- ارشاد,1391,no,32.6822102,51.6690006
|
||
بن بست شریعت,1390,no,32.6459939,51.6897556
|
||
قصر - یاسین,1389,no,32.6663549,51.7065512
|
||
پردیس,1390,no,32.6518961,51.6973621
|
||
آرامش حکیم,1392,no,32.6426783,51.6813477
|
||
آرامش- ادیب,1387,no,32.6789359,51.6771241
|
||
آرامش دخانیات,1393,no,32.6411924,51.6797555
|
||
بهشتی فرشادی,1393,no,32.6531989,51.6790984
|
||
کاکتوس- شریف واقفی,1394,no,32.6486747,51.6859003
|
||
آرامش - صیاد,1395,no,32.6486798,51.712659
|
||
کوثر- میرزاطاهر,1395,no,32.6506204,51.6344053
|
||
آرامش - بوعلی 1,1396,no,32.6455156,51.6918424
|
||
آرامش - میرزا نصیر,1396,no,32.668195,51.7173079
|
||
نیکان میرزاطاهر,1396,no,32.6504663,51.6355982
|
||
آرامش-رسا,1397,no,32.6522126,51.6857297
|
||
نگین- میرزاطاهر,1393,no,32.6507084,51.6313395
|
||
آرامش - بوعلی 2,1398,no,32.645391,51.6917828
|
||
آرامش استاد همایی,1398,no,32.6563505,51.691988
|
||
آرامش- صدر,1399,no,32.6469234,51.6826176
|
||
آرامش - سپهسالار,1399,no,32.6259658,51.7080268
|
||
آرامش - سنایی,1400,no,32.6973339,51.6194128
|
||
مغازه لباس فروشی,1400,no,32.6516965,51.7020497
|
||
آرامش - آتشگاه,1401,no,32.6547584,51.6319244
|
||
آرامش - بزرگمهر,1401,no,32.651381,51.6993575
|
||
آرامش - سعدی,1401,no,32.6970115,51.6196909
|
||
آرامش - صمدیه لباف,1401,no,32.6716585,51.6359248
|
||
آرامش میرزا طاهر 2,1401,no,32.6505704,51.6337863
|
||
آرامش 5 رمضان,1401,no,32.6674407,51.6568827
|
||
آرامش احمدآباد,1401,no,32.6626101,51.6969828
|
||
باهنر- شادی,1390,no,32.6824682,51.6670556
|
||
تجاری آتشگاه,1401,no,32.6515087,51.6033278
|
||
آرامش - شهیدان,1402,no,32.6854968,51.6171964
|
||
آرامش خواجو,1402,no,32.6419187,51.6831873
|
||
آرامش- اداری ابوالحسن,1402,no,32.6453749,51.6915519
|
||
آرامش - انقلاب اسلامی,1403,no,32.719769,51.645921
|
||
آرامش - شریف,1404,no,32.6473319,51.6955996
|
||
آرامش - کهندژ,1404,no,32.6649497,51.630077
|
||
آرامش جی,1404,no,32.6610717,51.7110184
|
||
آرامش زنیث,1405,no,32.674076,51.6368204
|
||
آرامش - شمس آبادی,1405,no,32.6560844,51.664771
|
||
آرامش الیسیان,1405,no,32.6495532,51.6178643
|
||
آرامش - وحدت,1405,no,32.6537564,51.6197007
|
||
آرامش پروین,1405,no,32.6618223,51.7098152
|
||
خلیفه سلطانی,1405,no,32.7035398,51.6223296
|
||
آرامش آوالون,1406,no,32.6517244,51.6202975
|
||
آرامش - میرزا طاهر 4,,no,32.6510328,51.631436
|
||
آرامش- میرزا طاهر 1,1396,no,32.6506236,51.6298238
|
||
آرامش لکسون,1406,no,32.60600917636113,51.67503993694544
|
||
ارغوانیه,1395,no,32.64448932441766,51.77707692162298
|
||
آرامش- ابوریحان,1405,no,32.700027968614734,51.71497771552572
|
||
`
|
||
|
||
/**
|
||
* تجزیهی یک خط CSV — پشتیبانی از فیلدهای گیومهدار و گیومهی دوگانهی درونی.
|
||
* جداکننده کاما و نویسههای مخفی ابتدای فایل (BOM) هم حذف میشوند.
|
||
*/
|
||
function parseCsvLine(line: string): string[] {
|
||
const fields: string[] = []
|
||
let current = ''
|
||
let inQuotes = false
|
||
|
||
for (let i = 0; i < line.length; i += 1) {
|
||
const char = line[i]
|
||
if (inQuotes) {
|
||
if (char === '"') {
|
||
if (line[i + 1] === '"') {
|
||
current += '"'
|
||
i += 1
|
||
} else inQuotes = false
|
||
} else current += char
|
||
} else if (char === '"') inQuotes = true
|
||
else if (char === ',') {
|
||
fields.push(current.trim())
|
||
current = ''
|
||
} else current += char
|
||
}
|
||
fields.push(current.trim())
|
||
return fields
|
||
}
|
||
|
||
/** CSV خام → آرایهی تایپشدهی JSON */
|
||
export function parseProjectsCsv(raw: string): MapProject[] {
|
||
const rows: CsvRow[] = []
|
||
const lines = raw.replace(/^\ufeff/, '').split(/\r?\n/)
|
||
|
||
for (const line of lines.slice(1)) {
|
||
if (!line.trim()) continue
|
||
const [name, year = '', isNew = '', lat = '', lng = '', description = ''] = parseCsvLine(line)
|
||
const parsedLat = Number(lat)
|
||
const parsedLng = Number(lng)
|
||
if (!name || !Number.isFinite(parsedLat) || !Number.isFinite(parsedLng)) continue
|
||
rows.push({ name, year, isNew: isNew.toLowerCase() === 'yes', lat: parsedLat, lng: parsedLng, description })
|
||
}
|
||
|
||
return rows.map((row, index) => ({ id: index + 1, ...row }))
|
||
}
|
||
|
||
export const MAP_PROJECTS: MapProject[] = parseProjectsCsv(RAW_CSV)
|
||
|
||
/**
|
||
* پیوند نقشهٔ گوگل روی مختصات پروژه — قالب رسمی Maps URLs با پارامتر query،
|
||
* بینیاز از کلید API. یک سنجاق دقیق روی همان نقطه مینشاند و روی موبایل
|
||
* اپلیکیشن گوگلمپ را باز میکند.
|
||
*/
|
||
export function buildGoogleMapsUrl(project: Pick<MapProject, 'lat' | 'lng'>): string {
|
||
return `https://www.google.com/maps/search/?api=1&query=${project.lat},${project.lng}`
|
||
}
|
||
|
||
const NAME_NOISE = ['آرامش', 'پروژه', 'مجتمع', 'گروه', 'ساختمانی', 'برج', 'مجموعه']
|
||
|
||
/**
|
||
* نرمالسازی نام برای تطبیق شیت با دادهی بکاند: نویسههای نامرئی،
|
||
* ی/ک عربی، نشانهها و واژههای عمومی حذف میشوند تا «آرامش - سنایی» و
|
||
* «پروژه سنایی» یک کلید بگیرند.
|
||
*/
|
||
export function normalizeProjectName(name: string): string {
|
||
let result = (name ?? '')
|
||
.normalize('NFC')
|
||
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
|
||
.replace(/[يى]/g, 'ی')
|
||
.replace(/ك/g, 'ک')
|
||
.replace(/[ۀة]/g, 'ه')
|
||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||
.toLowerCase()
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
|
||
for (const noise of NAME_NOISE) result = result.replace(new RegExp(`(^|\\s)${noise}(?=\\s|$)`, 'g'), ' ')
|
||
return result.replace(/\s+/g, ' ').trim()
|
||
}
|
||
|
||
/** پروژهی شیت متناظر با یک نام (تطبیق دقیق نرمالشده، سپس دربرگیری) */
|
||
export function findMapProjectByName(name: string): MapProject | undefined {
|
||
const key = normalizeProjectName(name)
|
||
if (!key) return undefined
|
||
|
||
const exact = MAP_PROJECTS.find((project) => normalizeProjectName(project.name) === key)
|
||
if (exact) return exact
|
||
|
||
return MAP_PROJECTS.find((project) => {
|
||
const candidate = normalizeProjectName(project.name)
|
||
return candidate.length >= 3 && key.length >= 3 && (candidate.includes(key) || key.includes(candidate))
|
||
})
|
||
}
|
||
|
||
/**
|
||
* محل جایگزین وقتی پروژهای در شیت پیدا نشد — مرکز اصفهان با نام پروژهی
|
||
* بکاند، تا نمای تکپروژه هرگز بدون نقشه نماند.
|
||
*/
|
||
export function createPlaceholderProject(name: string, description = ''): MapProject {
|
||
return {
|
||
id: 0,
|
||
name,
|
||
year: '',
|
||
isNew: false,
|
||
lat: ISFAHAN_CENTER[1],
|
||
lng: ISFAHAN_CENTER[0],
|
||
description,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* کادر محدودهٔ نقشه از مختصات پروژهها — با حاشیه تا لبهٔ نقشه خالی نماند و
|
||
* کاربر به بیرون از محدودهٔ پروژهها جابهجا نشود.
|
||
*/
|
||
export function boundsForProjects(
|
||
projects: Pick<MapProject, 'lat' | 'lng'>[],
|
||
padding = 0.02,
|
||
): [[number, number], [number, number]] | undefined {
|
||
if (!projects.length) return undefined
|
||
const lats = projects.map((project) => project.lat)
|
||
const lngs = projects.map((project) => project.lng)
|
||
return [
|
||
[Math.min(...lngs) - padding, Math.min(...lats) - padding],
|
||
[Math.max(...lngs) + padding, Math.max(...lats) + padding],
|
||
]
|
||
}
|
||
|
||
/** ردیف شیت → کارت فهرست: عنوان از شیت، وضعیت از ستون new، نشانی از مختصات */
|
||
export function csvProjectToCard(project: MapProject, detailHref: string): ProjectCardItem {
|
||
return {
|
||
id: project.id,
|
||
title: project.name,
|
||
status: project.isNew ? 'progress' : 'done',
|
||
detailHref,
|
||
addressUrl: buildGoogleMapsUrl(project),
|
||
}
|
||
}
|
||
|
||
/** پروژهٔ بکاند → کارت فهرست؛ نشانی نشان از تطبیق نامی با شیت میآید */
|
||
export function apiProjectToCard(project: Project, detailHref: string): ProjectCardItem {
|
||
const matched = findMapProjectByName(project.title)
|
||
return {
|
||
id: project.id,
|
||
title: project.title,
|
||
status: project.status,
|
||
detailHref,
|
||
location: project.location || undefined,
|
||
image: project.image || undefined,
|
||
area: project.area || undefined,
|
||
addressUrl: matched ? buildGoogleMapsUrl(matched) : undefined,
|
||
}
|
||
}
|