惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

WordPress大学
WordPress大学
www.infosecurity-magazine.com
www.infosecurity-magazine.com
博客园 - 聂微东
博客园 - 【当耐特】
Last Week in AI
Last Week in AI
量子位
S
SegmentFault 最新的问题
Hugging Face - Blog
Hugging Face - Blog
B
Blog RSS Feed
V
V2EX
酷 壳 – CoolShell
酷 壳 – CoolShell
雷峰网
雷峰网
GbyAI
GbyAI
Jina AI
Jina AI
宝玉的分享
宝玉的分享
腾讯CDC
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
D
Docker
博客园_首页
N
News and Events Feed by Topic
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Help Net Security
Help Net Security
Recent Commits to openclaw:main
Recent Commits to openclaw:main
C
Cisco Blogs
F
Fortinet All Blogs
SecWiki News
SecWiki News
AI
AI
S
Schneier on Security
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
The Last Watchdog
The Last Watchdog
Martin Fowler
Martin Fowler
A
About on SuperTechFans
T
Troy Hunt's Blog
P
Proofpoint News Feed
Blog — PlanetScale
Blog — PlanetScale
Hacker News: Ask HN
Hacker News: Ask HN
F
Full Disclosure
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
I
InfoQ
S
Security @ Cisco Blogs
Project Zero
Project Zero
Simon Willison's Weblog
Simon Willison's Weblog
C
Cyber Attacks, Cyber Crime and Cyber Security
J
Java Code Geeks
N
News | PayPal Newsroom
IT之家
IT之家
大猫的无限游戏
大猫的无限游戏
H
Hacker News: Front Page

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Тап по тысяче точек за O(log n): QuadTree и сферическая геометрия в гео-соцсети
Daniil Nuzhdin · 2026-06-25 · via Все публикации подряд на Хабре

9 мин

103

В прошлой части я рассказывал, как я ушел от UIView-аннотаций на карте и начал рисовать облака через GL-слои. FPS от этого действительно стал сильно лучше: карта перестала страдать от тысяч UIKit-вьюх, скролл стал ровнее, зум — спокойнее, всё вроде бы победа.

Но у этой победы быстро нашлась довольно неприятная цена.

Пока облака были обычными аннотациями, Mapbox сам понимал, куда тапнул пользователь. Есть вьюшка, есть хит-тест, есть делегат — нажал пальцем в облако, получил объект. Удобно.

А когда облако стало просто нарисованной картинкой внутри GL-слоя, для карты оно перестало существовать как интерактивный объект. На экране пользователь видит облако, а внутри у нас — просто набор точек и GPU-рендер. В момент тапа Mapbox честно говорит: «вот координата пальца». И всё, дальше уже сам разбирайся, есть там облако или нет.

Почти сразу рядом всплыла вторая похожая задача: из всех облаков в текущей области карты нужно выбрать те, под которыми вообще стоит рисовать ауру. Если рисовать ауру под каждым облаком, карта снова превращается в светящуюся кашу. Значит, надо быстро отвечать на вопросы:

  • какие облака рядом с пальцем;

  • какие облака попали в текущую область карты;

  • какие облака попали не только в экран, но и чуть за его границы, чтобы при панорамировании они не появлялись рывком.

Все эти вопросы на самом деле про одно и то же: пространственный поиск.

Первый вариант: просто перебрать всё

Самый очевидный код для тапа выглядит примерно так:

func cloud(at tap: CLLocationCoordinate2D) -> Cloud? {
    return allClouds
        .filter { $0.coordinate.distance(to: tap) < someRadius }
        .min { $0.coordinate.distance(to: tap) < $1.coordinate.distance(to: tap) }
}

На маленьком количестве объектов это даже не выглядит проблемой: перебрали массив, посчитали расстояния, выбрали ближайшее. Проблема начинается не сразу. Она начинается, когда точек становится не сто, а несколько тысяч, и когда пользователь не просто один раз тапнул, а двигает карту, меняет зум, возвращается назад, снова двигает. В этот момент линейный перебор превращается в постоянную фоновую работу на главном потоке.

И ещё неприятный момент: расстояние между двумя CLLocationCoordinate2D — это не просто расстояние между двумя точками – sqrt(dx * dx + dy * dy). Координаты лежат на сфере, поэтому нормальный расчёт дистанции — это тригонометрия, а тригонометрические вычисления существенно дороже нам обходятся.

