- Prevent RmlUi shader from second convertion to SRGB format - Doox Empire regions - ESH regions - Kartilia regions - Country colors are changed
720 lines
30 KiB
Python
720 lines
30 KiB
Python
# -- coding: utf-8 --
|
||
"""
|
||
Region Mapper — работа с двумя JSON: регионы (цвета) и страны (группы).
|
||
Зависимости: pip install PyQt5 Pillow
|
||
Запуск: python nudge.py map.bmp
|
||
"""
|
||
import sys
|
||
import os
|
||
import json
|
||
import math
|
||
from PyQt5.QtCore import Qt, QPoint, QPointF, QRect, QRectF, QTimer, pyqtSignal
|
||
from PyQt5.QtGui import QImage, QPixmap, QPainter, QColor, QPen, QKeySequence
|
||
from PyQt5.QtWidgets import (
|
||
QApplication, QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
|
||
QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem,
|
||
QSplitter, QFileDialog, QMessageBox, QInputDialog, QShortcut,
|
||
QFormLayout
|
||
)
|
||
from PIL import Image
|
||
Image.MAX_IMAGE_PIXELS = None
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Утилиты
|
||
# ----------------------------------------------------------------------
|
||
def rgb_to_hex(rgb):
|
||
return "#{:02X}{:02X}{:02X}".format(*rgb)
|
||
|
||
def hex_to_rgb(s):
|
||
s = s.lstrip("#")
|
||
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
|
||
|
||
def load_map(path):
|
||
img = QImage(path)
|
||
if not img.isNull():
|
||
return img, None
|
||
with Image.open(path) as f:
|
||
pil = f.convert("RGB")
|
||
w, h = pil.size
|
||
raw = pil.tobytes()
|
||
del pil
|
||
img = QImage(raw, w, h, w * 3, QImage.Format_RGB888)
|
||
if img.isNull():
|
||
raise RuntimeError("Не удалось декодировать изображение")
|
||
return img, raw
|
||
|
||
def make_overview(img, max_side=4096):
|
||
w, h = img.width(), img.height()
|
||
k = min(1.0, max_side / float(max(w, h)))
|
||
if k >= 1.0:
|
||
small = img
|
||
else:
|
||
small = img.scaled(max(1, int(w * k)), max(1, int(h * k)),
|
||
Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
|
||
return QPixmap.fromImage(small)
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Просмотр карты
|
||
# ----------------------------------------------------------------------
|
||
class MapViewer(QWidget):
|
||
pixelPicked = pyqtSignal(int, int, tuple)
|
||
zoomChanged = pyqtSignal(float)
|
||
cursorMoved = pyqtSignal(int, int)
|
||
|
||
LOD_SWITCH = 0.25
|
||
MAX_SCALE = 64.0
|
||
|
||
def __init__(self, image, raw_buffer, parent=None):
|
||
super().__init__(parent)
|
||
self.image = image
|
||
self._raw = raw_buffer
|
||
self.W = image.width()
|
||
self.H = image.height()
|
||
print("Размер карты: {} x {} пикселей".format(self.W, self.H))
|
||
print("Создаю уменьшенную копию...")
|
||
self.overview = make_overview(image)
|
||
|
||
self.scale = 1.0
|
||
self.offset = QPointF(0, 0)
|
||
self._fit_scale = 1.0
|
||
self._fitted = False
|
||
self._panning = False
|
||
self._pan_last = QPoint()
|
||
self._press_pos = None
|
||
self._press_offset = QPointF()
|
||
self._dragging = False
|
||
self.marker = None
|
||
|
||
self.setMouseTracking(True)
|
||
self.setFocusPolicy(Qt.StrongFocus)
|
||
self.setMinimumSize(200, 120)
|
||
|
||
def _to_image(self, pos):
|
||
x = math.floor((pos.x() - self.offset.x()) / self.scale)
|
||
y = math.floor((pos.y() - self.offset.y()) / self.scale)
|
||
return x, y
|
||
|
||
def fitToWindow(self):
|
||
vw, vh = self.width(), self.height()
|
||
if vw < 2 or vh < 2: return
|
||
s = min(vw / self.W, vh / self.H)
|
||
self._fit_scale = s
|
||
self.scale = s
|
||
self.offset = QPointF((vw - self.W * s) / 2.0, (vh - self.H * s) / 2.0)
|
||
self._fitted = True
|
||
self.update()
|
||
self.zoomChanged.emit(s)
|
||
|
||
def actualSize(self):
|
||
self._zoom_at(QPointF(self.width() / 2, self.height() / 2), 1.0)
|
||
|
||
def zoomIn(self):
|
||
self._zoom_at(QPointF(self.width() / 2, self.height() / 2), self.scale * 1.5)
|
||
|
||
def zoomOut(self):
|
||
self._zoom_at(QPointF(self.width() / 2, self.height() / 2), self.scale / 1.5)
|
||
|
||
def _zoom_at(self, point, new_scale):
|
||
new_scale = max(self._fit_scale * 0.25, min(self.MAX_SCALE, new_scale))
|
||
ix = (point.x() - self.offset.x()) / self.scale
|
||
iy = (point.y() - self.offset.y()) / self.scale
|
||
self.scale = new_scale
|
||
self.offset = QPointF(point.x() - ix * new_scale, point.y() - iy * new_scale)
|
||
self.update()
|
||
self.zoomChanged.emit(new_scale)
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
if not self._fitted: self.fitToWindow()
|
||
|
||
def paintEvent(self, event):
|
||
p = QPainter(self)
|
||
p.fillRect(self.rect(), QColor(45, 45, 48))
|
||
p.setRenderHint(QPainter.SmoothPixmapTransform, self.scale < 1.0)
|
||
s = self.scale
|
||
ox, oy = self.offset.x(), self.offset.y()
|
||
|
||
if s < self.LOD_SWITCH:
|
||
target = QRectF(ox, oy, self.W * s, self.H * s)
|
||
p.drawPixmap(target, self.overview, QRectF(0, 0, self.overview.width(), self.overview.height()))
|
||
else:
|
||
x0 = max(0, int(math.floor(-ox / s)))
|
||
y0 = max(0, int(math.floor(-oy / s)))
|
||
x1 = min(self.W, int(math.ceil((self.width() - ox) / s)))
|
||
y1 = min(self.H, int(math.ceil((self.height() - oy) / s)))
|
||
if x1 > x0 and y1 > y0:
|
||
src = QRect(x0, y0, x1 - x0, y1 - y0)
|
||
target = QRectF(ox + x0 * s, oy + y0 * s, (x1 - x0) * s, (y1 - y0) * s)
|
||
p.drawImage(target, self.image, QRectF(src))
|
||
|
||
p.setPen(QPen(QColor(95, 95, 95), 1))
|
||
p.drawRect(QRectF(ox, oy, self.W * s, self.H * s))
|
||
|
||
if self.marker is not None and s >= 4:
|
||
mx, my = self.marker
|
||
p.setPen(QPen(QColor(255, 255, 255), 1))
|
||
p.drawRect(QRectF(ox + mx * s, oy + my * s, s, s))
|
||
p.end()
|
||
|
||
def wheelEvent(self, event):
|
||
d = event.angleDelta().y()
|
||
if d:
|
||
factor = 1.25 if d > 0 else 1.0 / 1.25
|
||
self._zoom_at(QPointF(event.pos()), self.scale * factor)
|
||
|
||
def mousePressEvent(self, event):
|
||
if event.button() in (Qt.RightButton, Qt.MiddleButton):
|
||
self._panning = True
|
||
self._pan_last = event.pos()
|
||
self.setCursor(Qt.ClosedHandCursor)
|
||
elif event.button() == Qt.LeftButton:
|
||
self._press_pos = event.pos()
|
||
self._press_offset = QPointF(self.offset)
|
||
self._dragging = False
|
||
|
||
def mouseMoveEvent(self, event):
|
||
if self._panning:
|
||
d = event.pos() - self._pan_last
|
||
self._pan_last = event.pos()
|
||
self.offset = QPointF(self.offset.x() + d.x(), self.offset.y() + d.y())
|
||
self.update()
|
||
elif self._press_pos is not None:
|
||
d = event.pos() - self._press_pos
|
||
if d.manhattanLength() > 4: self._dragging = True
|
||
if self._dragging:
|
||
self.offset = QPointF(self._press_offset.x() + d.x(), self._press_offset.y() + d.y())
|
||
self.update()
|
||
else:
|
||
x, y = self._to_image(event.pos())
|
||
if 0 <= x < self.W and 0 <= y < self.H: self.cursorMoved.emit(x, y)
|
||
else: self.cursorMoved.emit(-1, -1)
|
||
|
||
def mouseReleaseEvent(self, event):
|
||
if event.button() in (Qt.RightButton, Qt.MiddleButton):
|
||
self._panning = False
|
||
self.setCursor(Qt.ArrowCursor)
|
||
elif event.button() == Qt.LeftButton and self._press_pos is not None:
|
||
if not self._dragging: self._pick(event.pos())
|
||
self._press_pos = None
|
||
self._dragging = False
|
||
|
||
def _pick(self, pos):
|
||
x, y = self._to_image(pos)
|
||
if 0 <= x < self.W and 0 <= y < self.H:
|
||
c = self.image.pixelColor(x, y)
|
||
self.marker = (x, y)
|
||
self.update()
|
||
self.pixelPicked.emit(x, y, (c.red(), c.green(), c.blue()))
|
||
|
||
def keyPressEvent(self, event):
|
||
if event.key() in (Qt.Key_0, Qt.Key_F): self.fitToWindow()
|
||
elif event.key() == Qt.Key_1: self.actualSize()
|
||
elif event.key() in (Qt.Key_Plus, Qt.Key_Equal): self.zoomIn()
|
||
elif event.key() == Qt.Key_Minus: self.zoomOut()
|
||
else: super().keyPressEvent(event)
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Окно
|
||
# ----------------------------------------------------------------------
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self, path):
|
||
super().__init__()
|
||
self.setWindowTitle("Region Mapper — " + os.path.basename(path))
|
||
self.resize(1400, 850)
|
||
|
||
# --- Файл 1: Регионы (плоский словарь) ---
|
||
self.mapping = {} # name -> "#RRGGBB"
|
||
self.by_color = {} # (r, g, b) -> name
|
||
|
||
# --- Файл 2: Страны (вложенная структура) ---
|
||
self.subjects_list = [] # список тегов для сохранения порядка
|
||
self.subjects = {} # tag -> {"color": str, "regions": list}
|
||
|
||
self.current_rgb = None
|
||
self._shown_once = False
|
||
|
||
image, raw = load_map(path)
|
||
self.viewer = MapViewer(image, raw)
|
||
self.viewer.pixelPicked.connect(self.on_pixel)
|
||
self.viewer.zoomChanged.connect(lambda s: self.zoom_label.setText("Масштаб: {:.4f}×".format(s)))
|
||
self.viewer.cursorMoved.connect(lambda x, y: self.coord_label.setText("X: {} Y: {}".format(x, y)))
|
||
self._build_ui()
|
||
|
||
def showEvent(self, event):
|
||
super().showEvent(event)
|
||
if not self._shown_once:
|
||
self._shown_once = True
|
||
QTimer.singleShot(0, self.viewer.fitToWindow)
|
||
|
||
def _build_ui(self):
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
root = QHBoxLayout(central)
|
||
root.setContentsMargins(6, 6, 6, 6)
|
||
splitter = QSplitter(Qt.Horizontal)
|
||
|
||
# --- слева: карта ---
|
||
left = QWidget()
|
||
lv = QVBoxLayout(left)
|
||
lv.setContentsMargins(0, 0, 0, 0)
|
||
tb = QHBoxLayout()
|
||
for text, slot in (("Вписать (0)", self.viewer.fitToWindow),
|
||
("1:1", self.viewer.actualSize),
|
||
("+", self.viewer.zoomIn),
|
||
("−", self.viewer.zoomOut)):
|
||
b = QPushButton(text)
|
||
b.clicked.connect(slot)
|
||
tb.addWidget(b)
|
||
self.zoom_label = QLabel("Масштаб: —")
|
||
tb.addWidget(self.zoom_label)
|
||
tb.addStretch()
|
||
lv.addLayout(tb)
|
||
lv.addWidget(self.viewer, 1)
|
||
splitter.addWidget(left)
|
||
|
||
# --- справа: панель управления ---
|
||
panel = QWidget()
|
||
pv = QVBoxLayout(panel)
|
||
pv.setContentsMargins(8, 8, 8, 8)
|
||
pv.setSpacing(6)
|
||
|
||
# Блок текущего пикселя
|
||
pv.addWidget(QLabel("Текущий пиксель:"))
|
||
row = QHBoxLayout()
|
||
self.swatch = QLabel()
|
||
self.swatch.setFixedSize(64, 44)
|
||
self.swatch.setStyleSheet("background:#808080; border:1px solid #555;")
|
||
row.addWidget(self.swatch)
|
||
self.color_info = QLabel("Кликните ЛКМ по карте")
|
||
self.color_info.setWordWrap(True)
|
||
row.addWidget(self.color_info, 1)
|
||
pv.addLayout(row)
|
||
|
||
# ================= БЛОК РЕГИОНОВ (ФАЙЛ 1) =================
|
||
pv.addSpacing(10)
|
||
pv.addWidget(QLabel("1. Регионы (Название и цвет):"))
|
||
self.name_input = QLineEdit()
|
||
self.name_input.setPlaceholderText("Например: pepene")
|
||
self.name_input.returnPressed.connect(self.commit)
|
||
pv.addWidget(self.name_input)
|
||
|
||
btn = QPushButton("Назначить / обновить цвет [Enter]")
|
||
btn.clicked.connect(self.commit)
|
||
pv.addWidget(btn)
|
||
|
||
self.list_widget = QListWidget()
|
||
self.list_widget.itemDoubleClicked.connect(lambda it: self.rename_region(it.data(Qt.UserRole)))
|
||
pv.addWidget(self.list_widget, 1)
|
||
|
||
row2 = QHBoxLayout()
|
||
b1 = QPushButton("Переименовать")
|
||
b1.clicked.connect(lambda: self.rename_region())
|
||
b2 = QPushButton("Удалить")
|
||
b2.clicked.connect(self.delete_region)
|
||
row2.addWidget(b1)
|
||
row2.addWidget(b2)
|
||
pv.addLayout(row2)
|
||
|
||
# ================= БЛОК СТРАН (ФАЙЛ 2) =================
|
||
pv.addSpacing(12)
|
||
pv.addWidget(QLabel("2. Страны (Теги и группы регионов):"))
|
||
|
||
subj_form = QFormLayout()
|
||
subj_form.setContentsMargins(0, 0, 0, 0)
|
||
subj_form.setSpacing(4)
|
||
|
||
self.subject_tag_input = QLineEdit()
|
||
self.subject_tag_input.setPlaceholderText("Тег (например, UCB)")
|
||
subj_form.addRow("Тег:", self.subject_tag_input)
|
||
|
||
self.subject_color_input = QLineEdit()
|
||
self.subject_color_input.setPlaceholderText("Цвет (например, #E5EA5D)")
|
||
subj_form.addRow("Цвет:", self.subject_color_input)
|
||
pv.addLayout(subj_form)
|
||
|
||
btn_use_color = QPushButton("Взять цвет текущего пикселя")
|
||
btn_use_color.clicked.connect(self.use_current_color_for_subject)
|
||
pv.addWidget(btn_use_color)
|
||
|
||
btn_save_subject = QPushButton("Создать / Обновить страну")
|
||
btn_save_subject.clicked.connect(self.commit_subject)
|
||
pv.addWidget(btn_save_subject)
|
||
|
||
self.subject_list = QListWidget()
|
||
self.subject_list.currentItemChanged.connect(self.on_subject_selected)
|
||
pv.addWidget(self.subject_list)
|
||
|
||
pv.addWidget(QLabel("Регионы в выбранной стране:"))
|
||
self.subject_regions_list = QListWidget()
|
||
pv.addWidget(self.subject_regions_list)
|
||
|
||
row_subj = QHBoxLayout()
|
||
b_rem_reg = QPushButton("Удалить из страны")
|
||
b_rem_reg.clicked.connect(self.remove_region_from_subject)
|
||
b_del_subj = QPushButton("Удалить страну")
|
||
b_del_subj.clicked.connect(self.delete_subject)
|
||
row_subj.addWidget(b_rem_reg)
|
||
row_subj.addWidget(b_del_subj)
|
||
pv.addLayout(row_subj)
|
||
|
||
# ================= КНОПКИ ФАЙЛОВ =================
|
||
pv.addSpacing(12)
|
||
pv.addWidget(QLabel("--- Файл регионов (JSON 1) ---"))
|
||
row_files1 = QHBoxLayout()
|
||
b_open_reg = QPushButton("Открыть регионы")
|
||
b_open_reg.clicked.connect(self.load_regions_json)
|
||
b_save_reg = QPushButton("Сохранить регионы")
|
||
b_save_reg.setStyleSheet("background:#1565c0; color:white; font-weight:bold; padding:4px;")
|
||
b_save_reg.clicked.connect(self.save_regions_json)
|
||
row_files1.addWidget(b_open_reg)
|
||
row_files1.addWidget(b_save_reg)
|
||
pv.addLayout(row_files1)
|
||
|
||
pv.addSpacing(6)
|
||
pv.addWidget(QLabel("--- Файл стран (JSON 2) ---"))
|
||
row_files2 = QHBoxLayout()
|
||
b_open_subj = QPushButton("Открыть страны")
|
||
b_open_subj.clicked.connect(self.load_subjects_json)
|
||
b_save_subj = QPushButton("Сохранить страны")
|
||
b_save_subj.setStyleSheet("background:#2e7d32; color:white; font-weight:bold; padding:4px;")
|
||
b_save_subj.clicked.connect(self.save_subjects_json)
|
||
row_files2.addWidget(b_open_subj)
|
||
row_files2.addWidget(b_save_subj)
|
||
pv.addLayout(row_files2)
|
||
|
||
splitter.addWidget(panel)
|
||
splitter.setStretchFactor(0, 1)
|
||
splitter.setStretchFactor(1, 0)
|
||
splitter.setSizes([1000, 500])
|
||
root.addWidget(splitter)
|
||
|
||
self.coord_label = QLabel("X: — Y: —")
|
||
self.statusBar().addPermanentWidget(self.coord_label)
|
||
self.statusBar().showMessage("ЛКМ — пиксель; ПКМ/ЛКМ-драг — панорама; колесо — зум")
|
||
|
||
# ---------- Логика пикселя и регионов ----------
|
||
def on_pixel(self, x, y, rgb):
|
||
self.current_rgb = rgb
|
||
hexv = rgb_to_hex(rgb)
|
||
self.swatch.setStyleSheet("background:{}; border:1px solid #555;".format(hexv))
|
||
text = "{}\nRGB ({}, {}, {})\nПиксель ({}, {})".format(hexv, rgb[0], rgb[1], rgb[2], x, y)
|
||
owner = self.by_color.get(rgb)
|
||
if owner:
|
||
text += "\nУже назначен: " + owner
|
||
self.name_input.setText(owner)
|
||
self._select_in_list(owner)
|
||
else:
|
||
self.name_input.clear()
|
||
self.color_info.setText(text)
|
||
self.name_input.setFocus()
|
||
self.name_input.selectAll()
|
||
|
||
# Автодобавление в выбранную страну при клике
|
||
current_subject_item = self.subject_list.currentItem()
|
||
if current_subject_item:
|
||
tag = current_subject_item.data(Qt.UserRole)
|
||
region_name = self.by_color.get(rgb)
|
||
if region_name:
|
||
self.add_region_to_subject(tag, region_name)
|
||
|
||
def commit(self):
|
||
name = self.name_input.text().strip()
|
||
if not name:
|
||
QMessageBox.warning(self, "Название", "Введите название региона.")
|
||
return
|
||
if self.current_rgb is None:
|
||
QMessageBox.warning(self, "Цвет", "Сначала кликните по пикселю на карте.")
|
||
return
|
||
rgb = self.current_rgb
|
||
hexv = rgb_to_hex(rgb)
|
||
|
||
owner = self.by_color.get(rgb)
|
||
if owner is not None and owner != name:
|
||
ret = QMessageBox.question(self, "Цвет занят",
|
||
"Цвет {} уже назначен региону «{}».\nПереназначить его региону «{}»?".format(hexv, owner, name),
|
||
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
||
if ret != QMessageBox.Yes: return
|
||
self.mapping.pop(owner, None)
|
||
self.by_color.pop(rgb, None)
|
||
|
||
if name in self.mapping:
|
||
old_rgb = hex_to_rgb(self.mapping[name])
|
||
if self.by_color.get(old_rgb) == name:
|
||
self.by_color.pop(old_rgb, None)
|
||
|
||
self.mapping[name] = hexv
|
||
self.by_color[rgb] = name
|
||
self._rebuild_list(select=name)
|
||
self.name_input.clear()
|
||
self.name_input.setFocus()
|
||
|
||
current_subject_item = self.subject_list.currentItem()
|
||
if current_subject_item:
|
||
tag = current_subject_item.data(Qt.UserRole)
|
||
self.add_region_to_subject(tag, name)
|
||
|
||
def _rebuild_list(self, select=None):
|
||
self.list_widget.clear()
|
||
for i, (name, hexv) in enumerate(self.mapping.items(), 1):
|
||
it = QListWidgetItem("{:>3}. {} {}".format(i, name, hexv))
|
||
it.setData(Qt.UserRole, name)
|
||
it.setBackground(QColor(hexv))
|
||
c = QColor(hexv)
|
||
bright = 0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue()
|
||
it.setForeground(QColor(20, 20, 20) if bright > 140 else QColor(245, 245, 245))
|
||
self.list_widget.addItem(it)
|
||
if select is not None: self._select_in_list(select)
|
||
|
||
def _select_in_list(self, name):
|
||
for i in range(self.list_widget.count()):
|
||
if self.list_widget.item(i).data(Qt.UserRole) == name:
|
||
self.list_widget.setCurrentRow(i)
|
||
return
|
||
|
||
def rename_region(self, name=None):
|
||
if name is None:
|
||
it = self.list_widget.currentItem()
|
||
if it is None: return
|
||
name = it.data(Qt.UserRole)
|
||
new, ok = QInputDialog.getText(self, "Переименовать регион", "Новое название:", QLineEdit.Normal, name)
|
||
if not ok: return
|
||
new = new.strip()
|
||
if not new or new == name: return
|
||
if new in self.mapping:
|
||
QMessageBox.warning(self, "Дубликат", "Регион с таким названием уже есть.")
|
||
return
|
||
|
||
# Обновляем имя во всех странах
|
||
for tag in self.subjects_list:
|
||
regions = self.subjects[tag]["regions"]
|
||
if name in regions:
|
||
idx = regions.index(name)
|
||
regions[idx] = new
|
||
|
||
reordered = {}
|
||
for k, v in self.mapping.items():
|
||
reordered[new if k == name else k] = v
|
||
self.mapping = reordered
|
||
|
||
rgb = hex_to_rgb(self.mapping[new])
|
||
self.by_color.pop(rgb, None)
|
||
self.by_color[rgb] = new
|
||
self._rebuild_list(select=new)
|
||
if self.subject_list.currentItem(): self._update_subject_regions()
|
||
|
||
def delete_region(self):
|
||
it = self.list_widget.currentItem()
|
||
if it is None: return
|
||
name = it.data(Qt.UserRole)
|
||
hexv = self.mapping.pop(name, None)
|
||
if hexv is not None:
|
||
rgb = hex_to_rgb(hexv)
|
||
if self.by_color.get(rgb) == name: self.by_color.pop(rgb, None)
|
||
|
||
# Удаляем из всех стран
|
||
for tag in self.subjects_list:
|
||
regions = self.subjects[tag]["regions"]
|
||
if name in regions: regions.remove(name)
|
||
|
||
self._rebuild_list()
|
||
if self.subject_list.currentItem(): self._update_subject_regions()
|
||
|
||
# ---------- Логика Стран ----------
|
||
def use_current_color_for_subject(self):
|
||
if self.current_rgb:
|
||
self.subject_color_input.setText(rgb_to_hex(self.current_rgb))
|
||
else:
|
||
QMessageBox.information(self, "Цвет", "Сначала кликните по пикселю на карте.")
|
||
|
||
def commit_subject(self):
|
||
tag = self.subject_tag_input.text().strip()
|
||
color = self.subject_color_input.text().strip().upper()
|
||
if not tag:
|
||
QMessageBox.warning(self, "Тег", "Введите тег страны.")
|
||
return
|
||
if not color.startswith("#") or len(color) != 7:
|
||
QMessageBox.warning(self, "Цвет", "Введите корректный HEX цвет (например, #E5EA5D).")
|
||
return
|
||
|
||
if tag in self.subjects:
|
||
self.subjects[tag]["color"] = color
|
||
else:
|
||
self.subjects_list.append(tag)
|
||
self.subjects[tag] = {"color": color, "regions": []}
|
||
|
||
self._rebuild_subject_list(select=tag)
|
||
self.subject_tag_input.clear()
|
||
self.subject_color_input.clear()
|
||
|
||
def _rebuild_subject_list(self, select=None):
|
||
self.subject_list.clear()
|
||
for tag in self.subjects_list:
|
||
data = self.subjects[tag]
|
||
color = data["color"]
|
||
text = "{} ({}) [{} рег.]".format(tag, color, len(data["regions"]))
|
||
it = QListWidgetItem(text)
|
||
it.setData(Qt.UserRole, tag)
|
||
it.setBackground(QColor(color))
|
||
c = QColor(color)
|
||
bright = 0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue()
|
||
it.setForeground(QColor(20, 20, 20) if bright > 140 else QColor(245, 245, 245))
|
||
self.subject_list.addItem(it)
|
||
|
||
if select:
|
||
for i in range(self.subject_list.count()):
|
||
if self.subject_list.item(i).data(Qt.UserRole) == select:
|
||
self.subject_list.setCurrentRow(i)
|
||
break
|
||
self._update_subject_regions()
|
||
|
||
def on_subject_selected(self, current, previous):
|
||
self._update_subject_regions()
|
||
|
||
def _update_subject_regions(self):
|
||
self.subject_regions_list.clear()
|
||
item = self.subject_list.currentItem()
|
||
if not item: return
|
||
tag = item.data(Qt.UserRole)
|
||
regions = self.subjects[tag]["regions"]
|
||
for r in regions:
|
||
self.subject_regions_list.addItem(r)
|
||
|
||
def add_region_to_subject(self, tag, region_name):
|
||
if tag not in self.subjects: return
|
||
if region_name not in self.subjects[tag]["regions"]:
|
||
self.subjects[tag]["regions"].append(region_name)
|
||
if self.subject_list.currentItem() and self.subject_list.currentItem().data(Qt.UserRole) == tag:
|
||
self._update_subject_regions()
|
||
self._rebuild_subject_list(select=tag)
|
||
|
||
def remove_region_from_subject(self):
|
||
item = self.subject_list.currentItem()
|
||
reg_item = self.subject_regions_list.currentItem()
|
||
if not item or not reg_item: return
|
||
tag = item.data(Qt.UserRole)
|
||
region_name = reg_item.text()
|
||
if region_name in self.subjects[tag]["regions"]:
|
||
self.subjects[tag]["regions"].remove(region_name)
|
||
self._update_subject_regions()
|
||
self._rebuild_subject_list(select=tag)
|
||
|
||
def delete_subject(self):
|
||
item = self.subject_list.currentItem()
|
||
if not item: return
|
||
tag = item.data(Qt.UserRole)
|
||
if tag in self.subjects_list: self.subjects_list.remove(tag)
|
||
if tag in self.subjects: del self.subjects[tag]
|
||
self._rebuild_subject_list()
|
||
|
||
# ---------- Работа с ФАЙЛОМ 1 (Регионы) ----------
|
||
def save_regions_json(self):
|
||
if not self.mapping:
|
||
QMessageBox.information(self, "Пусто", "Сначала добавьте регионы.")
|
||
return
|
||
path, _ = QFileDialog.getSaveFileName(self, "Сохранить регионы", "regions.json", "JSON (*.json)")
|
||
if not path: return
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(self.mapping, f, ensure_ascii=False, indent=2)
|
||
f.write("\n")
|
||
self.statusBar().showMessage("Сохранено регионов: {} → {}".format(len(self.mapping), path), 8000)
|
||
|
||
def load_regions_json(self):
|
||
path, _ = QFileDialog.getOpenFileName(self, "Открыть регионы", "", "JSON (*.json)")
|
||
if not path: return
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
if not isinstance(data, dict): raise ValueError("Ожидался JSON-объект (словарь)")
|
||
|
||
mapping, by_color = {}, {}
|
||
for name, value in data.items():
|
||
if not isinstance(value, str): continue
|
||
v = value.strip().upper()
|
||
if len(v) != 7 or not v.startswith("#"): continue
|
||
try: rgb = hex_to_rgb(v)
|
||
except ValueError: continue
|
||
name = str(name)
|
||
if rgb in by_color: continue
|
||
mapping[name] = rgb_to_hex(rgb)
|
||
by_color[rgb] = name
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Ошибка чтения", str(e))
|
||
return
|
||
|
||
self.mapping = mapping
|
||
self.by_color = by_color
|
||
self._rebuild_list()
|
||
self.statusBar().showMessage("Загружено регионов: {}".format(len(mapping)), 5000)
|
||
|
||
# ---------- Работа с ФАЙЛОМ 2 (Страны) ----------
|
||
def save_subjects_json(self):
|
||
if not self.subjects_list:
|
||
QMessageBox.information(self, "Пусто", "Сначала добавьте страны.")
|
||
return
|
||
path, _ = QFileDialog.getSaveFileName(self, "Сохранить страны", "subjects.json", "JSON (*.json)")
|
||
if not path: return
|
||
|
||
data = {"content": {"subjects": []}}
|
||
for tag in self.subjects_list:
|
||
subj = self.subjects[tag]
|
||
data["content"]["subjects"].append({
|
||
"tag": tag,
|
||
"color": subj["color"],
|
||
"regions": subj["regions"]
|
||
})
|
||
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
f.write("\n")
|
||
self.statusBar().showMessage("Сохранено стран: {} → {}".format(len(self.subjects_list), path), 8000)
|
||
|
||
def load_subjects_json(self):
|
||
path, _ = QFileDialog.getOpenFileName(self, "Открыть страны", "", "JSON (*.json)")
|
||
if not path: return
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
|
||
if "content" not in data or "subjects" not in data["content"]:
|
||
raise ValueError("Ожидался формат с 'content' -> 'subjects'")
|
||
|
||
self.subjects_list = []
|
||
self.subjects = {}
|
||
for subj in data["content"]["subjects"]:
|
||
tag = str(subj.get("tag", ""))
|
||
color = str(subj.get("color", "")).strip().upper()
|
||
regions = [str(r) for r in subj.get("regions", [])]
|
||
if tag and color.startswith("#") and len(color) == 7:
|
||
self.subjects_list.append(tag)
|
||
self.subjects[tag] = {"color": color, "regions": regions}
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Ошибка чтения", str(e))
|
||
return
|
||
|
||
self._rebuild_subject_list()
|
||
self.statusBar().showMessage("Загружено стран: {}".format(len(self.subjects_list)), 5000)
|
||
|
||
# ----------------------------------------------------------------------
|
||
def main():
|
||
app = QApplication(sys.argv)
|
||
app.setApplicationName("Region Mapper")
|
||
if len(sys.argv) > 1:
|
||
path = sys.argv[1]
|
||
else:
|
||
path, _ = QFileDialog.getOpenFileName(
|
||
None, "Открыть карту", "",
|
||
"Изображения (*.bmp *.png *.jpg *.jpeg *.tif *.tiff);;Все файлы (*)")
|
||
if not path: return
|
||
if not os.path.isfile(path):
|
||
QMessageBox.critical(None, "Файл не найден", path)
|
||
return
|
||
try:
|
||
win = MainWindow(path)
|
||
except Exception as exc:
|
||
QMessageBox.critical(None, "Не удалось открыть карту", repr(exc))
|
||
raise
|
||
win.show()
|
||
sys.exit(app.exec_())
|
||
|
||
if __name__ == "__main__":
|
||
main() |