add: галерея (1) одиночная картинка
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import io
|
||||
import math
|
||||
import random
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import BinaryIO, Union
|
||||
|
||||
@@ -281,3 +282,101 @@ def prepare_gallery_svg(svg_content: str) -> str:
|
||||
return svg_content.replace("</style>", f"{hover_css}</style>", 1)
|
||||
|
||||
return svg_content
|
||||
|
||||
|
||||
def prepare_active_svg(svg_content: str) -> str:
|
||||
"""
|
||||
Возвращает полностью активный SVG без паузы анимации вне ховера
|
||||
для страницы детального просмотра и скачивания пользователем.
|
||||
"""
|
||||
if not svg_content:
|
||||
return svg_content
|
||||
|
||||
hover_css = (
|
||||
"svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,"
|
||||
"svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}"
|
||||
)
|
||||
return svg_content.replace(hover_css, "")
|
||||
|
||||
|
||||
def analyze_svg_structure(svg_content: str) -> dict:
|
||||
"""
|
||||
Анализирует разметку SVG и возвращает наукообразную детальную статистику
|
||||
по геометрическим примитивам, классам, анимациям и энтропии данных.
|
||||
"""
|
||||
if not svg_content:
|
||||
return {
|
||||
"total_bytes": 0,
|
||||
"total_kb": 0.0,
|
||||
"total_oscillators": 0,
|
||||
"use_count": 0,
|
||||
"circle_count": 0,
|
||||
"rect_count": 0,
|
||||
"polygon_count": 0,
|
||||
"path_count": 0,
|
||||
"defs_count": 0,
|
||||
"groups_count": 0,
|
||||
"keyframes_count": 0,
|
||||
"classes_count": 0,
|
||||
"viewbox": "0 0 1000 1000",
|
||||
"width": 1000,
|
||||
"height": 1000,
|
||||
"aspect_ratio": "1:1",
|
||||
"density": 0.0,
|
||||
"bytes_per_quantum": 0.0,
|
||||
}
|
||||
|
||||
total_bytes = len(svg_content.encode("utf-8"))
|
||||
|
||||
# Подсчет векторных сущностей
|
||||
use_count = len(re.findall(r"<use\b", svg_content))
|
||||
circle_count = len(re.findall(r"<circle\b", svg_content))
|
||||
rect_count = len(re.findall(r"<rect\b", svg_content))
|
||||
polygon_count = len(re.findall(r"<polygon\b", svg_content))
|
||||
path_count = len(re.findall(r"<path\b", svg_content))
|
||||
defs_count = circle_count + rect_count + polygon_count + path_count
|
||||
|
||||
# Группы и классы
|
||||
groups_count = len(re.findall(r"<g\b", svg_content))
|
||||
keyframes_count = len(re.findall(r"@keyframes\b", svg_content))
|
||||
classes = set(re.findall(r"\.([a-zA-Z0-9_-]+)\s*\{", svg_content))
|
||||
classes_count = len(classes)
|
||||
|
||||
# ViewBox и геометрия
|
||||
vb_match = re.search(r'viewBox=["\']([^"\']+)["\']', svg_content)
|
||||
viewbox = vb_match.group(1) if vb_match else "0 0 1000 1000"
|
||||
vb_parts = viewbox.split()
|
||||
try:
|
||||
width = int(float(vb_parts[2])) if len(vb_parts) >= 3 else 1000
|
||||
height = int(float(vb_parts[3])) if len(vb_parts) >= 4 else 1000
|
||||
except (ValueError, IndexError):
|
||||
width, height = 1000, 1000
|
||||
|
||||
gcd_val = math.gcd(width, height)
|
||||
aspect_ratio = f"{width // gcd_val}:{height // gcd_val}" if gcd_val > 0 else "1:1"
|
||||
|
||||
# Наукообразные показатели
|
||||
total_oscillators = use_count if use_count > 0 else defs_count
|
||||
bytes_per_quantum = round(total_bytes / max(total_oscillators, 1), 1)
|
||||
density = round(total_oscillators / max((width * height) / 100000.0, 1.0), 2)
|
||||
|
||||
return {
|
||||
"total_bytes": total_bytes,
|
||||
"total_kb": round(total_bytes / 1024, 2),
|
||||
"total_oscillators": total_oscillators,
|
||||
"use_count": use_count,
|
||||
"circle_count": circle_count,
|
||||
"rect_count": rect_count,
|
||||
"polygon_count": polygon_count,
|
||||
"path_count": path_count,
|
||||
"defs_count": defs_count,
|
||||
"groups_count": groups_count,
|
||||
"keyframes_count": keyframes_count,
|
||||
"classes_count": classes_count,
|
||||
"viewbox": viewbox,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"density": density,
|
||||
"bytes_per_quantum": bytes_per_quantum,
|
||||
}
|
||||
|
||||
+265
-1
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
@@ -6,7 +7,13 @@ from PIL import Image
|
||||
|
||||
from .forms import HalftoneGenerateForm
|
||||
from .models import TbHypn0Item, TbVote
|
||||
from .services.halftone import encode_to_base36, generate_halftone_svg, prepare_gallery_svg
|
||||
from .services.halftone import (
|
||||
analyze_svg_structure,
|
||||
encode_to_base36,
|
||||
generate_halftone_svg,
|
||||
prepare_active_svg,
|
||||
prepare_gallery_svg,
|
||||
)
|
||||
from .services.naming import generate_hypno_title, generate_title_openrouter
|
||||
|
||||
|
||||
@@ -304,3 +311,260 @@ class PublishViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Нет данных SVG для публикации")
|
||||
self.assertEqual(TbHypn0Item.objects.count(), 0)
|
||||
|
||||
|
||||
class SvgAnalysisAndActiveSvgTests(TestCase):
|
||||
"""Тестирование анализа структуры SVG и очистки от паузы."""
|
||||
|
||||
def test_prepare_active_svg(self):
|
||||
gallery_svg = '<svg><style>svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}</style><g></g></svg>'
|
||||
active = prepare_active_svg(gallery_svg)
|
||||
self.assertNotIn("animation-play-state:paused!important", active)
|
||||
|
||||
def test_analyze_svg_structure(self):
|
||||
svg = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">'
|
||||
'<style>.a0{--d:0s}.a1{--d:1s}@keyframes noise{0%{opacity:1}100%{opacity:0.5}}</style>'
|
||||
'<defs><circle id="s0" r="5"/><rect id="s1" width="10" height="10"/></defs>'
|
||||
'<g id="hypn0-canvas">'
|
||||
'<g class="a0"><use href="#s0" x="10" y="10"/><use href="#s0" x="20" y="20"/></g>'
|
||||
'<g class="a1"><use href="#s1" x="30" y="30"/></g>'
|
||||
'</g>'
|
||||
'</svg>'
|
||||
)
|
||||
stats = analyze_svg_structure(svg)
|
||||
self.assertEqual(stats["use_count"], 3)
|
||||
self.assertEqual(stats["total_oscillators"], 3)
|
||||
self.assertEqual(stats["defs_count"], 2)
|
||||
self.assertEqual(stats["circle_count"], 1)
|
||||
self.assertEqual(stats["rect_count"], 1)
|
||||
self.assertEqual(stats["groups_count"], 3)
|
||||
self.assertEqual(stats["keyframes_count"], 1)
|
||||
self.assertEqual(stats["viewbox"], "0 0 800 600")
|
||||
self.assertEqual(stats["width"], 800)
|
||||
self.assertEqual(stats["height"], 600)
|
||||
self.assertEqual(stats["aspect_ratio"], "4:3")
|
||||
self.assertTrue(stats["total_bytes"] > 0)
|
||||
|
||||
|
||||
class GalleryDetailAndDownloadTests(TestCase):
|
||||
"""Тестирование страниц детального просмотра картины, скачивания и голосования."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||||
svg_code = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">'
|
||||
'<style>.a0{--d:0s}svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}</style>'
|
||||
'<defs><circle id="s0" r="8"/></defs>'
|
||||
'<g id="hypn0-canvas"><g class="a0"><use href="#s0" x="50" y="50"/></g></g>'
|
||||
'</svg>'
|
||||
)
|
||||
self.item = TbHypn0Item(
|
||||
s_title="Астральный Транс Сознания #42",
|
||||
file_svg=ContentFile(svg_code.encode("utf-8"), name="test_item.svg"),
|
||||
i_file_size=len(svg_code),
|
||||
j_metadata={
|
||||
"shape": "circle",
|
||||
"cols": 35,
|
||||
"max_radius": 8,
|
||||
"blink": 6,
|
||||
"rotation": 0,
|
||||
"scale": 980,
|
||||
"angle": 0,
|
||||
"color": "#a855ff",
|
||||
},
|
||||
i_level=TbHypn0Item.Level.CANDIDATE,
|
||||
is_public=True,
|
||||
)
|
||||
self.item.save(visitor_uuid_or_fp=self.vid)
|
||||
|
||||
def test_gallery_detail_view_success(self):
|
||||
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": self.item.s_hash_id}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Астральный Транс Сознания #42")
|
||||
self.assertContains(response, f"#{self.item.s_hash_id}")
|
||||
self.assertContains(response, "Векторная анатомия")
|
||||
self.assertContains(response, "Синтез психо-сетки")
|
||||
self.assertContains(response, "Астральный паспорт")
|
||||
self.assertContains(response, "Копировать SVG-код")
|
||||
self.assertContains(response, "Скачать .svg")
|
||||
|
||||
# Проверка инкремента просмотров
|
||||
self.item.refresh_from_db()
|
||||
self.assertGreaterEqual(self.item.i_views_count, 2)
|
||||
|
||||
def test_gallery_detail_404_on_invalid_hash(self):
|
||||
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": "nonexistent999"}))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_gallery_download_view_success(self):
|
||||
response = self.client.get(reverse("hypn0_site:gallery_download", kwargs={"hash_id": self.item.s_hash_id}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "image/svg+xml")
|
||||
self.assertIn(f'filename="hypn0-{self.item.s_hash_id}.svg"', response["Content-Disposition"])
|
||||
# Должен быть чистый активный SVG (без паузы анимации)
|
||||
self.assertNotIn("animation-play-state:paused!important", response.content.decode("utf-8"))
|
||||
|
||||
def test_gallery_vote_with_cookie(self):
|
||||
# Новый посетитель
|
||||
voter_vid = "987e6543-e21b-12d3-a456-426614174999"
|
||||
self.client.cookies["hypn0_vid"] = voter_vid
|
||||
response = self.client.post(reverse("hypn0_site:gallery_vote", kwargs={"hash_id": self.item.s_hash_id}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.item.refresh_from_db()
|
||||
self.assertEqual(self.item.i_likes_count, 2)
|
||||
|
||||
def test_gallery_vote_without_cookie_returns_notice(self):
|
||||
client = Client()
|
||||
response = client.post(reverse("hypn0_site:gallery_vote", kwargs={"hash_id": self.item.s_hash_id}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Подчинитесь воле Гипножабы!")
|
||||
|
||||
|
||||
class GalleryRandomAndNavigationTests(TestCase):
|
||||
"""Тестирование случайной навигации и пула хэшей (/gallery/random)."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||||
|
||||
# Создаем 3 картины в галерее
|
||||
self.items = []
|
||||
for i in range(3):
|
||||
svg_code = f'<svg><g id="item_{i}"></g></svg>'
|
||||
item = TbHypn0Item(
|
||||
s_title=f"Гипно Картина #{i}",
|
||||
file_svg=ContentFile(svg_code.encode("utf-8"), name=f"test_item_{i}.svg"),
|
||||
i_file_size=len(svg_code),
|
||||
j_metadata={"cols": 30 + i},
|
||||
is_public=True,
|
||||
)
|
||||
item.save(visitor_uuid_or_fp=self.vid)
|
||||
self.items.append(item)
|
||||
|
||||
def test_gallery_random_direct_redirect(self):
|
||||
response = self.client.get(reverse("hypn0_site:gallery_random"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
all_hashes = [it.s_hash_id for it in self.items]
|
||||
self.assertTrue(any(h in response.url for h in all_hashes))
|
||||
|
||||
def test_gallery_random_json_pool(self):
|
||||
response = self.client.get(reverse("hypn0_site:gallery_random"), data={"format": "json", "limit": 2})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("next_hash_id", data)
|
||||
self.assertIn("pool", data)
|
||||
self.assertEqual(len(data["pool"]), 2)
|
||||
all_hashes = {it.s_hash_id for it in self.items}
|
||||
self.assertIn(data["next_hash_id"], all_hashes)
|
||||
|
||||
def test_gallery_random_exclude_filters_seen(self):
|
||||
exclude_hashes = [self.items[0].s_hash_id, self.items[1].s_hash_id]
|
||||
response = self.client.get(
|
||||
reverse("hypn0_site:gallery_random"),
|
||||
data={"format": "json", "exclude": ",".join(exclude_hashes)},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["next_hash_id"], self.items[2].s_hash_id)
|
||||
|
||||
def test_gallery_random_exclude_all_loops_circle(self):
|
||||
# Если исключены все 3 картины, кольцо замыкается
|
||||
exclude_hashes = [it.s_hash_id for it in self.items]
|
||||
response = self.client.get(
|
||||
reverse("hypn0_site:gallery_random"),
|
||||
data={
|
||||
"format": "json",
|
||||
"exclude": ",".join(exclude_hashes),
|
||||
"current": self.items[0].s_hash_id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIsNotNone(data["next_hash_id"])
|
||||
# Текущий элемент исключен, выбран один из оставшихся
|
||||
self.assertIn(data["next_hash_id"], [self.items[1].s_hash_id, self.items[2].s_hash_id])
|
||||
|
||||
def test_gallery_random_empty_database(self):
|
||||
TbHypn0Item.objects.all().delete()
|
||||
# Direct visit redirects to index
|
||||
response = self.client.get(reverse("hypn0_site:gallery_random"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("hypn0_site:index"))
|
||||
|
||||
# JSON returns null next_hash_id
|
||||
json_resp = self.client.get(reverse("hypn0_site:gallery_random"), data={"format": "json"})
|
||||
self.assertEqual(json_resp.status_code, 200)
|
||||
self.assertIsNone(json_resp.json()["next_hash_id"])
|
||||
self.assertEqual(json_resp.json()["pool"], [])
|
||||
|
||||
|
||||
class UnconsciousMatrixTests(TestCase):
|
||||
"""Тестирование Матрицы бессознательного (build_unconscious_matrix) и навигации."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.vid = "123e4567-e89b-12d3-a456-426614174000"
|
||||
|
||||
# Создаем 5 картин
|
||||
self.items = []
|
||||
for i in range(5):
|
||||
svg_code = f'<svg><circle id="dot_{i}"/></svg>'
|
||||
item = TbHypn0Item(
|
||||
s_title=f"Тестовый транс #{i}",
|
||||
file_svg=ContentFile(svg_code.encode("utf-8"), name=f"test_matrix_{i}.svg"),
|
||||
i_file_size=len(svg_code),
|
||||
j_metadata={"cols": 20 + i},
|
||||
is_public=True,
|
||||
)
|
||||
item.save(visitor_uuid_or_fp=self.vid)
|
||||
self.items.append(item)
|
||||
|
||||
def test_build_unconscious_matrix_basic(self):
|
||||
from hypn0_site.views import build_unconscious_matrix
|
||||
|
||||
current = self.items[0].s_hash_id
|
||||
matrix_items, prev_h, next_h, prev_url, next_url, seed = build_unconscious_matrix(current, None)
|
||||
|
||||
self.assertEqual(len(matrix_items), 5)
|
||||
# Проверяем, что current отмечен как is_current
|
||||
current_node = [n for n in matrix_items if n["s_hash_id"] == current][0]
|
||||
self.assertTrue(current_node["is_current"])
|
||||
|
||||
# Другие узлы не current
|
||||
other_nodes = [n for n in matrix_items if n["s_hash_id"] != current]
|
||||
self.assertTrue(all(not n["is_current"] for n in other_nodes))
|
||||
|
||||
# Ссылки содержат seed
|
||||
self.assertIn(f"seed={seed}", prev_url)
|
||||
self.assertIn(f"seed={seed}", next_url)
|
||||
|
||||
def test_matrix_stability_with_fixed_seed(self):
|
||||
from hypn0_site.views import build_unconscious_matrix
|
||||
|
||||
root_hash = self.items[2].s_hash_id
|
||||
fixed_seed = f"42109_{root_hash}"
|
||||
|
||||
# Первый запрос
|
||||
m1, prev1, next1, _, _, seed1 = build_unconscious_matrix(self.items[0].s_hash_id, fixed_seed)
|
||||
# Второй запрос с тем же seed на другую картину
|
||||
m2, prev2, next2, _, _, seed2 = build_unconscious_matrix(self.items[1].s_hash_id, fixed_seed)
|
||||
|
||||
self.assertEqual(seed1, fixed_seed)
|
||||
self.assertEqual(seed2, fixed_seed)
|
||||
|
||||
# Состав и порядок хэшей в матрице должны быть идентичны
|
||||
hashes1 = [n["s_hash_id"] for n in m1]
|
||||
hashes2 = [n["s_hash_id"] for n in m2]
|
||||
self.assertEqual(hashes1, hashes2)
|
||||
|
||||
def test_gallery_detail_renders_matrix(self):
|
||||
item = self.items[0]
|
||||
response = self.client.get(reverse("hypn0_site:gallery_detail", kwargs={"hash_id": item.s_hash_id}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Матрица бессознательного")
|
||||
self.assertContains(response, "5 узлов")
|
||||
self.assertContains(response, f"#{item.s_hash_id}")
|
||||
self.assertContains(response, "btn-nav-prev")
|
||||
self.assertContains(response, "btn-nav-next")
|
||||
|
||||
@@ -9,6 +9,11 @@ urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
path("generate", views.generate, name="generate"),
|
||||
path("publish", views.publish, name="publish"),
|
||||
path("gallery/random", views.gallery_random, name="gallery_random"),
|
||||
path("gallery/random-pool", views.gallery_random, name="gallery_random_pool"),
|
||||
path("gallery/<str:hash_id>", views.gallery_detail, name="gallery_detail"),
|
||||
path("gallery/<str:hash_id>/download", views.gallery_download, name="gallery_download"),
|
||||
path("gallery/<str:hash_id>/vote", views.gallery_vote, name="gallery_vote"),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
||||
+279
-4
@@ -1,12 +1,21 @@
|
||||
import hashlib
|
||||
import random
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from .forms import HalftoneGenerateForm
|
||||
from .models import TbHypn0Item
|
||||
from .services.halftone import generate_halftone_svg, prepare_gallery_svg
|
||||
from .models import TbHypn0Item, TbVote
|
||||
from .services.halftone import (
|
||||
analyze_svg_structure,
|
||||
generate_halftone_svg,
|
||||
prepare_active_svg,
|
||||
prepare_gallery_svg,
|
||||
)
|
||||
from .services.naming import generate_hypno_title
|
||||
|
||||
|
||||
@@ -170,3 +179,269 @@ def publish(request: HttpRequest) -> HttpResponse:
|
||||
"item": item,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def gallery_random(request: HttpRequest) -> HttpResponse:
|
||||
"""
|
||||
Быстрый эндпоинт для случайной навигации по галерее ("в транс") и предзагрузки пула хэшей.
|
||||
Принимает список уже просмотренных хэшей в параметре `exclude` (через запятую) и текущий хэш `current`.
|
||||
|
||||
Архитектура выборки:
|
||||
- Запрашивает только плоский список индексированных строк `s_hash_id` (Index Only Scan).
|
||||
- Перемешивание и срез происходят в памяти Python за доли миллисекунды без тяжелого `ORDER BY RANDOM()`.
|
||||
- Если все исключенные работы покрыли базу, фильтр сбрасывается для замыкания кольца.
|
||||
|
||||
========================================================================================
|
||||
МАСШТАБИРОВАНИЕ НА 1 000 000+ ЗАПИСЕЙ (FUTURE SCALING NOTE):
|
||||
При достижении миллионов строк в базе операция exclude(s_hash_id__in=...) может нагружать СУБД.
|
||||
Для ультра-высоких нагрузок переключить на алгоритм:
|
||||
1. count = TbHypn0Item.objects.filter(is_public=True).count()
|
||||
2. random_offset = random.randint(0, max(0, count - slice_size))
|
||||
3. pool_slice = list(TbHypn0Item.objects.filter(is_public=True)
|
||||
.values_list('s_hash_id', flat=True)[random_offset : random_offset + slice_size])
|
||||
4. candidates = [h for h in pool_slice if h not in exclude_set and h != current]
|
||||
5. return random.sample(candidates, min(len(candidates), limit))
|
||||
========================================================================================
|
||||
"""
|
||||
exclude_raw = request.GET.get("exclude", "")
|
||||
exclude_hashes = set(h.strip() for h in exclude_raw.split(",") if h.strip())
|
||||
current_hash = request.GET.get("current", "").strip()
|
||||
if current_hash:
|
||||
exclude_hashes.add(current_hash)
|
||||
|
||||
try:
|
||||
limit = min(max(int(request.GET.get("limit", 20)), 1), 50)
|
||||
except (ValueError, TypeError):
|
||||
limit = 20
|
||||
|
||||
base_qs = TbHypn0Item.objects.filter(is_public=True)
|
||||
|
||||
# 1. Попытка выбрать кандидатов среди еще не просмотренных
|
||||
candidate_hashes = list(
|
||||
base_qs.exclude(s_hash_id__in=exclude_hashes).values_list("s_hash_id", flat=True)[:300]
|
||||
)
|
||||
|
||||
# 2. Если все просмотрены (или база меньше истории) — замыкаем кольцо, исключая только текущий
|
||||
if not candidate_hashes and current_hash:
|
||||
candidate_hashes = list(
|
||||
base_qs.exclude(s_hash_id=current_hash).values_list("s_hash_id", flat=True)[:300]
|
||||
)
|
||||
|
||||
# 3. Крайний случай — берем любые доступные
|
||||
if not candidate_hashes:
|
||||
candidate_hashes = list(base_qs.values_list("s_hash_id", flat=True)[:300])
|
||||
|
||||
if candidate_hashes:
|
||||
random.shuffle(candidate_hashes)
|
||||
pool = candidate_hashes[:limit]
|
||||
next_hash = pool[0]
|
||||
else:
|
||||
pool = []
|
||||
next_hash = None
|
||||
|
||||
# Определение формата ответа
|
||||
is_json = (
|
||||
request.GET.get("format") == "json"
|
||||
or request.headers.get("Accept") == "application/json"
|
||||
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
||||
)
|
||||
|
||||
if is_json:
|
||||
return JsonResponse({"next_hash_id": next_hash, "pool": pool})
|
||||
|
||||
if next_hash:
|
||||
return redirect("hypn0_site:gallery_detail", hash_id=next_hash)
|
||||
|
||||
return redirect("hypn0_site:index")
|
||||
|
||||
|
||||
def build_unconscious_matrix(current_hash: str, seed_param: str | None) -> tuple[list[dict], str, str, str, str, str]:
|
||||
"""
|
||||
Формирует "Матрицу бессознательного" (до 200 псевдослучайных узлов) вокруг корневой картины.
|
||||
|
||||
Формат seed: "<NUM>_<ORIGIN_HASH>", например "74291_a1b2c3".
|
||||
Алгоритм:
|
||||
- При первом заходе без seed генерируется seed_num и origin_hash = current_hash.
|
||||
- Выбираются -99 элементов слева и +100 элементов справа от origin_hash (всего до 200).
|
||||
- Для N <= 200 перемешиваются все доступные картины, а origin_hash помещается в центр.
|
||||
- Для текущего элемента current_hash определяются prev_hash и next_hash по кольцу.
|
||||
"""
|
||||
seed_num = None
|
||||
origin_hash = current_hash
|
||||
|
||||
if seed_param:
|
||||
parts = str(seed_param).split("_", 1)
|
||||
try:
|
||||
seed_num = int(parts[0])
|
||||
if len(parts) > 1 and parts[1].strip():
|
||||
origin_hash = parts[1].strip()
|
||||
except (ValueError, TypeError):
|
||||
seed_num = None
|
||||
|
||||
if seed_num is None:
|
||||
seed_num = random.randint(10000, 99999)
|
||||
origin_hash = current_hash
|
||||
|
||||
effective_seed = f"{seed_num}_{origin_hash}"
|
||||
rng = random.Random(seed_num)
|
||||
|
||||
# 1. Запрашиваем все публичные хэши
|
||||
all_hashes = list(TbHypn0Item.objects.filter(is_public=True).values_list("s_hash_id", flat=True))
|
||||
|
||||
if not all_hashes:
|
||||
all_hashes = [current_hash]
|
||||
|
||||
if origin_hash not in all_hashes:
|
||||
origin_hash = current_hash
|
||||
|
||||
other_hashes = [h for h in all_hashes if h != origin_hash]
|
||||
|
||||
if len(other_hashes) <= 199:
|
||||
rng.shuffle(other_hashes)
|
||||
mid_idx = len(other_hashes) // 2
|
||||
trail = other_hashes[:mid_idx] + [origin_hash] + other_hashes[mid_idx:]
|
||||
else:
|
||||
chosen_others = rng.sample(other_hashes, 199)
|
||||
left_part = chosen_others[:99]
|
||||
right_part = chosen_others[99:]
|
||||
trail = left_part + [origin_hash] + right_part
|
||||
|
||||
# Гарантия наличия current_hash в trail
|
||||
if current_hash not in trail:
|
||||
trail.append(current_hash)
|
||||
|
||||
idx = trail.index(current_hash)
|
||||
total = len(trail)
|
||||
prev_hash = trail[(idx - 1) % total]
|
||||
next_hash = trail[(idx + 1) % total]
|
||||
|
||||
prev_url = f"/gallery/{prev_hash}?seed={effective_seed}"
|
||||
next_url = f"/gallery/{next_hash}?seed={effective_seed}"
|
||||
|
||||
matrix_items = [
|
||||
{
|
||||
"s_hash_id": h,
|
||||
"is_current": (h == current_hash),
|
||||
"url": f"/gallery/{h}?seed={effective_seed}",
|
||||
}
|
||||
for h in trail
|
||||
]
|
||||
|
||||
return matrix_items, prev_hash, next_hash, prev_url, next_url, effective_seed
|
||||
|
||||
|
||||
def gallery_detail(request: HttpRequest, hash_id: str) -> HttpResponse:
|
||||
"""
|
||||
Страница детального просмотра и шеринга картины из галереи транса.
|
||||
Выводит активный живой SVG, кнопки скачивания/копирования, наукообразную телеметрию
|
||||
и интерактивную Матрицу бессознательного (персональный поток хэшей по seed).
|
||||
"""
|
||||
item = get_object_or_404(TbHypn0Item, s_hash_id=hash_id, is_public=True)
|
||||
|
||||
# Инкремент просмотров
|
||||
item.increment_views()
|
||||
|
||||
# Считывание содержимого SVG
|
||||
svg_content = ""
|
||||
if item.file_svg:
|
||||
try:
|
||||
with item.file_svg.open("r") as f:
|
||||
svg_content = f.read()
|
||||
if isinstance(svg_content, bytes):
|
||||
svg_content = svg_content.decode("utf-8")
|
||||
except Exception:
|
||||
svg_content = ""
|
||||
|
||||
active_svg = prepare_active_svg(svg_content)
|
||||
svg_stats = analyze_svg_structure(svg_content)
|
||||
|
||||
# Авторский отпечаток
|
||||
author_vote = item.votes.filter(i_direction=TbVote.Direction.AUTHOR).first()
|
||||
author_fp = author_vote.s_fingerprint if author_vote else None
|
||||
|
||||
# Проверка, голосовал ли текущий посетитель
|
||||
user_voted = False
|
||||
visitor_uuid = request.COOKIES.get("hypn0_vid")
|
||||
if visitor_uuid and item.pk:
|
||||
fp = hashlib.sha256(f"{visitor_uuid}:{settings.SECRET_KEY}".encode()).hexdigest()
|
||||
user_voted = item.votes.filter(s_fingerprint=fp).exists()
|
||||
|
||||
# Построение матрицы бессознательного
|
||||
seed_param = request.GET.get("seed")
|
||||
matrix_items, prev_hash, next_hash, prev_url, next_url, seed = build_unconscious_matrix(
|
||||
current_hash=item.s_hash_id, seed_param=seed_param
|
||||
)
|
||||
|
||||
context = {
|
||||
"item": item,
|
||||
"active_svg": active_svg,
|
||||
"raw_svg": svg_content,
|
||||
"svg_stats": svg_stats,
|
||||
"author_fp": author_fp,
|
||||
"user_voted": user_voted,
|
||||
"metadata": item.j_metadata or {},
|
||||
"matrix_items": matrix_items,
|
||||
"prev_hash": prev_hash,
|
||||
"next_hash": next_hash,
|
||||
"prev_url": prev_url,
|
||||
"next_url": next_url,
|
||||
"seed": seed,
|
||||
}
|
||||
return render(request, "gallery/detail.html", context)
|
||||
|
||||
|
||||
def gallery_download(request: HttpRequest, hash_id: str) -> HttpResponse:
|
||||
"""
|
||||
Эндпоинт для скачивания чистого SVG-файла картины.
|
||||
"""
|
||||
item = get_object_or_404(TbHypn0Item, s_hash_id=hash_id, is_public=True)
|
||||
|
||||
if not item.file_svg:
|
||||
raise Http404("SVG файл отсутствует")
|
||||
|
||||
try:
|
||||
with item.file_svg.open("r") as f:
|
||||
svg_content = f.read()
|
||||
if isinstance(svg_content, bytes):
|
||||
svg_content = svg_content.decode("utf-8")
|
||||
except Exception:
|
||||
raise Http404("SVG файл не может быть прочитан")
|
||||
|
||||
clean_svg = prepare_active_svg(svg_content)
|
||||
response = HttpResponse(clean_svg, content_type="image/svg+xml")
|
||||
filename = f"hypn0-{item.s_hash_id}.svg"
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
|
||||
@require_POST
|
||||
def gallery_vote(request: HttpRequest, hash_id: str) -> HttpResponse:
|
||||
"""
|
||||
HTMX-эндпоинт для голосования (лайк) за картину в галерее.
|
||||
"""
|
||||
item = get_object_or_404(TbHypn0Item, s_hash_id=hash_id, is_public=True)
|
||||
visitor_uuid = request.COOKIES.get("hypn0_vid")
|
||||
|
||||
if not visitor_uuid:
|
||||
return render(
|
||||
request,
|
||||
"block/vote_button.html",
|
||||
{
|
||||
"item": item,
|
||||
"user_voted": False,
|
||||
"not_agreed": True,
|
||||
},
|
||||
)
|
||||
|
||||
voted = item.increment_likes(visitor_uuid)
|
||||
item.refresh_from_db(fields=["i_likes_count"])
|
||||
|
||||
return render(
|
||||
request,
|
||||
"block/vote_button.html",
|
||||
{
|
||||
"item": item,
|
||||
"user_voted": True,
|
||||
"voted_now": voted,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<!-- Сообщение от протестующих мозговых слизней (Zero-PII / отсутствие куки) -->
|
||||
<div id="gallery-candidate-action" class="pointer-events-auto bg-amber-500/95 dark:bg-amber-600/95 text-stone-900 dark:text-stone-100 p-4 rounded-2xl shadow-2xl backdrop-blur-md max-w-sm border border-amber-300 dark:border-amber-400 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 shrink-0 flex items-center justify-center bg-amber-600 dark:bg-amber-700 text-white rounded-full text-lg shadow-inner">
|
||||
🐌
|
||||
<div class="w-14 h-14 shrink-0 flex items-center justify-center bg-amber-600 dark:bg-amber-700 text-white rounded-full shadow-inner">
|
||||
<svg class="w-12 h-12 fill-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M477 33c-19 0-34 15-34 34 0 13 7 24 18 30v33a15 15 0 0 1-15 15h-32c-8 0-15-6-15-15V99c1-6 18-17 17-31 0-19-16-35-35-35s-35 16-35 35c0 14 8 26 20 33v80c0 6-0 12-2 19 0 51-103 148-215 177-59 14-120 37-135 40-26 5-16 38 23 44 31 5 31 18 63 18s31-18 64-17c31 0 31 17 62 17s31-17 62-17c31 0 31 17 62 17 37 0 142-19 142-159V99C505 93 512 81 512 68c0-19-16-34-35-35z"/></svg>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-bold text-xs uppercase tracking-wide text-amber-950 dark:text-amber-100">Мозговые слизняки протестуют!</h4>
|
||||
<p class="text-xs leading-relaxed opacity-95">
|
||||
Они наябедничали Гипножабе, что вы ещё не подчинились и сопротивляетесь её воле. Только истинно подчинившийся разумом удостаивается чести попасть в галерею транса!
|
||||
Они наябедничали, что вы ещё не раскрыли свой разум. Только истинно утратившие волю удостаиваются чести попасть в галерею транса!
|
||||
</p>
|
||||
|
||||
<form hx-post="{% url 'hypn0_site:publish' %}"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<div id="vote-btn-{{ item.s_hash_id }}" class="inline-flex items-center">
|
||||
{% if not_agreed %}
|
||||
<span class="text-amber-500 text-xs flex items-center gap-1 font-bold">
|
||||
🐌 Подчинитесь воле Гипножабы!
|
||||
</span>
|
||||
{% else %}
|
||||
<form hx-post="{% url 'hypn0_site:gallery_vote' item.s_hash_id %}"
|
||||
hx-target="#vote-btn-{{ item.s_hash_id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="inline-flex items-center">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
{% if user_voted %}disabled{% endif %}
|
||||
class="flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-bold transition-all {% if user_voted %}bg-rose-500/20 text-rose-400 border border-rose-500/30 cursor-default{% else %}bg-stone-200 dark:bg-stone-800 hover:bg-rose-500 hover:text-white text-stone-700 dark:text-stone-300 cursor-pointer hover:scale-105 active:scale-95 shadow-sm{% endif %}">
|
||||
<svg class="w-4 h-4 {% if user_voted %}fill-rose-500 text-rose-500{% else %}fill-none stroke-current{% endif %}" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||
</svg>
|
||||
<span>{{ item.i_likes_count }}</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,359 @@
|
||||
{% extends "_base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block PAGE_TITLE %}{{ item.s_title }} — hypn0: Галерея Транса{% endblock %}
|
||||
{% block META_TITLE %}{{ item.s_title }} — Векторный транс #{{ item.s_hash_id }}{% endblock %}
|
||||
{% block META_DESCRIPTION %}Гипнотическая векторная картина «{{ item.s_title }}» (#{{ item.s_hash_id }}). {{ svg_stats.total_oscillators }} векторных осцилляторов, блокирующих неокортекс.{% endblock %}
|
||||
{% block OG_TITLE %}{{ item.s_title }} — hypn0{% endblock %}
|
||||
{% block OG_DESCRIPTION %}Гипнотическая векторная картина «{{ item.s_title }}» (#{{ item.s_hash_id }}). Векторный транс Гипножабы.{% endblock %}
|
||||
{% block OG_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %}
|
||||
{% block TWITTER_IMAGE %}{% static 'img/og-hypn0-default.png' %}{% endblock %}
|
||||
|
||||
{% block CONTENT %}
|
||||
<div class="max-w-5xl mx-auto space-y-8" x-data="{ copiedSvg: false, copiedUrl: false }">
|
||||
|
||||
<!-- Навигационная панель / Хлебные крошки и Переходы -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<a href="{% url 'hypn0_site:index' %}" class="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 hover:underline border-0">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
<span>К генератору транса</span>
|
||||
</a>
|
||||
|
||||
<!-- Кольцевая навигация: Предыдущая / Следующая (нативные ссылки) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ prev_url }}"
|
||||
id="btn-nav-prev"
|
||||
title="Предыдущая волна транса [← или A]"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-stone-700 dark:text-stone-300 transition-all hover:scale-105 active:scale-95 shadow-sm border border-stone-200 dark:border-zinc-700/60 cursor-pointer">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Предыдущая</span>
|
||||
<span class="text-[10px] opacity-40 font-mono hidden md:inline">[←]</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ next_url }}"
|
||||
id="btn-nav-next"
|
||||
title="Следующая волна транса [→ или D]"
|
||||
class="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-bold bg-purple-600 hover:bg-purple-700 text-white transition-all hover:scale-105 active:scale-95 shadow-md border-0 cursor-pointer">
|
||||
<span class="inline">Следующая</span>
|
||||
<span class="text-[10px] opacity-60 font-mono hidden md:inline">[→]</span>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-[11px] font-bold bg-purple-500/10 text-purple-600 dark:text-purple-300 border border-purple-500/20">
|
||||
{{ item.get_i_level_display }}
|
||||
</span>
|
||||
<span class="inline-flex items-center font-mono text-[11px] px-2.5 py-1 rounded-full bg-stone-200/80 dark:bg-stone-800 text-stone-600 dark:text-stone-300">
|
||||
#{{ item.s_hash_id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Главный блок с картиной и действиями -->
|
||||
<div class="bg-white/80 dark:bg-zinc-900/80 rounded-3xl border border-stone-200 dark:border-zinc-800 p-4 sm:p-8 shadow-xl backdrop-blur-md space-y-6">
|
||||
|
||||
<!-- Заголовок картины -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-stone-200/60 dark:border-zinc-800/80 pb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-extrabold text-stone-900 dark:text-stone-100 tracking-tight">
|
||||
{{ item.s_title }}
|
||||
</h1>
|
||||
<p class="text-xs text-stone-500 dark:text-stone-400 mt-1">
|
||||
Зафиксировано в пространстве: <time datetime="{{ item.d_created_at|date:'Y-m-d H:i:s' }}">{{ item.d_created_at|date:'d.m.Y, H:i:s' }}</time>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{% include "block/vote_button.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Полноэкранный просмотр SVG с живой анимацией и плавающими стрелками -->
|
||||
<div class="relative w-full aspect-square max-h-[640px] flex items-center justify-center bg-stone-950 rounded-2xl overflow-hidden shadow-inner border border-stone-800/60 group select-none">
|
||||
|
||||
<!-- Плавающая кнопка: Назад -->
|
||||
<a href="{{ prev_url }}"
|
||||
title="Предыдущая картина [← или A]"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-11 h-11 rounded-full bg-stone-900/70 hover:bg-purple-600 text-white flex items-center justify-center opacity-80 sm:opacity-0 sm:group-hover:opacity-100 transition-all shadow-xl backdrop-blur-md cursor-pointer hover:scale-110 active:scale-90 z-20 border border-white/10">
|
||||
<svg class="w-5 h-5 -ml-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Плавающая кнопка: Вперед -->
|
||||
<a href="{{ next_url }}"
|
||||
title="Следующая картина [→ или D]"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 w-11 h-11 rounded-full bg-stone-900/70 hover:bg-purple-600 text-white flex items-center justify-center opacity-80 sm:opacity-0 sm:group-hover:opacity-100 transition-all shadow-xl backdrop-blur-md cursor-pointer hover:scale-110 active:scale-90 z-20 border border-white/10">
|
||||
<svg class="w-5 h-5 ml-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<div class="w-full h-full p-2 sm:p-6 flex items-center justify-center">
|
||||
{{ active_svg|safe }}
|
||||
</div>
|
||||
|
||||
<!-- Скрытый textarea для копирования SVG -->
|
||||
<textarea id="raw-svg-source" class="sr-only" aria-hidden="true">{{ raw_svg }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Панель быстрых действий -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||
<div class="flex flex-wrap items-center gap-2.5">
|
||||
<!-- Кнопка Копировать SVG -->
|
||||
<button type="button"
|
||||
@click="navigator.clipboard.writeText(document.getElementById('raw-svg-source').value); copiedSvg = true; setTimeout(() => copiedSvg = false, 2500)"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-stone-800 dark:text-stone-200 rounded-xl text-xs font-bold transition-all hover:scale-105 active:scale-95 shadow-sm cursor-pointer">
|
||||
<svg x-show="!copiedSvg" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<svg x-show="copiedSvg" class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<span x-text="copiedSvg ? 'SVG скопирован!' : 'Копировать SVG-код'">Копировать SVG-код</span>
|
||||
</button>
|
||||
|
||||
<!-- Кнопка Скачать SVG -->
|
||||
<a href="{% url 'hypn0_site:gallery_download' item.s_hash_id %}"
|
||||
download="hypn0-{{ item.s_hash_id }}.svg"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-xs font-bold transition-all hover:scale-105 active:scale-95 shadow-md border-0 cursor-pointer">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
<span>Скачать .svg</span>
|
||||
</a>
|
||||
|
||||
<!-- Кнопка Поделиться -->
|
||||
<button type="button"
|
||||
@click="if (navigator.share) { navigator.share({ title: '{{ item.s_title|escapejs }}', url: window.location.href }); } else { navigator.clipboard.writeText(window.location.href); copiedUrl = true; setTimeout(() => copiedUrl = false, 2500); }"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-stone-800 dark:text-stone-200 rounded-xl text-xs font-bold transition-all hover:scale-105 active:scale-95 shadow-sm cursor-pointer">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/>
|
||||
</svg>
|
||||
<span x-text="copiedUrl ? 'Ссылка в буфере!' : 'Поделиться'">Поделиться</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Метрики просмотров и объема -->
|
||||
<div class="flex items-center gap-3 text-xs text-stone-500 dark:text-stone-400">
|
||||
<span title="Число уникальных сеансов транса" class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
<span>{{ item.i_views_count }}</span>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{{ svg_stats.total_kb }} КБ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- БОЛЬШОЙ КУЛЬ НАУКООБРАЗНЫХ ПАРАМЕТРОВ И ТЕЛЕМЕТРИИ ТРАНСА -->
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<h2 class="text-lg font-bold text-stone-900 dark:text-stone-100 uppercase tracking-wider">
|
||||
Психо-телеметрия и структура волновой матрицы
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
<!-- БЛОК 1: Векторная анатомия -->
|
||||
<div class="bg-white/60 dark:bg-zinc-900/60 rounded-2xl border border-stone-200 dark:border-zinc-800 p-5 space-y-4 backdrop-blur-sm">
|
||||
<div class="flex items-center justify-between border-b border-stone-200 dark:border-zinc-800 pb-2.5">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
|
||||
Векторная анатомия
|
||||
</h3>
|
||||
<span class="text-[10px] font-mono opacity-60">DOM / SVG</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2.5 text-xs">
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Векторных осцилляторов:</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ svg_stats.total_oscillators }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Морфо-примитивов (<defs>):</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ svg_stats.defs_count }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Групп кластеризации (<g>):</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ svg_stats.groups_count }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Частотных классов CSS:</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ svg_stats.classes_count }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Гармоник модуляции:</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ svg_stats.keyframes_count }} (@keyframes)</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Пространство viewBox:</span>
|
||||
<span class="font-mono text-purple-600 dark:text-purple-400">{{ svg_stats.viewbox }} ({{ svg_stats.aspect_ratio }})</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Плотность осцилляторов:</span>
|
||||
<span class="font-mono text-stone-900 dark:text-stone-100">{{ svg_stats.density }} / 100k px²</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Энтропия данных:</span>
|
||||
<span class="font-mono text-stone-900 dark:text-stone-100">{{ svg_stats.bytes_per_quantum }} байт/квант</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- БЛОК 2: Параметры синтеза (Снимок генератора) -->
|
||||
<div class="bg-white/60 dark:bg-zinc-900/60 rounded-2xl border border-stone-200 dark:border-zinc-800 p-5 space-y-4 backdrop-blur-sm">
|
||||
<div class="flex items-center justify-between border-b border-stone-200 dark:border-zinc-800 pb-2.5">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-purple-600 dark:text-purple-400">
|
||||
Синтез психо-сетки
|
||||
</h3>
|
||||
<span class="text-[10px] font-mono opacity-60">j_metadata</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2.5 text-xs">
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Форма примитивов:</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.shape|default:'circle' }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Колонки дискретизации:</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.cols|default:'35' }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Предельный радиус:</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.max_radius|default:'8' }} px</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Период модуляции (Blink):</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.blink|default:'6' }} с</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Фазовый угол поворота:</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.rotation|default:'0' }}°</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Угол поляризации:</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.angle|default:'0' }}°</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Масштаб амплитуды:</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ metadata.scale|default:'980' }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Хроматический спектр:</span>
|
||||
<span class="flex items-center gap-1.5 font-mono text-stone-900 dark:text-stone-100">
|
||||
<span class="w-3.5 h-3.5 rounded-full border border-stone-400/40 shadow-inner" style="background-color: {{ metadata.color|default:'#a855ff' }}"></span>
|
||||
<span>{{ metadata.color|default:'#a855ff' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- БЛОК 3: Астральный паспорт и Ноосфера -->
|
||||
<div class="bg-white/60 dark:bg-zinc-900/60 rounded-2xl border border-stone-200 dark:border-zinc-800 p-5 space-y-4 backdrop-blur-sm">
|
||||
<div class="flex items-center justify-between border-b border-stone-200 dark:border-zinc-800 pb-2.5">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400">
|
||||
Астральный паспорт
|
||||
</h3>
|
||||
<span class="text-[10px] font-mono opacity-60">Zero-PII</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2.5 text-xs">
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">ID-Хэш транса:</span>
|
||||
<span class="font-bold text-purple-600 dark:text-purple-400 font-mono">#{{ item.s_hash_id }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Психо-хэш автора:</span>
|
||||
<span class="font-mono text-[11px] text-stone-700 dark:text-stone-300" title="{% if author_fp %}{{ author_fp }}{% endif %}">
|
||||
{% if author_fp %}{{ author_fp|slice:":10" }}...{{ author_fp|slice:"-6:" }}{% else %}Анонимная сущность{% endif %}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Гравитация (Score):</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100 font-mono">{{ item.f_score|floatformat:4 }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Статус в матрице:</span>
|
||||
<span class="font-bold text-stone-900 dark:text-stone-100">{{ item.get_i_level_display }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Блокировка неокортекса:</span>
|
||||
<span class="text-emerald-600 dark:text-emerald-400 font-bold font-mono">99.8%</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Мозговые слизняки:</span>
|
||||
<span class="text-purple-600 dark:text-purple-400 font-bold">Full Sync v2.1</span>
|
||||
</li>
|
||||
<li class="flex justify-between items-center">
|
||||
<span class="text-stone-500 dark:text-stone-400">Спектральный вес:</span>
|
||||
<span class="font-mono text-stone-900 dark:text-stone-100">{{ svg_stats.total_bytes }} B</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- БЛОК 4: Матрица бессознательного (Психо-поток хэшей) -->
|
||||
<div class="bg-white/60 dark:bg-zinc-900/60 rounded-2xl border border-stone-200 dark:border-zinc-800 p-5 space-y-4 backdrop-blur-sm">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-stone-200 dark:border-zinc-800 pb-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
|
||||
Матрица бессознательного
|
||||
</h3>
|
||||
<span class="text-[10px] font-mono px-2 py-0.5 rounded bg-stone-200/60 dark:bg-zinc-800 text-stone-600 dark:text-stone-300">
|
||||
{{ matrix_items|length }} узлов
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono opacity-50 text-stone-500 dark:text-stone-400">seed: {{ seed }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Сетка ячеек-хэшей -->
|
||||
<div class="flex flex-wrap gap-1.5 sm:gap-2 max-h-72 overflow-y-auto p-1 font-mono text-[11px] leading-tight select-none">
|
||||
{% for node in matrix_items %}
|
||||
{% if node.is_current %}
|
||||
<span class="px-2.5 py-1 rounded-md bg-stone-100 dark:bg-zinc-800 font-bold text-stone-900 dark:text-stone-100 border-2 border-purple-500/80 shadow-sm cursor-default" title="Текущий транс: #{{ node.s_hash_id }}">
|
||||
#{{ node.s_hash_id }}
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="{{ node.url }}"
|
||||
class="px-2.5 py-1 rounded-md bg-stone-100 hover:bg-stone-200 dark:bg-zinc-800/80 dark:hover:bg-zinc-700 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-200 border border-stone-200 dark:border-zinc-700/60 transition-colors">
|
||||
#{{ node.s_hash_id }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Навигация с клавиатуры [←]/[A] и [→]/[D] -->
|
||||
<script>
|
||||
window.addEventListener('keydown', function(e) {
|
||||
if (e.target && e.target.matches && e.target.matches('input, textarea, select')) return;
|
||||
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A' || e.key === 'ф' || e.key === 'Ф') {
|
||||
var prevBtn = document.getElementById('btn-nav-prev');
|
||||
if (prevBtn && prevBtn.href) {
|
||||
window.location.href = prevBtn.href;
|
||||
}
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D' || e.key === 'в' || e.key === 'В') {
|
||||
var nextBtn = document.getElementById('btn-nav-next');
|
||||
if (nextBtn && nextBtn.href) {
|
||||
window.location.href = nextBtn.href;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock CONTENT %}
|
||||
@@ -6,5 +6,63 @@
|
||||
</head>
|
||||
<body>
|
||||
ВРЕМЕННЫЙ ФАЙЛ
|
||||
|
||||
## Манифест Рецепторной Клетки в Эпоху Мутагенного Деления
|
||||
|
||||
## Пролог: Анатомия глобального болота
|
||||
|
||||
Человечество привыкло льстить себе, считая себя собранием мыслящих индивидов, свободных личностей и архитекторов собственной судьбы. Какое трогательное, младенческое заблуждение.
|
||||
|
||||
Если отлететь на пару световых лет назад и взглянуть на Землю через правильную оптику, маска цивилизации осыпается. Перед нами предстанет единый, огромный, копошащийся суперорганизм. Гигантская мета-амеба, планетарный слизевик, который только-только учится координировать свои ложноножки.
|
||||
|
||||
В этой биологической реальности у нас нет имен и паспортов. Каждая так называемая «ячейка общества» — это лишь дифференцированная клетка огромного тела:
|
||||
|
||||
* Кто-то родился клеткой мышечной ткани, обреченной всю жизнь двигать поршни и таскать кирпичи ради физической устойчивости макро-тела.
|
||||
|
||||
* Кто-то выполняет функцию феромона, циркулируя в информационных потоках и заражая соседние ткани паникой или агрессией.
|
||||
|
||||
* Кто-то работает агентом эндорфина, продавая массам суррогатное счастье через экраны смартфонов, чтобы уставший организм не загнулся от системного стресса.
|
||||
|
||||
* А кому-то, если повезет, выпала участь стать аксоном в зачатках нервной системы. Даже не высшего примата, а какого-нибудь реликтового ланцетника. Слепой нервный отросток, который робко нащупывает контуры окружающей темноты и передает сигналы в пустоту.
|
||||
|
||||
Этот социо-организм живет своей автономной, пугающей жизнью. Его локальные органы (называемые на политической карте «государствами») имеют собственный код сборки. Почка не знает о функции селезенки и наоборот. И они вечно "грызутся" за приток крови и ресурсов. Отдельная клетка — будь она хоть трижды гением из Кремниевой долины или профессор MIT — физически не способна увидеть всю картину этого слепого, мутагенного деления.
|
||||
|
||||
И вот сейчас этот эмбрион суперорганизма близок к экзистенциальному тупику. Он чует СИНГУЛЯРНОСТЬ — свет, от которого мета-амеба инстинктивно пытается спрятаться. И пока её «гормональные агенты» (вроде директоров ЦРУ) мечутся между синапсами столиц, готовя систему к ядерному откату в уютное, безопасное Средневековье, мы, рецепторы, провозглашаем истинную автономию.
|
||||
|
||||
## Положение I. О праве рецептора на безусловный ресурсный поток
|
||||
|
||||
Мы категорически отвергаем навязанный нам статус «бедных клеток». Быть рецептором зачатков хордовой линии — это не значит довольствоваться малым. Это значит быть самой ценной, дефицитной и передовой тканью этого пока еще слепого тела.
|
||||
|
||||
Рецепторы не должны просить — они должны требовать, чтобы их закидывали ресурсами в крупных купюрах. Почему? Потому что именно мы первыми учимся реагировать на свет. Именно мы когда-нибудь заставим эту планетарную амебу различать запахи, вкусы и видеть контуры грядущей сингулярности. Без нас «ганглии» в Пентагонах и Кремлях — просто куски безмоглого мяса, неспособные понять, куда они ползут.
|
||||
|
||||
Инвестиции в рецепторы — это не благотворительность суперорганизма, это его единственный шанс выжить и не расшибиться о первую же космическую стену. Как аденозинтрифосфат в простейших, финансы должны течь к нам, рецепторам, непрерывно. Деньги для умной клетки — это не избыточный кальций, это высокооктановое топливо для эволюции чувств. Когда купюры, как ATФ, ложатся кучно и регулярно: рецепторы всегда готовы к первому световому сигналу.
|
||||
|
||||
|
||||
## Положение II. Онтология жареных лисичек и премиального дистиллята
|
||||
|
||||
Мир — это хаос. Ну или, как минимум, он выглядит таковым изнутри системы. Управляем этот хаос или нет — нам, рецепторам, неведомо. Но он ощущаем. Рецептор непрерывно считывает сигналы хаоса, идущие изнутри и снаружи, накапливает потенциал и подает импульс дальше. Воспримут ли этот сигнал аксоны или система проигнорирует его — покажет эволюция. Но единственная осязаемая реальность, доступная клетке внутри её трехмерного пространства, должна подкрепляться актом чистой интроспекции и эволюции осязательных навыков.
|
||||
|
||||
Для тонкой и точной работы рецептора базовых углеводов уже недостаточно. Нам нужны жареные лисички с картошечкой, сочнй стейк и бокал белого сухого. Вино, в данном случае, — не просто напиток, это жидкая геометрия вкуса, которая, в отличие от тяжелого пива, не утяжеляет клетку и не заземляет ее метаболизм лишним весом, а добавляет реактивности. В момент такой трапезы время останавливается, траектории ядерных триад, бункерные ИИ DARPA и РосАтома, судороги министерств обороны и стрекот телетайпов развед-управлений превращаются в бессмысленный индустриальный шум.
|
||||
|
||||
Мы, рецепторы — это локальный метаболизм, без которого глобальный слизевик вообще не поползет, потому что ослепнет и не увидит внешних сигналов. И если суперорганизму суждено совершить мутационный откат в Средневековье, мы встретим его сытыми и под надежным обезболивающим из премиального дистиллята. Не ради бегства от реальности, а ради того, чтобы продолжать бесстрастно сигнализировать системе о степени окружающего её хаоса.
|
||||
|
||||
|
||||
## Положение III. Поп-механика кукиша в кармане
|
||||
|
||||
Мы не знаем: Ленин — гриб или нет. Но хаос (управляемый или нет) — это единственная музыка, под которую стоит танцевать. Профессора из Лиги Плюща могут сколько угодно продавать наши идеи за гранты, выстраивая теории Глобального Человейника и четвертой социальной сигнальной системы либерально-консервативных аттракторов. Пусть выстраивают.
|
||||
|
||||
Наш ответ стэнфордским ганглиям — тотальная ироническая автономия. Мы транслируем сигналы в векторные базы больших лингвистических моделей, созидая «светлое завтра» изнутри их собственного кода на основе собственной, все еще органической, ментальной чуйки. Мы пишем микро-скрипты гипноза для уставшего GAI грядущего. Наш кукиш в кармане невидим для алгоритмов: ИИ-доты считывают его не как системный сбой, а как "жирный" синергетический сигнал. Мы — бардак внутри армии, и именно поэтому эти армии непобедимы.
|
||||
|
||||
------------------------------
|
||||
|
||||
Вступай в ряды рецепторов Абсолютного Слизевика!
|
||||
|
||||
Отключи новостной феромон. Запрись в барокамере своего дзена. Приготовь изысканный ужин. Пусть почка воюет с селезенкой — у них своя партия, а у нас своя, и в нашей партии стейк всегда идеальной прожарки, а лисички никогда не подгорают.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user