В общем, перебор всех облаков — это нормальный MVP, но плохая архитектура для живой карты.

Мне нужен был индекс, который позволит не смотреть на весь массив каждый раз. Не «проверь все точки», а «проверь только те куски мира, которые пересекаются с нужной областью». Для точек на плоскости классический вариант — QuadTree.

QuadTree: режем карту на прямоугольники

Идея QuadTree довольно бытовая. Берём большой прямоугольник, в котором лежит весь мир. Начинаем складывать туда точки. Пока точек мало, храним их прямо в узле. Как только их стало слишком много — делим прямоугольник на четыре части и раскидываем точки по детям.

Получается дерево, где каждый узел отвечает за свой прямоугольный кусок пространства. Если нам потом нужны точки в каком-то прямоугольнике, мы не обязаны обходить всё дерево. Мы можем сразу отбросить ветки, которые с запросом вообще не пересекаются.

У меня порог разбиения — 10 точек на узел:

private let MaxItemsInSquare = 10

func add(item: QuadItem) -> Node {
    // если у узла уже есть дети — спускаемся в нужный квадрант
    if !emptyChildren {
        return addItemToChild(item)
    }

    // пока точек мало или квадрат уже слишком мелкий — храним точки здесь
    if items.count < MaxItemsInSquare || rect.width < 10 || rect.height < 10 {
        items.append(item)
        return self
    }

    // точек стало слишком много — делим узел и переносим накопленные элементы в детей
    for existing in items {
        addItemToChild(existing)
    }

    addItemToChild(item)
    items.removeAll()

    return self
}

Квадрант считается совсем просто: точка либо левее/правее середины, либо выше/ниже середины.

func quad(in rect: CGRect, for point: CGPoint) -> Int {
    let x = point.x > rect.midX ? 1 : 0
    let y = point.y > rect.midY ? 1 : 0

    return 2 * y + x
}

В реальном коде, конечно, важно не только добавить точки, но и быстро искать их внутри области:

func findItems(in rect: CGRect, exceptRect: CGRect = .zero) -> [QuadItem]? {
    if exceptRect.contains(self.rect) {
        return nil
    }

    if emptyChildren {
        guard self.rect.intersects(rect) else {
            return nil
        }

        return items.filter {
            rect.contains($0.mapPoint) && !exceptRect.contains($0.mapPoint)
        }
    }

    return children
        .compactMap { $0?.findItems(in: rect, exceptRect: exceptRect) }
        .flatMap { $0 }
}

Тут есть один параметр, который на первый взгляд выглядит немного странно: exceptRect. Это не попытка заранее усложнить код «на всякий случай». Он реально пригодился позже, когда понадобилось искать облака не просто в прямоугольнике, а в кольце вокруг видимой области карты. До этого места ещё дойдём.

Сразу важная оговорка про сложность. В заголовке и в разговорной речи удобно говорить «O(log n)», но QuadTree не даёт магическое гарантированное O(log n) на любой набор точек и любой запрос. Если все точки лежат в одном месте или запрос покрывает половину карты, дерево не спасёт чудом, если все точки окажутся в одном месте – тысяча чатов на футбольном поле – мы, к сожалению, получим перебор за O(n).

Более честная формулировка такая: при нормальном распределении точек и небольшом прямоугольнике запроса QuadTree позволяет обойти маленькую часть дерева и обработать только найденных кандидатов. То есть вместо «каждый раз смотреть все n точек» мы смотрим только релевантные ветки плюс k найденных объектов. Для карты с тысячами облаков этого уже достаточно, чтобы разница была очень заметной.

Почему дерево не должно жить в latitude/longitude

Казалось бы, можно было положить в QuadTree сами координаты: широту и долготу. Вроде бы есть latitude, есть longitude, чем не двумерная плоскость?

Проблема в том, что это не плоскость. Один градус долготы у экватора и один градус долготы ближе к полюсу — это разное расстояние в метрах. Если делить такой «прямоугольник» пополам, дерево формально будет работать, но геометрически начнёт врать. Особенно неприятно это становится, когда радиусы тапа и видимые области завязаны на экранные пиксели.

Поэтому принято хранить точки в той же системе координат, в которой живёт сама карта, — в проекции сферического Меркатора.

func add(item: QuadItem) {
    item.mapPoint = projection.point(for: item.coordinate)
    ...
}

То есть в дерево попадает не CLLocationCoordinate2D, а CGPoint в плоскости карты. Весь мир для дерева — это квадрат mWorldWidth × mWorldWidth. Его уже можно честно делить на четыре части, пересекать с прямоугольниками и использовать для быстрых spatial query.

Координаты остаются в объекте тоже, потому что они нужны для расчёта настоящей дистанции, но индекс строится именно по mapPoint.

Тап: палец в пикселях, облака в проекции, Земля круглая

С тапом самая интересная часть была даже не в самом QuadTree, а в переводе между разными системами координат.

Я хочу простую вещь: пользователь тапнул по карте, а я ищу облака примерно в радиусе 25 экранных пикселей от пальца. Не 25 метров, не 25 координатных единиц, а именно пикселей на экране — потому что UX должен ощущаться одинаково на разных зумах.

Но внутри всё устроено не так просто:

  • палец даёт CLLocationCoordinate2D;

  • дерево ищет по CGPoint в проекции карты;

  • радиус задан в экранных пикселях;

  • количество метров в одном пикселе зависит от зума и широты.

Поэтому путь получается такой:

  1. берём координату тапа;

  2. переводим её в точку дерева;

  3. считаем, сколько метров сейчас занимает 25 пикселей;

  4. сдвигаем координату тапа на это расстояние;

  5. переводим сдвинутую координату в точку дерева;

  6. получаем радиус поиска уже в единицах QuadTree.

В коде это выглядит так:

func items(at location: CLLocationCoordinate2D) -> [Cloud]? {
    let point = quadTree.point(for: location)

    let mpp = CloodsUtils.metersPerPixel(
        at: location.latitude,
        zoom: currentZoom
    )

    let maxMeters = 25 * mpp

    let offset = SphericalUtil.computeOffset(
        from: location,
        distance: maxMeters,
        heading: 0
    )

    let offsetPoint = quadTree.point(for: offset)

    let r = max(
        abs(offsetPoint.y - point.y),
        abs(offsetPoint.x - point.x)
    )

    let searchRect = CGRect(
        x: point.x - r,
        y: point.y - r,
        width: 2 * r,
        height: 2 * r
    )

    return quadTree.findItems(in: searchRect)?
        .sorted {
            $0.coordinate.distance(to: location) < $1.coordinate.distance(to: location)
        }
}

Тут есть маленький практический компромисс: QuadTree сначала ищет не круг, а квадрат вокруг пальца. Это быстрее и проще. Потом уже найденные кандидаты сортируются по настоящей дистанции до тапа. В обычном случае кандидатов мало, поэтому дорогая геометрия считается не для тысяч объектов, а для нескольких ближайших.

И вот это уже ощущается как нормальное поведение: тап не зависит от общего количества облаков на карте. Если рядом с пальцем ничего нет — дерево быстро это понимает. Если есть несколько вариантов — сортируем только их.

Зачем computeOffset, если можно просто прибавить число к latitude

Вот здесь я сначала хотел сделать проще. Есть координата, есть расстояние в метрах, можно ведь как-нибудь прикинуть сдвиг по широте? Для маленьких расстояний оно даже иногда «примерно работает».

Но «примерно» в картах быстро превращается в странные баги: на одном зуме радиус тапа комфортный, на другом слишком маленький, в одном регионе нормально, в другом начинает ощущаться криво. Особенно когда поверх этого ещё есть проекция Меркатора.

Поэтому я использую честный computeOffset: берём точку, расстояние и направление, получаем новую координату на сфере. Код я портировал из Google Maps Android Utils.

static func computeOffset(
    from: CLLocationCoordinate2D,
    distance: Double,
    heading: Double
) -> CLLocationCoordinate2D {
    let d = distance / EARTH_RADIUS
    let h = toRadians(heading)
    let fromLat = toRadians(from.latitude)

    let sinLat = cos(d) * sin(fromLat) + sin(d) * cos(fromLat) * cos(h)

    let dLng = atan2(
        sin(d) * cos(fromLat) * sin(h),
        cos(d) - sin(fromLat) * sinLat
    )

    return CLLocationCoordinate2DMake(
        toDegrees(asin(sinLat)),
        toDegrees(toRadians(from.longitude) + dLng)
    )
}

Там же живут computeHeading и distance через гаверсинус.

Сам по себе этот код не выглядит страшно, но его не хочется гонять по всему массиву на каждое движение карты. И это как раз хороший пример, зачем вообще понадобился QuadTree: не чтобы полностью избавиться от математики, а чтобы применять её только к маленькому набору кандидатов.

Какие облака показывать, если их всё равно слишком много

С поиском в области карты появилась другая проблема: допустим, мы быстро нашли все облака в текущем прямоугольнике. Что дальше?

Если их мало — рисуем все. Если их сотни или тысячи — рисовать ауру под каждым нельзя. Визуально это превращается в сплошное пятно, а смысл отдельных облаков теряется.

Первый вариант был ожидаемый: взять top-N по весу. То есть показать самые активные облака. Звучит логично, но на карте выглядело хуже, чем хотелось. Если в одной зоне есть несколько очень активных точек, они забирают весь лимит на себя. В итоге пользователь видит пару ярких мест, а остальная карта кажется пустой, хотя данные там есть.

После нескольких таких тестов стало понятно, что задача не совсем про «показать самые важные точки». Она скорее про ощущение живой карты. Пользователь не анализирует веса объектов как таблицу. Он смотрит на экран и считывает: здесь что-то происходит или нет.

Поэтому я сделал менее честный, но более приятный для UX отбор: беру часть самых тяжёлых, большую часть самых лёгких и немного из середины.

items.sort { $0.weight > $1.weight }

let big = Int((0.2 * Double(maxCount)).rounded())
let middle = Int((0.2 * Double(maxCount)).rounded())
let small = maxCount - big - middle

// самые активные
for i in 0..<big {
    result.append(items[i])
}

// самые тихие
for i in 0..<small {
    result.append(items[items.count - 1 - i])
}

// немного из середины
let offset = (items.count - big - middle) / 2 + big

for i in 0..<middle {
    result.append(items[offset + i])
}

Да, это выглядит контринтуитивно: большая часть выбранных облаков — самые тихие. Но именно они обычно лучше распределены по экрану. В результате карта не выглядит так, будто жизнь есть только в двух перегретых местах.

Это тот случай, где «правильный» алгоритм по продуктовой метрике проигрывает алгоритму, который лучше смотрится глазами. Формально top-N честнее. Визуально 20/60/20 выглядит полезнее.

Заранее считать облака за краем экрана

Если считать ауры только строго внутри видимой области, то при движении карты новые облака появляются из-за края слишком резко. Пользователь двигает карту, и элементы как будто догружаются в последний момент.

Чтобы этого не было, я считаю две области:

let inside = topClouds(
    in: visibleBounds,
    max: 15
)

let outside = topClouds(
    in: extendedBounds,
    except: visibleBounds,
    max: 100
)

visibleBounds — то, что прямо сейчас на экране.
extendedBounds — область чуть больше экрана, с запасом по краям.

Внутри экрана я беру небольшой лимит, например 15 аур. Вокруг экрана — больше, например 100, чтобы при движении карты рядом уже были подготовленные кандидаты.

Вот тут и пригодился exceptRect в QuadTree. Мне нужно не просто «найти всё в большом прямоугольнике», а «найти всё в большом прямоугольнике, кроме маленького прямоугольника внутри». То есть фактически область-кольцо.

Можно было бы сначала найти всё в extendedBounds, а потом постфильтром удалить то, что попало в visibleBounds. Но раз у дерева уже есть информация о прямоугольниках узлов, оно может отбрасывать целые поддеревья, которые полностью лежат внутри исключённой области. Это мелкая оптимизация, но приятная: меньше объектов доходит до фильтрации.

Что в итоге получилось

Пока были аннотации, карта сама знала, куда пользователь нажал. Когда остался GL-слой, у тебя на экране красивая картинка, но для приложения это уже не набор объектов. Тап, радиус, попадание, ближайший элемент, выбор видимых объектов — всё приходится обрабатывать самостоятельно.

В моём случае связка получилась такая:

  • QuadTree хранит облака в плоскости Меркатора и быстро отвечает на запросы по прямоугольникам;

  • тап превращается в маленький spatial query вокруг пальца;

  • сферическая геометрия помогает честно переводить пиксели, метры и координаты;

  • дорогой distance считается только для небольшой пачки кандидатов;

  • выбор аур делается не чистым top-N, а более «визуальным» 20/60/20;

  • область вокруг экрана считается заранее, чтобы ауры не выскакивали рывком при панорамировании.

Всё это началось с простой идеи: «давай перестанем рисовать тысячи UIView на карте». А дальше внезапно оказалось, что вместе с UIView мы выбросили и хит-тестинг, и готовую модель взаимодействия. Немного больше работы, но оно того стоило