вгадай київське місце на мапі
Ткни, де це в Києві, — гра зміряє твій промах у кілометрах.
Пʼять раундів · спільний запас промаху · близько хвилини
«Місцевості» зараховуємо з допуском: це район, а не точка. Грати можна мишею, пальцем або з клавіатури.
Приватний режим: статистика й незакінчена партія не збережуться. Грати можна.
Де це?
Тицьни в мапу, щоб поставити хрестик
Якорі мапи — цифра на клавіатурі переносить хрестик:
Мапа й координати — © учасники OpenStreetMap, ODbL
(function () {
'use strict';
var NL = String.fromCharCode(10);
var POOL_VERSION = "7d2c7429";
var DISTRICTS = ["Голосіївський","Дарницький","Деснянський","Дніпровський","Оболонський","Печерський","Подільський","Святошинський","Соломʼянський","Шевченківський"];
var PLACES = [
["Лавра","Національний заповідник «Києво-Печерська лавра»",50.4342,30.559,0,1,0.4,5],
["Софійський собор",0,50.4529,30.5143,0,1,0.1,9],
["Михайлівський Золотоверхий монастир",0,50.4558,30.523,0,1,0.1,9],
["Андріївська церква",0,50.459,30.518,0,1,0.05,9],
["Золоті ворота",0,50.4489,30.5134,0,1,0.1,9],
["Володимирський собор",0,50.4449,30.5088,0,1,0.05,9],
["Маріїнський палац",0,50.4481,30.538,0,1,0.1,5],
["Будинок з химерами",0,50.4451,30.5286,0,1,0.05,5],
["Оперний театр","Національна опера України імені Тараса Шевченка",50.4467,30.5124,0,1,0.05,9],
["Замок Річарда",0,50.46,30.516,0,2,0.05,6],
["Кирилівська церква",0,50.4835,30.472,0,2,0.05,6],
["Видубицький монастир",0,50.4167,30.5679,0,2,0.15,5],
["Покровський монастир","Свято-Покровський жіночий монастир",50.4592,30.4943,0,3,0.15,9],
["Китаївська пустинь","Свято-Троїцький монастир «Китаївська пустинь»",50.3672,30.5414,0,3,0.2,0],
["Міст Патона",0,50.4273,30.582,0,1,0.8,5],
["Міст Метро",0,50.4427,30.5644,0,1,0.5,3],
["Північний міст",0,50.4907,30.5359,0,2,0.6,4],
["Парковий міст",0,50.4566,30.5341,0,2,0.49,6],
["Дарницький міст","Дарницький залізнично-автомобільний міст",50.4156,30.5851,0,2,0.7,0],
["Фунікулер","Київський фунікулер",50.4572,30.5231,0,1,0.15,9],
["Ботанічний сад Гришка","Національний ботанічний сад імені Миколи Гришка",50.4137,30.5629,0,1,0.6,5],
["Ботанічний сад Фоміна","Ботанічний сад імені Олександра Фоміна",50.4431,30.504,0,2,0.3,9],
["Київський зоопарк",0,50.4555,30.4654,0,1,0.3,9],
["Маріїнський парк",0,50.4467,30.5405,0,2,0.25,5],
["Володимирська гірка",0,50.456,30.5254,0,1,0.25,9],
["Парк «Наталка»",0,50.4957,30.5249,0,2,0.35,4],
["Голосіївський парк","Голосіївський парк імені Максима Рильського",50.3901,30.5078,0,2,0.8,0],
["Парк «Феофанія»",0,50.3421,30.4869,0,2,0.9,0],
["Пейзажна алея",0,50.4564,30.5122,0,2,0.25,9],
["Палац «Україна»","Національний палац мистецтв «Україна»",50.4223,30.5213,0,1,0.25,5],
["Палац спорту",0,50.4372,30.5224,0,1,0.1,5],
["НСК «Олімпійський»","Національний спортивний комплекс «Олімпійський»",50.4334,30.5219,0,1,0.2,5],
["Стадіон «Динамо»","Стадіон «Динамо» імені Валерія Лобановського",50.4507,30.5336,0,1,0.15,5],
["Київський цирк","Національний цирк України",50.4479,30.4925,0,1,0.1,9],
["Могилянка","Національний університет «Києво-Могилянська академія»",50.4653,30.5212,0,1,0.25,6],
["КПІ","Національний технічний університет України «Київський політехнічний інститут імені Ігоря Сікорського»",50.4472,30.4543,0,1,0.54,8],
["Червоний корпус","Червоний корпус Київського національного університету імені Тараса Шевченка",50.4421,30.5105,0,2,0.1,9],
["Бібліотека Вернадського","Національна бібліотека України імені В. І. Вернадського",50.4042,30.5188,0,2,0.1,0],
["Український дім",0,50.4532,30.5268,0,2,0.05,9],
["ЦУМ","Центральний універмаг",50.4455,30.5205,0,1,0.05,9],
["Ocean Plaza",0,50.412,30.5225,0,1,0.1,0],
["Lavina Mall",0,50.4956,30.3605,0,2,0.2,7],
["Житній ринок",0,50.4649,30.5109,0,2,0.05,6],
["Володимирський ринок",0,50.4217,30.5183,0,3,0.05,0],
["Бессарабка","Бессарабський ринок",50.4424,30.5216,0,1,0.05,9],
["Аеропорт «Жуляни»","Міжнародний аеропорт «Київ» імені Ігоря Сікорського",50.4067,30.4494,0,1,1.2,8],
["Річковий вокзал","Київський річковий вокзал",50.4591,30.527,0,2,0.05,6],
["Центральний автовокзал",0,50.4065,30.5197,0,1,0.1,0],
["Майдан Незалежності",0,50.4502,30.5243,0,1,0.15,9],
["Контрактова площа",0,50.4638,30.5183,0,1,0.12,6],
["Поштова площа",0,50.4592,30.5264,0,1,0.1,6],
["Європейська площа",0,50.4522,30.5275,0,2,0.1,9],
["Театральна площа",0,50.4471,30.513,0,2,0.1,9],
["Либідська площа",0,50.4121,30.5254,0,2,0.1,0],
["Севастопольська площа",0,50.4251,30.4594,0,2,0.1,8],
["Площа Українських Героїв",0,50.4392,30.5161,0,2,0.12,0],
["Лукʼянівська площа",0,50.462,30.4819,0,2,0.1,9],
["Голосіївська площа",0,50.3977,30.5099,0,2,0.1,0],
["Арка Свободи","Арка Свободи українського народу",50.4544,30.53,0,1,0.05,5],
["Мистецький арсенал",0,50.4342,30.5535,0,2,0.1,5],
["PinchukArtCentre",0,50.4417,30.5214,0,2,0.05,5],
["Музей Ханенків","Національний музей мистецтв імені Богдана та Варвари Ханенків",50.4412,30.5146,0,3,0.05,9],
["Національний художній музей","Національний художній музей України",50.4494,30.5308,0,2,0.05,5],
["Музей історії України","Національний музей історії України",50.4583,30.5161,0,2,0.05,9],
["Пирогів","Національний музей народної архітектури та побуту України",50.3548,30.5149,0,1,1.0,0],
["Музей авіації","Державний музей авіації імені О. К. Антонова",50.4062,30.4607,0,2,0.25,8],
["Кінотеатр «Жовтень»",0,50.4684,30.5102,0,2,0.05,6],
["Театр Франка","Національний академічний драматичний театр імені Івана Франка",50.4456,30.5284,0,2,0.05,5],
["Театр Лесі Українки","Національний академічний драматичний театр імені Лесі Українки",50.4447,30.5186,0,2,0.05,9],
["Театр на Подолі",0,50.4603,30.5145,0,3,0.05,6],
["Національна філармонія","Національна філармонія України",50.4533,30.5282,0,2,0.05,5],
["Синагога Бродського",0,50.4386,30.5205,0,3,0.64,5],
["Мечеть Ар-Рахма",0,50.465,30.5,0,3,0.05,9],
["Костел Святого Миколая",0,50.4269,30.5176,0,2,0.05,5],
["Озеро Тельбін",0,50.4262,30.6063,0,3,0.35,3],
["Озеро Вербне",0,50.49,30.5161,0,3,0.3,4],
["Гідропарк",0,50.4485,30.5744,0,1,0.7,3],
["Парк «Муромець»",0,50.5074,30.5424,0,3,0.9,2],
["Долобецький острів",0,50.4588,30.5706,0,2,0.67,3],
["Венеціанський острів","Венеційський острів",50.4386,30.5777,0,2,0.6,3],
["Міжнародний виставковий центр",0,50.4524,30.5914,0,2,0.2,3],
["Київська фортеця",0,50.4313,30.5304,0,2,0.4,5],
["Київський іподром",0,50.3755,30.4609,0,2,0.4,0],
["Володимирська вулиця",0,50.4467,30.5137,1,1,1.48,9],
["Велика Васильківська вулиця",0,50.4294,30.5189,1,1,1.73,5],
["вулиця Богдана Хмельницького",0,50.4466,30.5098,1,1,0.84,9],
["вулиця Саксаганського",0,50.439,30.5057,1,1,1.09,0],
["Жилянська вулиця",0,50.441,30.4972,1,2,1.49,9],
["вулиця Антоновича",0,50.4266,30.5166,1,1,1.66,0],
["вулиця Гетьмана Павла Скоропадського",0,50.4402,30.5029,1,2,0.84,0],
["бульвар Тараса Шевченка",0,50.4444,30.5074,1,1,0.95,9],
["бульвар Лесі Українки",0,50.4295,30.5365,1,1,1.33,5],
["вулиця Михайла Грушевського",0,50.4477,30.5354,1,1,0.77,5],
["Інститутська вулиця",0,50.4462,30.5335,1,1,0.41,5],
["Прорізна вулиця",0,50.4483,30.5184,1,2,0.28,9],
["вулиця Ярославів Вал",0,50.4512,30.5099,1,2,0.42,9],
["Андріївський узвіз",0,50.4606,30.5165,1,1,0.21,6],
["вулиця Петра Сагайдачного",0,50.4603,30.523,1,1,0.41,6],
["вулиця Верхній Вал",0,50.467,30.5139,1,3,0.71,6],
["Межигірська вулиця",0,50.4723,30.5066,1,3,1.0,6],
["Набережно-Хрещатицька вулиця",0,50.4675,30.5232,1,2,0.85,6],
["Кирилівська вулиця",0,50.4808,30.4818,1,2,2.49,6],
["Оболонський проспект",0,50.5067,30.4984,1,1,2.44,4],
["проспект Степана Бандери",0,50.4888,30.5007,1,1,2.27,4],
["вулиця Героїв Дніпра",0,50.5206,30.4964,1,2,1.04,4],
["Оболонська набережна",0,50.5007,30.5196,1,2,1.42,4],
["Берестейський проспект",0,50.4552,30.4102,1,1,3.0,7],
["проспект Повітряних Сил",0,50.4239,30.4559,1,2,3.0,8],
["проспект Валерія Лобановського",0,50.4138,30.4866,1,2,2.29,8],
["Голосіївський проспект",0,50.394,30.5025,1,1,2.23,0],
["проспект Науки",0,50.3902,30.5319,1,1,2.24,0],
["проспект Миколи Бажана",0,50.3991,30.6404,1,1,3.0,1],
["проспект Петра Григоренка",0,50.4051,30.6301,1,2,2.19,1],
["Дніпровська набережна",0,50.412,30.6025,1,2,2.23,1],
["Харківське шосе",0,50.4233,30.6488,1,1,2.96,1],
["Броварський проспект",0,50.4609,30.6303,1,2,3.0,2],
["проспект Соборності",0,50.4374,30.6108,1,1,1.36,3],
["проспект Романа Шухевича",0,50.495,30.5744,1,2,2.38,3],
["проспект Червоної Калини",0,50.5112,30.608,1,2,2.13,2],
["вулиця Оноре де Бальзака",0,50.5131,30.5947,1,3,2.52,2],
["Братиславська вулиця",0,50.4764,30.6193,1,2,1.99,2],
["вулиця Ревуцького",0,50.4102,30.6469,1,3,1.79,1],
["вулиця Анни Ахматової",0,50.4089,30.6293,1,2,1.2,1],
["вулиця Олени Теліги",0,50.4777,30.4566,1,2,1.75,9],
["вулиця Юрія Іллєнка",0,50.4694,30.4651,1,3,1.5,9],
["Дегтярівська вулиця",0,50.4619,30.4509,1,2,2.19,9],
["вулиця Вадима Гетьмана",0,50.4449,30.444,1,2,0.81,8],
["Борщагівська вулиця",0,50.4456,30.4572,1,2,1.79,8],
["Чоколівський бульвар",0,50.4317,30.4554,1,3,0.79,8],
["проспект Академіка Глушкова",0,50.3688,30.4564,1,2,1.94,0],
["проспект Академіка Палладіна",0,50.4745,30.3593,1,3,2.33,7],
["проспект Європейського Союзу",0,50.5057,30.4269,1,3,1.93,6],
["проспект Володимира Івасюка",0,50.5076,30.5145,1,3,2.27,4],
["Воскресенський проспект",0,50.4805,30.5946,1,3,1.68,3],
["вулиця Миколи Закревського",0,50.5089,30.6157,1,2,2.23,2],
["бульвар Миколи Міхновського",0,50.4185,30.5471,1,2,1.92,5],
["Набережне шосе",0,50.443,30.5528,1,2,2.46,5],
["Столичне шосе",0,50.3588,30.5554,1,1,3.0,0],
["проспект Леся Курбаса",0,50.4289,30.3833,1,3,1.32,7],
["Березняки",0,50.4287,30.6042,2,2,1.0,3],
["Бортничі",0,50.3801,30.6913,2,3,1.2,1],
["Південна Борщагівка",0,50.4122,30.3997,2,2,1.2,7],
["Виноградар",0,50.5094,30.4261,2,1,1.5,6],
["Воскресенка",0,50.4842,30.5986,2,1,1.57,3],
["Голосіїв",0,50.3897,30.49,2,1,1.5,0],
["ДВРЗ",0,50.4516,30.6799,2,3,1.0,3],
["Стара Дарниця",0,50.4363,30.6408,2,2,0.8,3],
["Деміївка",0,50.404,30.5159,2,1,0.9,0],
["Жуляни",0,50.3928,30.4422,2,1,1.5,8],
["Звіринець",0,50.4192,30.5532,2,3,0.9,5],
["Караваєві Дачі",0,50.4401,30.4447,2,3,0.9,8],
["Конча-Заспа",0,50.3007,30.5765,2,1,2.0,0],
["Липки",0,50.4449,30.5331,2,2,0.6,5],
["Лісовий масив",0,50.4746,30.6302,2,2,1.0,2],
["Лукʼянівка",0,50.4644,30.4751,2,1,1.3,9],
["Мишоловка",0,50.3732,30.5285,2,3,0.8,0],
["Нивки",0,50.4702,30.4093,2,1,1.32,9],
["Новобіличі",0,50.4722,30.3375,2,3,1.1,7],
["Осокорки",0,50.3929,30.636,2,1,1.8,1],
["Печерськ",0,50.4322,30.5446,2,1,1.3,5],
["Поділ",0,50.4691,30.5166,2,1,1.3,6],
["Почайна",0,50.4882,30.4956,2,2,0.9,4],
["Пріорка",0,50.5047,30.4525,2,3,1.0,4],
["Пуща-Водиця",0,50.5371,30.3564,2,1,2.0,4],
["Русанівка",0,50.4398,30.5983,2,1,0.8,3],
["Святошин",0,50.4536,30.3711,2,1,1.5,7],
["Сирець",0,50.4754,30.4356,2,1,1.2,9],
["Совки",0,50.4062,30.4852,2,3,1.0,8],
["Соломʼянка",0,50.4335,30.4796,2,1,1.2,8],
["Теличка",0,50.3956,30.5711,2,3,1.2,0],
["Феофанія",0,50.3488,30.483,2,2,1.2,0],
["Харківський масив",0,50.4117,30.6581,2,2,1.2,1],
["Чоколівка",0,50.4314,30.4548,2,2,1.0,8],
["Шулявка",0,50.45,30.4441,2,1,1.0,8],
["Микільська Слобідка",0,50.4639,30.5873,2,2,1.94,3],
["Ширма",0,50.4031,30.4961,2,3,0.7,0],
["Батиєва гора",0,50.4307,30.4961,2,3,0.6,8],
["Протасів Яр",0,50.4247,30.501,2,2,0.6,8],
["Татарка",0,50.4691,30.4875,2,2,0.8,9],
["Щекавиця",0,50.4646,30.5004,2,2,0.5,9],
["Кудрявець",0,50.4575,30.4994,2,3,0.6,9],
["Корчувате",0,50.3656,30.5534,2,3,1.0,0],
["Китаїв",0,50.3701,30.5428,2,3,0.8,0],
["Рибальський острів",0,50.4793,30.5305,2,2,0.7,6],
["Микільська Борщагівка",0,50.43,30.3788,2,3,1.2,7],
["Гончарі-Кожумʼяки",0,50.4596,30.5114,2,3,0.4,6],
["Євбаз",0,50.4468,30.4931,2,3,0.4,9],
["Віта-Литовська",0,50.3297,30.5517,2,3,1.0,0]
];
var GEO = {"b":[50.2132,50.5908,30.2361,30.8264],"city":[50.2806,30.6407,50.2787,30.6096,50.2655,30.6175,50.2629,30.6264,50.2562,30.6253,50.2466,30.6339,50.2398,30.6454,50.2354,30.6369,50.2261,30.6436,50.2132,30.5927,50.2454,30.5862,50.2595,30.5755,50.2593,30.5699,50.2773,30.5638,50.2772,30.5538,50.2697,30.5536,50.2759,30.5426,50.2826,30.5479,50.291,30.5464,50.2907,30.5276,50.2958,30.5333,50.3171,30.5336,50.3262,30.5091,50.3219,30.4962,50.3268,30.4883,50.3325,30.4974,50.3354,30.4913,50.3333,30.4751,50.3395,30.4781,50.3526,30.4682,50.3601,30.4682,50.361,30.4577,50.3546,30.4521,50.357,30.4375,50.3669,30.4377,50.3719,30.4303,50.3814,30.4426,50.3956,30.4231,50.4063,30.3932,50.4232,30.3664,50.4452,30.3581,50.4457,30.3516,50.4505,30.3485,50.4459,30.3489,50.4423,30.3203,50.4473,30.2814,50.4307,30.2762,50.4263,30.2693,50.4312,30.2601,50.4232,30.2447,50.4268,30.2361,50.4405,30.2465,50.4524,30.2399,50.4698,30.2577,50.4743,30.2491,50.4848,30.2517,50.4877,30.2627,50.4982,30.2701,50.5115,30.2611,50.5208,30.2728,50.5273,30.2909,50.5327,30.2927,50.5334,30.3025,50.5439,30.3044,50.5544,30.298,50.5552,30.3111,50.5462,30.3265,50.5501,30.326,50.5513,30.3327,50.5567,30.3315,50.5564,30.3412,50.5705,30.3072,50.5736,30.3706,50.5865,30.3708,50.5843,30.4631,50.5717,30.4646,50.5723,30.4817,50.5677,30.4841,50.5668,30.4938,50.5516,30.502,50.5344,30.5415,50.5222,30.5422,50.522,30.555,50.5158,30.5657,50.521,30.568,50.5343,30.5526,50.5403,30.557,50.5342,30.654,50.5515,30.6641,50.5537,30.67,50.562,30.6976,50.5547,30.699,50.5556,30.714,50.5728,30.7083,50.5859,30.7214,50.5908,30.7198,50.5833,30.7536,50.5839,30.7664,50.5756,30.771,50.58,30.7839,50.5701,30.7974,50.564,30.8158,50.5371,30.8233,50.5185,30.7525,50.509,30.7619,50.4989,30.7376,50.4909,30.7402,50.492,30.7469,50.4876,30.7491,50.4902,30.7544,50.4836,30.7598,50.4838,30.7487,50.4744,30.7499,50.4688,30.7415,50.4698,30.7524,50.4616,30.7547,50.4585,30.7426,50.4433,30.7769,50.4381,30.7779,50.4337,30.7922,50.4268,30.8016,50.4153,30.7982,50.4041,30.8264,50.3919,30.8131,50.3979,30.7904,50.3949,30.776,50.3854,30.7806,50.3806,30.7642,50.3751,30.7621,50.372,30.7408,50.3824,30.7169,50.3786,30.7116,50.3688,30.7149,50.3657,30.7071,50.3546,30.7131,50.3473,30.6968,50.355,30.6738,50.3605,30.6739,50.3598,30.6686,50.3714,30.6792,50.3611,30.6359,50.3419,30.6348,50.3378,30.642,50.3419,30.6305,50.3487,30.6265,50.3464,30.6113,50.3399,30.6162,50.3373,30.6129,50.2916,30.6493,50.2784,30.6724,50.2744,30.6684,50.2736,30.6573,50.2682,30.6566,50.2807,30.6474,50.2806,30.6407],"river":[50.212,30.7103,50.218,30.7013,50.224,30.6996,50.242,30.6795,50.248,30.6769,50.26,30.6518,50.266,30.6195,50.272,30.6385,50.284,30.6379,50.29,30.6422,50.296,30.6359,50.302,30.6119,50.308,30.6071,50.314,30.5943,50.32,30.5908,50.332,30.5928,50.338,30.604,50.344,30.5846,50.356,30.5726,50.386,30.58,50.416,30.5786,50.422,30.5708,50.44,30.5615,50.458,30.5284,50.464,30.526,50.482,30.5384,50.5,30.5245,50.506,30.515,50.512,30.5145,50.518,30.5226,50.53,30.5226,50.536,30.5307,50.548,30.5009,50.554,30.4944,50.572,30.4917,50.584,30.5021,50.584,30.5247,50.578,30.539,50.572,30.5417,50.566,30.5342,50.554,30.5385,50.548,30.5333,50.542,30.5584,50.53,30.5552,50.524,30.5653,50.494,30.5684,50.482,30.5551,50.476,30.554,50.464,30.577,50.446,30.5904,50.44,30.5883,50.416,30.5937,50.41,30.5984,50.404,30.6136,50.398,30.5973,50.374,30.588,50.362,30.5907,50.356,30.5948,50.344,30.6137,50.32,30.6296,50.296,30.6548,50.272,30.7127,50.254,30.725,50.236,30.7264,50.23,30.7452,50.224,30.7367,50.218,30.756,50.212,30.7585,50.212,30.7103],"isles":{"trukhaniv":[50.4824,30.5503,50.4861,30.5506,50.4878,30.5473,50.4877,30.5498,50.4908,30.546,50.4913,30.5485,50.4964,30.5479,50.4985,30.5446,50.4967,30.5481,50.4988,30.5512,50.5057,30.5491,50.5148,30.5412,50.5206,30.5426,50.5228,30.5402,50.5111,30.534,50.5025,30.5343,50.4958,30.5361,50.4834,30.5461,50.4766,30.543,50.4672,30.5323,50.4617,30.5327,50.4566,30.5378,50.4558,30.5449,50.4476,30.56,50.4586,30.5468,50.4595,30.551,50.4612,30.5426,50.4649,30.5381,50.4614,30.5429,50.4608,30.5494,50.464,30.547,50.4645,30.5492,50.4478,30.5665,50.4514,30.57,50.4627,30.5626,50.4718,30.5519,50.4824,30.5503],"venetsiiskyi":[50.4436,30.5679,50.4278,30.5869,50.4346,30.5884,50.4394,30.5864,50.439,30.5833,50.4487,30.5832,50.447,30.5676,50.4436,30.5679],"dolobetskyi":[50.4504,30.5808,50.4607,30.5748,50.4566,30.5705,50.4613,30.5748,50.4643,30.573,50.4638,30.5749,50.4685,30.5667,50.4687,30.5618,50.4489,30.5722,50.4504,30.5808],"muromets":[50.523,30.5405,50.5208,30.5424,50.5223,30.5429,50.522,30.5517,50.5219,30.5434,50.5161,30.5415,50.5062,30.549,50.5122,30.5521,50.5139,30.5618,50.5183,30.565,50.5186,30.5591,50.5211,30.5561,50.5195,30.5586,50.5223,30.5639,50.5224,30.5582,50.5255,30.5537,50.5273,30.5549,50.5259,30.5589,50.5286,30.5537,50.5337,30.5509,50.5456,30.5566,50.5432,30.5576,50.5453,30.5602,50.5496,30.545,50.5492,30.5381,50.5483,30.545,50.5476,30.5427,50.5489,30.535,50.5447,30.5302,50.5372,30.5377,50.5443,30.5348,50.5433,30.5365,50.5455,30.5355,50.5433,30.5373,50.5451,30.5393,50.5361,30.5383,50.5314,30.5431,50.5348,30.5424,50.5301,30.5443,50.523,30.5405],"hidropark":[50.4453,30.5833,50.4448,30.5836,50.4442,30.5832,50.4424,30.5839,50.4407,30.5854,50.4426,30.5852,50.4441,30.5844,50.445,30.5847,50.4458,30.584,50.4457,30.5836,50.4453,30.5833]}};
var ANCHORS = [["1","Хрещатик",50.4479,30.5231,"e","point"],["2","Головний вокзал",50.4404,30.4894,"w","point"],["3","Оболонь",50.5107,30.5034,"w","area"],["4","Троєщина",50.5137,30.6065,"e","area"],["5","ВДНГ",50.3809,30.4766,"s","point"],["6","Теремки",50.3722,30.4525,"w","area"],["7","Позняки",50.4108,30.6272,"e","area"],["8","Академмістечко",50.4667,30.3644,"w","area"],["9","Дніпро",50.452,30.5636,"e","river"],["0","Либідь",50.417,30.521,"s","river"]];
var LINKS = [["Голосіївський","Кафе й ресторани в Голосіївському","https://my-kiev.com/catalog/kafe/holosiivskyi"],["Дарницький","Кафе й ресторани в Дарницькому","https://my-kiev.com/catalog/kafe/darnytskyi"],["Деснянський","Кафе й ресторани в Деснянському","https://my-kiev.com/catalog/kafe/desnianskyi"],["Дніпровський","Кафе й ресторани в Дніпровському","https://my-kiev.com/catalog/kafe/dniprovskyi"],["Оболонський","Кафе й ресторани на Оболоні","https://my-kiev.com/catalog/kafe/obolonskyi"],["Печерський","Кафе й ресторани на Печерську","https://my-kiev.com/catalog/kafe/pecherskyi"],["Подільський","Кафе й ресторани на Подолі","https://my-kiev.com/catalog/kafe/podilskyi"],["Святошинський","Кафе й ресторани у Святошинському","https://my-kiev.com/catalog/kafe/sviatoshynskyi"],["Соломʼянський","Кафе й ресторани на Соломʼянці","https://my-kiev.com/catalog/kafe/solomyanskyi"],["Шевченківський","Кафе й ресторани в Шевченківському","https://my-kiev.com/catalog/kafe/shevchenkivskyi"]];
var CFG = {
scope: '.shpylka-special',
storeKey: 'mkv_game_shpylka',
endpoint: ''
};
var STATE_V = 1;
var ROUNDS = 5;
var SEEN_MAX = 40; // spec.state: вікно = min(40, пул − 10)
var SEEN_RESERVE = 10;
var STEP_M = 210; // крок стрілки в МЕТРАХ (spec.constraints.a11y_detail)
var STEP_BIG_M = 1050; // Shift+стрілка
var ARM_PX = 20;
var DOT_PX = 4.5;
var INPUT_GUARD_MS = 480; // КОВЗНЕ вікно глухоти під кнопкою (B1)
var GUARD_PAD = 10; // запас поверх габаритів кнопки (B1)
var CLAIM_TTL = 20000; // застарілий токен щоденної віддається (B2)
var MAX_COUNT = 1000000; // стеля лічильників, що друкуються (M-E)
var MAX_KM = 300; // стеля будь-якої суми на екрані (M-E)
var SPREAD_TRIES = 60; // спроби набрати розкидану пʼятірку (M11)
var EPOCH_DAY = '2026-07-29';
var TOL_NOTE = 'Місцевості зараховуємо з допуском: це район, а не точка';
var SIZE_NOTE = 'Обʼєкт великий: у бал іде відстань до його краю, а не до центра';
var TIER_ID = ['landmark', 'street', 'locality'];
var MODES = [
{ id: 'easy', label: 'Впізнавані місця', tier: 0, budget: 35, daily: false },
{ id: 'medium', label: 'Вулиці', tier: 1, budget: 25, daily: false },
{ id: 'hard', label: 'Місцевості', tier: 2, budget: 20, daily: false },
{ id: 'daily', label: 'Челендж дня', tier: -1, budget: 30, daily: true }
];
var MODE_IDS = ['easy', 'medium', 'hard', 'daily'];
var BANDS = [
{ id: 'hit', max: 1, cp: 0x1F7E9, label: 'Влучив у квартал' },
{ id: 'near', max: 3, cp: 0x1F7E8, label: 'Промах на район' },
{ id: 'off', max: 7, cp: 0x1F7E7, label: 'Промах на кілька районів' },
{ id: 'lost', max: null, cp: 0x1F7E5, label: 'Промах на інший кінець міста' }
];
var RANKS = [
{ max: 3, label: 'Ходить пішки', line: 'Пʼять шпильок, і жодна не мимо: місто ти знаєш ногами.',
alt: 'Чотири шпильки лягли щільно, найгірша — {w} км убік: звання за чотирма найкращими.' },
{ max: 7, label: 'Свій на районі', line: 'Своє знаєш до підʼїзду, чуже — приблизно.' },
{ max: 15, label: 'Гість столиці', line: 'Хрещатик і ВДНГ на місці, решта пливе.' },
{ max: null, label: 'Орієнтуєшся по Хрещатику', line: 'Одна точка опори — теж точка опори.' }
];
var DAILY_QUOTA = [0, 2, 2, 1];
var DAILY_ORDER = [1, 1, 2, 2, 3]; // порядок раундів: спершу впізнаване
var DIRS = ['північ', 'північний схід', 'схід', 'південний схід',
'південь', 'південний захід', 'захід', 'північний захід'];
var SVG_NS = 'http://www.w3.org/2000/svg';
var LAT_REF = 50.45;
var M_PER_DEG_LAT = 111320;
var M_PER_DEG_LON = 111320 * Math.cos(LAT_REF * Math.PI / 180);
var B = { latMin: GEO.b[0], latMax: GEO.b[1], lonMin: GEO.b[2], lonMax: GEO.b[3] };
var SPAN_LAT = B.latMax - B.latMin;
var SPAN_LON = B.lonMax - B.lonMin;
var SPAN_LAT_M = SPAN_LAT * M_PER_DEG_LAT;
var SPAN_LON_M = SPAN_LON * M_PER_DEG_LON;
var VB_W = 1000;
var VB_H = VB_W * SPAN_LAT_M / SPAN_LON_M; // ~1004 на реальних межах Києва
var M_PER_UNIT_X = SPAN_LON_M / VB_W;
var M_PER_UNIT_Y = SPAN_LAT_M / VB_H;
function ux(lon) { return (lon - B.lonMin) / SPAN_LON * VB_W; }
function uy(lat) { return (B.latMax - lat) / SPAN_LAT * VB_H; }
function lonOf(x) { return B.lonMin + x / VB_W * SPAN_LON; }
function latOf(y) { return B.latMax - y / VB_H * SPAN_LAT; }
function distKm(a, c) {
var dx = (a.lon - c.lon) * M_PER_DEG_LON;
var dy = (a.lat - c.lat) * M_PER_DEG_LAT;
return Math.sqrt(dx * dx + dy * dy) / 1000;
}
function fmt(km) { return (Math.round(km * 10) / 10).toFixed(1); }
function pad2(n) { return (n < 10 ? '0' : '') + n; }
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function ordinal(n) { return n + '-му'; }
function place(i) {
var r = PLACES[i];
return {
idx: i, name: r[0], official: r[1] || '', lat: r[2], lon: r[3],
tier: r[4], fame: r[5], tol: r[6], district: DISTRICTS[r[7]]
};
}
function mode(id) {
for (var i = 0; i < MODES.length; i++) if (MODES[i].id === id) return MODES[i];
return MODES[0];
}
function bandOf(km) {
for (var i = 0; i < BANDS.length; i++) {
if (BANDS[i].max === null || km <= BANDS[i].max) return i;
}
return BANDS.length - 1;
}
function rankBasis(rounds) {
var v = [], i;
for (i = 0; i < rounds.length; i++) v.push(rounds[i].km);
v.sort(function (a, b) { return a - b; });
var s = 0;
if (v.length < 2) { for (i = 0; i < v.length; i++) s += v[i]; return s; }
for (i = 0; i < v.length - 1; i++) s += v[i];
return s * v.length / (v.length - 1);
}
function rankOf(total) {
for (var i = 0; i < RANKS.length; i++) {
if (RANKS[i].max === null || total <= RANKS[i].max) return RANKS[i];
}
return RANKS[RANKS.length - 1];
}
function rankLine(rk, rounds) {
var worst = 0, i;
for (i = 0; i < rounds.length; i++) if (rounds[i].km > worst) worst = rounds[i].km;
if (!rk.alt || worst <= BANDS[0].max) return rk.line;
return rk.alt.replace('{w}', fmt(worst));
}
function rankKnown(s) {
for (var i = 0; i < RANKS.length; i++) if (RANKS[i].label === s) return true;
return false;
}
function sq(i) { return String.fromCodePoint(BANDS[i].cp); }
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seedOf(str) {
var h = 2166136261;
for (var i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
function shuffle(arr, rnd) {
var a = arr.slice();
for (var i = a.length - 1; i > 0; i--) {
var j = Math.floor(rnd() * (i + 1));
var t = a[i]; a[i] = a[j]; a[j] = t;
}
return a;
}
function kyivDayKey() {
var d = new Date();
var zones = ['Europe/Kyiv', 'Europe/Kiev'];
for (var i = 0; i < zones.length; i++) {
try {
var s = d.toLocaleDateString('en-CA', { timeZone: zones[i] });
if (s && s.length === 10 && s.charAt(4) === '-') return s;
} catch (e) { /* старий браузер не знає зони — пробуємо наступну */ }
}
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
function dayIndex(key) {
var p = key.split('-');
return Math.floor(Date.UTC(+p[0], +p[1] - 1, +p[2]) / 86400000);
}
function shiftDay(key, delta) {
var d = new Date((dayIndex(key) + delta) * 86400000);
return d.getUTCFullYear() + '-' + pad2(d.getUTCMonth() + 1) + '-' + pad2(d.getUTCDate());
}
function dailyNumber(key) {
return Math.max(1, dayIndex(key) - dayIndex(EPOCH_DAY) + 1);
}
var storageOk = true;
var st = {
v: STATE_V, poolVersion: POOL_VERSION,
plays: 0, finished: 0, streak: 0, maxStreak: 0,
lastDailyKey: null, prevDailyKey: null, lastDailyResult: null,
best: {}, seen: {}, run: null
};
function readStored() {
try {
var raw = localStorage.getItem(CFG.storeKey);
if (!raw) return null;
var o = JSON.parse(raw);
if (!o || typeof o !== 'object') return null;
return (o.v === STATE_V && o.poolVersion === POOL_VERSION) ? o : null;
} catch (e) { return null; }
}
function num(v) { return typeof v === 'number' && isFinite(v) ? v : 0; }
function roundsIn(r) { return (r && r.rounds && r.rounds.length) || 0; }
function mergeFrom(o) {
if (!o) return;
st.plays = Math.max(num(o.plays), num(st.plays));
st.finished = Math.max(num(o.finished), num(st.finished));
st.maxStreak = Math.max(num(o.maxStreak), num(st.maxStreak));
if (o.best) {
for (var k in o.best) {
if (!Object.prototype.hasOwnProperty.call(o.best, k)) continue;
if (typeof o.best[k] !== 'number') continue;
if (typeof st.best[k] !== 'number' || o.best[k] < st.best[k]) st.best[k] = o.best[k];
}
}
if (o.seen) {
for (var m in o.seen) {
if (!Object.prototype.hasOwnProperty.call(o.seen, m)) continue;
var theirs = o.seen[m] || [], mine = st.seen[m] || [], u = theirs.slice();
for (var i = 0; i < mine.length; i++) if (u.indexOf(mine[i]) < 0) u.push(mine[i]);
var win = seenWindow((POOL[m] || PLACES).length);
st.seen[m] = u.length > win ? u.slice(u.length - win) : u;
}
}
var mineKey = st.lastDailyKey || '', theirKey = o.lastDailyKey || '';
if (theirKey > mineKey ||
(theirKey && theirKey === mineKey && roundsIn(o.lastDailyResult) > roundsIn(st.lastDailyResult))) {
st.lastDailyKey = o.lastDailyKey;
st.prevDailyKey = o.prevDailyKey || null;
st.lastDailyResult = o.lastDailyResult;
st.streak = num(o.streak);
}
}
function intAt(v) { return clamp(Math.floor(num(v)), 0, MAX_COUNT); }
function kmAt(v) { return clamp(num(v), 0, MAX_KM); }
function sanitize() {
st.plays = intAt(st.plays);
st.finished = Math.min(st.plays, intAt(st.finished));
var cap = dailyNumber(kyivDayKey());
st.streak = Math.min(intAt(st.streak), cap);
st.maxStreak = Math.max(st.streak, Math.min(intAt(st.maxStreak), cap));
if (!st.best || typeof st.best !== 'object') st.best = {};
for (var k in st.best) {
if (!Object.prototype.hasOwnProperty.call(st.best, k)) continue;
var b = st.best[k];
if (typeof b !== 'number' || !isFinite(b) || b < 0 || b > MAX_KM) delete st.best[k];
}
if (!st.seen || typeof st.seen !== 'object') st.seen = {};
for (var m in st.seen) {
if (!Object.prototype.hasOwnProperty.call(st.seen, m)) continue;
var arr = st.seen[m], clean = [];
if (Object.prototype.toString.call(arr) !== '[object Array]') { st.seen[m] = []; continue; }
for (var i = 0; i < arr.length; i++) {
var v = arr[i];
if (typeof v === 'number' && v >= 0 && v < PLACES.length && clean.indexOf(v) < 0) clean.push(v);
}
st.seen[m] = clean;
}
if (typeof st.lastDailyKey !== 'string' || st.lastDailyKey.length !== 10) st.lastDailyKey = null;
if (typeof st.prevDailyKey !== 'string' || st.prevDailyKey.length !== 10) st.prevDailyKey = null;
if (!st.lastDailyKey) st.lastDailyResult = null;
var r = st.lastDailyResult;
if (r && typeof r === 'object') {
if (Object.prototype.toString.call(r.rounds) !== '[object Array]') r.rounds = [];
r.rounds = r.rounds.filter(function (g) {
return g && typeof g.km === 'number' && isFinite(g.km) && g.km >= 0;
}).map(function (g) {
var k = kmAt(g.km);
return { km: k, b: bandOf(k) };
}).slice(0, ROUNDS);
r.total = kmAt(r.total);
r.dayKey = st.lastDailyKey;
if (!rankKnown(r.rank)) {
r.rank = (r.busted || !r.rounds.length) ? null : rankOf(rankBasis(r.rounds)).label;
}
if (DISTRICTS.indexOf(r.worst) < 0) r.worst = null;
} else if (r) {
st.lastDailyResult = null;
}
var q = st.run;
if (q && typeof q === 'object' && MODE_IDS.indexOf(q.mode) >= 0) {
var lim = mode(q.mode).budget;
q.budgetLeft = clamp(num(q.budgetLeft), -lim, lim);
q.roundIdx = clamp(intAt(q.roundIdx), 0, ROUNDS - 1);
} else if (q) {
st.run = null;
}
}
function loadState() {
var raw = null;
try {
raw = localStorage.getItem(CFG.storeKey);
localStorage.setItem(CFG.storeKey + '_t', '1');
localStorage.removeItem(CFG.storeKey + '_t');
} catch (e) { storageOk = false; }
if (!raw) return;
var o;
try { o = JSON.parse(raw); } catch (e) { return; }
if (!o || typeof o !== 'object') return;
if (typeof o.v !== 'undefined' && o.v !== STATE_V) return;
for (var k in st) if (Object.prototype.hasOwnProperty.call(o, k)) st[k] = o[k];
st.v = STATE_V;
if (!st.best) st.best = {};
if (!st.seen) st.seen = {};
if (o.poolVersion !== POOL_VERSION) {
st.seen = {};
st.run = null;
st.poolVersion = POOL_VERSION;
}
sanitize();
}
function saveState() {
if (!storageOk) return;
try {
mergeFrom(readStored()); // read-modify-write, а не сліпий перезапис
sanitize();
localStorage.setItem(CFG.storeKey, JSON.stringify(st));
} catch (e) { storageOk = false; }
}
var CLAIM_KEY = CFG.storeKey + '_c';
var SESSION = String(Date.now()) + ':' + Math.random();
try {
var sk = CFG.storeKey + '_s';
var sv = sessionStorage.getItem(sk);
if (sv) SESSION = sv; else sessionStorage.setItem(sk, SESSION);
} catch (e) { /* немає sessionStorage — токен живе до перезавантаження */ }
var claimTimer = 0;
function claimRead() {
try { return (localStorage.getItem(CLAIM_KEY) || '').split('|'); } catch (e) { return []; }
}
function claimStamp(dayKey) {
try { localStorage.setItem(CLAIM_KEY, dayKey + '|' + SESSION + '|' + Date.now()); } catch (e) { /* дарма */ }
}
function claimMine(dayKey) {
var c = claimRead();
return c[0] === dayKey && c[1] === SESSION;
}
function claimBusy(dayKey) {
var c = claimRead();
return c[0] === dayKey && c[1] !== SESSION && Date.now() - (+c[2] || 0) < CLAIM_TTL;
}
function claimTake(dayKey) {
if (!storageOk) return true;
if (claimBusy(dayKey)) return false;
claimStamp(dayKey);
if (claimRead()[1] !== SESSION) return false;
if (claimTimer) clearInterval(claimTimer);
claimTimer = setInterval(function () {
if (claimMine(dayKey)) claimStamp(dayKey);
else claimDrop(false);
}, 6000);
return true;
}
function claimDrop(release) {
if (claimTimer) { clearInterval(claimTimer); claimTimer = 0; }
if (!release) return;
try {
var c = claimRead();
if (c[1] === SESSION) localStorage.removeItem(CLAIM_KEY);
} catch (e) { /* дарма */ }
}
var events = [];
function track(event, payload) {
try {
var rec = { g: 'shpylka', e: event, t: Date.now() };
if (payload) for (var k in payload) rec[k] = payload[k];
events.push(rec);
if (events.length > 60) events.shift();
if (!CFG.endpoint) return;
var body = JSON.stringify(rec);
if (navigator.sendBeacon) {
navigator.sendBeacon(CFG.endpoint, new Blob([body], { type: 'application/json' }));
}
} catch (e) { /* аналітика не має права уронити гру */ }
}
var root = document.querySelector(CFG.scope);
if (!root) return;
var $ = function (s) { return root.querySelector(s); };
var $$ = function (s) { return Array.prototype.slice.call(root.querySelectorAll(s)); };
var el = {
live: $('[data-live]'),
toast: $('[data-toast]'),
err: $('[data-err]'),
expired: $('[data-expired]'),
storeNote: $('[data-store-note]'),
resume: $('[data-resume]'),
resumeTxt: $('[data-resume-txt]'),
dailyNote: $('[data-daily-note]'),
stats: $('[data-stats]'),
hudRound: $('[data-hud="round"]'),
hudBudget: $('[data-hud="budget"]'),
hudMode: $('[data-hud="mode"]'),
ask: $('[data-place]'),
board: $('[data-board]'),
aimline: $('[data-aimline]'),
bar: $('[data-bar]'),
legend: $('[data-legend]'),
reveal: $('[data-reveal]'),
revealSq: $('[data-reveal-sq]'),
revealKm: $('[data-reveal-km]'),
revealMeta: $('[data-reveal-meta]'),
revealTol: $('[data-reveal-tol]'),
submit: $('[data-submit]'),
quit: $('[data-quit]'),
rTitle: $('[data-result-title]'),
rSub: $('[data-result-sub]'),
rStrip: $('[data-strip]'),
rTotal: $('[data-result-total]'),
rWorst: $('[data-result-worst]'),
rStats: $('[data-result-stats]'),
crosslink: $('[data-crosslink]'),
share: $('[data-share]'),
shareFb: $('[data-share-fallback]'),
replay: $('[data-replay]')
};
var liveTimer = 0;
function announce(msg) {
if (!el.live) return;
el.live.textContent = '';
clearTimeout(liveTimer);
liveTimer = setTimeout(function () { el.live.textContent = msg; }, 60);
}
var toastTimer = 0;
function toast(msg) {
if (!el.toast) return;
el.toast.textContent = msg;
el.toast.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { el.toast.hidden = true; }, 2200);
}
function focusTitle(id) {
var h = $('[data-screen="' + id + '"] .gm-title');
if (h) { h.setAttribute('tabindex', '-1'); h.focus({ preventScroll: true }); }
}
function show(id) {
guardInput();
$$('[data-screen]').forEach(function (s) {
var on = s.getAttribute('data-screen') === id;
s.hidden = !on;
s.classList.toggle('is-active', on);
});
if (id !== 'game') focusTitle(id);
}
function resetScroll() {
try {
var s = $('[data-screen="game"]');
var now = window.pageYOffset || document.documentElement.scrollTop || 0;
var y = now + s.getBoundingClientRect().top - 8;
if (y < 0) y = 0;
if (Math.abs(y - now) > 2) window.scrollTo(0, y);
} catch (e) { /* дарма */ }
}
function focusBoard() {
try { el.board.focus({ preventScroll: true }); } catch (e) { /* дарма */ }
}
function focusGuard() {
setTimeout(function () {
var s = $('[data-screen="game"]');
if (!s || s.hidden) return;
if (document.activeElement && document.activeElement !== document.body) return;
focusBoard();
}, 410); // рятувати фокус треба швидше, ніж живе вікно гарду
}
function keepActionVisible() {
try {
var vh = window.innerHeight || document.documentElement.clientHeight;
var r = el.submit.getBoundingClientRect();
if (r.bottom > vh - 2 || r.top < 0) { el.submit.scrollIntoView({ block: 'nearest' }); return; }
var rv = el.reveal.getBoundingClientRect();
var bar = (el.bar || el.submit.parentNode).getBoundingClientRect();
var over = rv.bottom - (vh - bar.height - 6);
if (over > 0 && rv.top - over > 0) window.scrollBy(0, over);
} catch (e) { /* дарма */ }
}
function setPhase(p) {
phase = p;
var s = $('[data-screen="game"]');
if (s) s.setAttribute('data-phase', p);
}
var reduceMotion = false;
try {
reduceMotion = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
} catch (e) { /* нема matchMedia — вважаємо, що рух дозволено */ }
var svg = null;
var layerPlayer = null;
var scalMap = [];
var scalPlayer = [];
var scalTarget = scalMap;
var anchorPts = [];
var lastPlayer = {}; // останні опції drawPlayer — для перемальовки
var guardUntil = 0; // до цього моменту мапа глуха під кнопкою
var lastBtnBox = null; // габарити кнопки, яка привела нас сюди
function guardInput() { guardUntil = Date.now() + INPUT_GUARD_MS; }
function noteBtn(b) {
try {
var r = b.getBoundingClientRect();
lastBtnBox = { l: r.left - GUARD_PAD, t: r.top - GUARD_PAD,
r: r.right + GUARD_PAD, b: r.bottom + GUARD_PAD };
} catch (e) { lastBtnBox = null; }
}
function inBtnBox(ev) {
var b = lastBtnBox;
return !!b && ev.clientX >= b.l && ev.clientX <= b.r && ev.clientY >= b.t && ev.clientY <= b.b;
}
function isEchoTap(ev) {
if (!lastBtnBox || Date.now() >= guardUntil || !inBtnBox(ev)) return false;
guardInput();
return true;
}
function mk(tag, cls) {
var n = document.createElementNS(SVG_NS, tag);
if (cls) n.setAttribute('class', cls);
return n;
}
function scalable(node, attr, basePx) {
scalTarget.push([node, attr, basePx]);
return node;
}
function pathD(flat, close) {
var d = '';
for (var i = 0; i < flat.length; i += 2) {
d += (i ? 'L' : 'M') + ux(flat[i + 1]).toFixed(1) + ' ' + uy(flat[i]).toFixed(1) + ' ';
}
return close ? d + 'Z' : d;
}
var ART = 'https://my-kiev.com/wp-content/uploads/2026/07/vgaday-mistse-na-karti-kyyeva';
function wireLede() {
var art = $('[data-art]');
var lede = $('[data-lede]');
if (!art || !lede || art.getAttribute('data-wired')) return;
art.setAttribute('data-wired', '1');
var drop = function () { lede.className = 'gm-lede'; };
art.addEventListener('error', drop);
art.decoding = 'async';
art.sizes = '(max-width: 752px) calc(100vw - 32px), 688px';
art.srcset = ART + '-620x353.webp 620w, ' + ART + '-1280x720.webp 1280w, ' + ART + '.webp 1500w';
art.src = ART + '.webp';
}
function buildMap() {
scalMap = [];
scalPlayer = [];
scalTarget = scalMap;
anchorPts = ANCHORS.map(function (a) {
return { key: a[0], name: a[1], lat: a[2], lon: a[3], side: a[4], kind: a[5] };
});
svg = mk('svg', 'gm-map');
svg.setAttribute('viewBox', '0 0 ' + VB_W + ' ' + VB_H.toFixed(1));
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
var city = mk('path', 'gm-city');
city.setAttribute('d', pathD(GEO.city, true));
scalable(city, 'stroke-width', 1.2);
svg.appendChild(city);
var river = mk('path', 'gm-river');
river.setAttribute('d', pathD(GEO.river, true));
svg.appendChild(river);
for (var key in GEO.isles) {
if (!Object.prototype.hasOwnProperty.call(GEO.isles, key)) continue;
var isle = mk('path', 'gm-isle');
isle.setAttribute('d', pathD(GEO.isles[key], true));
svg.appendChild(isle);
}
var gAnchors = mk('g', 'gm-anchors');
anchorPts.forEach(function (a) {
var x = ux(a.lon), y = uy(a.lat);
if (a.kind !== 'river') {
var dot = mk('circle', 'gm-anchor-dot');
dot.setAttribute('cx', x.toFixed(1));
dot.setAttribute('cy', y.toFixed(1));
scalable(dot, 'r', 2.6);
scalable(dot, 'stroke-width', 1.4);
gAnchors.appendChild(dot);
}
var t = mk('text', 'gm-anchor-label' + (a.kind === 'river' ? ' gm-anchor-label--river' : ''));
var dx = 6, dy = 4, anch = 'start';
if (a.side === 'w') { dx = -6; anch = 'end'; }
else if (a.side === 's') { dx = 0; dy = 17; anch = 'middle'; }
else if (a.side === 'n') { dx = 0; dy = -8; anch = 'middle'; }
t.setAttribute('text-anchor', anch);
t.setAttribute('x', x.toFixed(1));
t.setAttribute('y', y.toFixed(1));
t.textContent = a.name;
scalable(t, 'dx', dx);
scalable(t, 'dy', dy);
scalable(t, 'stroke-width', 3);
gAnchors.appendChild(t);
});
svg.appendChild(gAnchors);
layerPlayer = mk('g', 'gm-player');
svg.appendChild(layerPlayer);
el.board.textContent = '';
el.board.appendChild(svg);
el.board.style.aspectRatio = VB_W + ' / ' + VB_H.toFixed(1);
rescale();
}
function viewScale() {
if (!svg) return null;
var r = svg.getBoundingClientRect();
if (!r.width || !r.height) return null;
var s = Math.min(r.width / VB_W, r.height / VB_H);
return {
s: s,
ox: r.left + (r.width - VB_W * s) / 2,
oy: r.top + (r.height - VB_H * s) / 2
};
}
function rescale() {
var v = viewScale();
if (!v) return;
var k = 1 / v.s; // одиниць viewBox на піксель
svg.style.fontSize = (12.5 * k).toFixed(2) + 'px';
[scalMap, scalPlayer].forEach(function (list) {
for (var i = 0; i < list.length; i++) {
list[i][0].setAttribute(list[i][1], (list[i][2] * k).toFixed(2));
}
});
nudgeLabels();
}
function nudgeLabels() {
if (!svg) return;
var vs = viewScale();
var kpx = vs ? 1 / vs.s : VB_W / 520;
var texts = svg.querySelectorAll('.gm-anchor-label');
var placed = [];
for (var i = 0; i < texts.length; i++) {
var t = texts[i];
t.removeAttribute('transform');
var bb;
try { bb = t.getBBox(); } catch (e) { return; }
if (!bb || !bb.width) continue;
var side = t.getAttribute('text-anchor');
if ((bb.x < 0 && side === 'end') || (bb.x + bb.width > VB_W && side === 'start')) {
t.setAttribute('text-anchor', side === 'end' ? 'start' : 'end');
for (var q = 0; q < scalMap.length; q++) {
if (scalMap[q][0] === t && scalMap[q][1] === 'dx') {
scalMap[q][2] = -scalMap[q][2];
t.setAttribute('dx', (scalMap[q][2] * kpx).toFixed(2));
}
}
try { bb = t.getBBox(); } catch (e) { return; }
}
var step = bb.height + bb.height * 0.25;
var shift = 0, tries = 0;
while (tries < 8 && overlaps(bb, shift, placed)) {
tries++;
shift = (tries % 2 ? 1 : -1) * Math.ceil(tries / 2) * step;
}
if (shift) t.setAttribute('transform', 'translate(0 ' + shift.toFixed(1) + ')');
placed.push({ x: bb.x, y: bb.y + shift, w: bb.width, h: bb.height });
}
}
function overlaps(bb, shift, placed) {
var pad = bb.height * 0.3; // підписи, що ледь торкаються, теж нечитні
var x0 = bb.x - pad, x1 = bb.x + bb.width + pad;
var y0 = bb.y + shift - pad * 0.4, y1 = bb.y + shift + bb.height + pad * 0.4;
for (var i = 0; i < placed.length; i++) {
var p = placed[i];
if (x0 < p.x + p.w && x1 > p.x && y0 < p.y + p.h && y1 > p.y) return true;
}
return false;
}
function onViewportChange() {
rescale();
if (lastPlayer && (lastPlayer.aim || lastPlayer.real)) drawPlayer(lastPlayer);
}
function drawPlayer(opts) {
if (!layerPlayer) return;
var o = opts || {};
lastPlayer = o; // щоб перемалювати шар після ресайзу
var vs = viewScale();
var kpx = vs ? 1 / vs.s : VB_W / 520; // одиниць viewBox на CSS-піксель
layerPlayer.textContent = '';
scalPlayer = [];
scalTarget = scalPlayer;
if (o.line && o.aim && o.real) {
var ln = mk('line', 'gm-link' + (reduceMotion ? '' : ' is-drawn'));
ln.setAttribute('x1', ux(o.aim.lon).toFixed(1));
ln.setAttribute('y1', uy(o.aim.lat).toFixed(1));
ln.setAttribute('x2', ux(o.real.lon).toFixed(1));
ln.setAttribute('y2', uy(o.real.lat).toFixed(1));
scalable(ln, 'stroke-width', 2);
layerPlayer.appendChild(ln);
}
if (o.aim) {
var g = mk('g', o.pinned ? 'gm-pin' : 'gm-crosshair');
var x = ux(o.aim.lon), y = uy(o.aim.lat);
var arm = ARM_PX * kpx;
var arms = [[x - arm, y, x + arm, y], [x, y - arm, x, y + arm]];
for (var i = 0; i < arms.length; i++) {
var l = mk('line', 'gm-cross-arm');
l.setAttribute('x1', arms[i][0].toFixed(1)); l.setAttribute('y1', arms[i][1].toFixed(1));
l.setAttribute('x2', arms[i][2].toFixed(1)); l.setAttribute('y2', arms[i][3].toFixed(1));
scalable(l, 'stroke-width', 2);
g.appendChild(l);
}
var c1 = mk('circle', 'gm-cross-dot');
c1.setAttribute('cx', x.toFixed(1)); c1.setAttribute('cy', y.toFixed(1));
scalable(c1, 'r', DOT_PX);
scalable(c1, 'stroke-width', 2);
g.appendChild(c1);
layerPlayer.appendChild(g);
}
if (o.real) {
var gr = mk('g', 'gm-real');
var rx = ux(o.real.lon), ry = uy(o.real.lat);
var halo = mk('circle', 'gm-real-halo');
halo.setAttribute('cx', rx.toFixed(1)); halo.setAttribute('cy', ry.toFixed(1));
scalable(halo, 'r', 9);
scalable(halo, 'stroke-width', 2);
gr.appendChild(halo);
var core = mk('circle', 'gm-real-dot');
core.setAttribute('cx', rx.toFixed(1)); core.setAttribute('cy', ry.toFixed(1));
scalable(core, 'r', 3.5);
gr.appendChild(core);
layerPlayer.appendChild(gr);
}
rescale();
}
var run = null; // {mode, dayKey, roundIdx, placeIdx[], guesses[], budgetLeft, startedAt, busted}
var aim = null; // {lat, lon} або null
var lastAnchorKey = null; // якір, від якого рахували минулого разу (M4)
var phase = 'aim'; // aim | reveal
var sessionPlays = 0;
var firstMoveSent = false;
var lastResult = null;
var POOL = {};
MODES.forEach(function (m) {
if (m.tier < 0) return;
var a = [];
for (var i = 0; i < PLACES.length; i++) if (PLACES[i][4] === m.tier) a.push(i);
POOL[m.id] = a;
});
var POOL_ALL = [];
for (var pidx = 0; pidx < PLACES.length; pidx++) POOL_ALL.push(pidx);
function seenWindow(poolLen) {
return Math.max(0, Math.min(SEEN_MAX, poolLen - SEEN_RESERVE));
}
var CELL = [];
for (var ci = 0; ci < PLACES.length; ci++) {
var cbest = 0, cbk = Infinity;
for (var cj = 0; cj < ANCHORS.length; cj++) {
var ck = distKm({ lat: PLACES[ci][2], lon: PLACES[ci][3] },
{ lat: ANCHORS[cj][2], lon: ANCHORS[cj][3] });
if (ck < cbk) { cbk = ck; cbest = cj; }
}
CELL.push(cbest);
}
function spreadOk(pick) {
var cells = {}, distinct = 0, worst = 0, i, c;
for (i = 0; i < pick.length; i++) {
c = CELL[pick[i]];
if (!cells[c]) { cells[c] = 0; distinct++; }
cells[c]++;
if (cells[c] > worst) worst = cells[c];
}
return distinct >= 4 && worst <= 2;
}
function pickTraining(m) {
var pool = POOL[m.id] || [];
var win = seenWindow(pool.length);
var seen = (st.seen[m.id] || []).slice();
if (seen.length > win) seen = seen.slice(seen.length - win);
var avail = pool.filter(function (i) { return seen.indexOf(i) < 0; });
while (avail.length < ROUNDS && seen.length) {
seen.shift();
avail = pool.filter(function (i) { return seen.indexOf(i) < 0; });
}
var rnd = mulberry32(seedOf(String(Date.now()) + ':' + Math.random()));
var pick = shuffle(avail, rnd).slice(0, ROUNDS);
for (var t = 0; t < SPREAD_TRIES && !spreadOk(pick); t++) {
pick = shuffle(avail, rnd).slice(0, ROUNDS);
}
var next = seen.concat(pick);
if (win <= 0) next = [];
else if (next.length > win) next = next.slice(next.length - win);
st.seen[m.id] = next;
return pick;
}
function pickDaily(dayKey) {
var di = dayIndex(dayKey);
var byFame = { 1: [], 2: [], 3: [] };
for (var i = 0; i < PLACES.length; i++) {
var f = PLACES[i][5];
if (byFame[f]) byFame[f].push(i);
}
var taken = { 1: [], 2: [], 3: [] };
[1, 2, 3].forEach(function (f) {
var q = DAILY_QUOTA[f];
var bucket = byFame[f];
if (!bucket.length || !q) return;
var perm = shuffle(bucket, mulberry32(seedOf(POOL_VERSION + ':fame' + f)));
var start = ((di * q) % perm.length + perm.length) % perm.length;
for (var k = 0; k < q; k++) taken[f].push(perm[(start + k) % perm.length]);
});
var out = [];
var cursor = { 1: 0, 2: 0, 3: 0 };
DAILY_ORDER.forEach(function (f) {
var arr = taken[f];
if (arr && cursor[f] < arr.length && out.indexOf(arr[cursor[f]]) < 0) out.push(arr[cursor[f]]);
cursor[f]++;
});
if (out.length < ROUNDS) {
var fill = shuffle(POOL_ALL, mulberry32(seedOf(POOL_VERSION + ':fill' + di)));
for (var j = 0; j < fill.length && out.length < ROUNDS; j++) {
if (out.indexOf(fill[j]) < 0) out.push(fill[j]);
}
}
return out.slice(0, ROUNDS);
}
function modeAvailable(m) {
if (m.daily) return PLACES.length >= ROUNDS;
return (POOL[m.id] || []).length >= ROUNDS;
}
function startRun(id) {
var m = mode(id);
if (!modeAvailable(m)) return;
var dayKey = kyivDayKey();
if (m.daily) {
mergeFrom(readStored());
sanitize();
if (claimBusy(dayKey)) { dailyBusy(dayKey); return; }
if (st.run && mode(st.run.mode).daily && st.run.dayKey === dayKey) { resumeRun(); return; }
if (st.lastDailyKey === dayKey) { showDailyResult(); return; }
if (!claimTake(dayKey)) { dailyBusy(dayKey); return; }
}
run = {
mode: m.id,
dayKey: dayKey,
roundIdx: 0,
placeIdx: m.daily ? pickDaily(dayKey) : pickTraining(m),
guesses: [],
budgetLeft: m.budget,
startedAt: Date.now(),
busted: false
};
aim = null;
setPhase('aim');
firstMoveSent = false;
st.plays++;
st.run = run;
if (m.daily) {
if (st.lastDailyKey !== dayKey) st.prevDailyKey = st.lastDailyKey || null;
st.lastDailyKey = dayKey;
st.lastDailyResult = {
rounds: [], total: 0, busted: true, dayKey: dayKey, rank: null, worst: null
};
}
saveState();
if (sessionPlays > 0) track('replay', { mode: m.id });
sessionPlays++;
show('game');
buildMap();
renderRound();
announce('Партія почалась. ' + m.label + ', раунд 1 з ' + ROUNDS + '. Де це? ' + currentPlace().name);
}
function resumeRun() {
if (!st.run) return;
run = st.run;
var m = mode(run.mode);
if (m.daily && run.dayKey !== kyivDayKey()) { renderWelcome(); show('welcome'); return; }
if (m.daily && !claimTake(run.dayKey)) { run = null; dailyBusy(kyivDayKey()); return; }
var bad = !run.placeIdx || run.placeIdx.length !== ROUNDS || !run.guesses;
for (var i = 0; !bad && i < run.placeIdx.length; i++) {
if (!(run.placeIdx[i] >= 0 && run.placeIdx[i] < PLACES.length)) bad = true;
}
if (bad) { dropRun(); renderWelcome(); return; }
aim = null;
setPhase('aim');
firstMoveSent = true;
sessionPlays++;
show('game');
buildMap();
renderRound();
announce('Партію відновлено, раунд ' + (run.roundIdx + 1) + ' з ' + ROUNDS + '.');
}
function dropRun() {
if (run && mode(run.mode).daily) claimDrop(true);
run = null;
st.run = null;
saveState();
}
function busyNote(dayKey) {
return 'Набір №' + dailyNumber(dayKey) +
' вже грають в іншій вкладці — удвох той самий набір не роздамо';
}
function dailyBusy(dayKey) {
renderWelcome();
if (el.dailyNote) el.dailyNote.textContent = busyNote(dayKey);
show('welcome');
toast('Щоденну вже почали в іншій вкладці');
announce('Сьогоднішній набір уже грають в іншій вкладці. Продовж партію там або тренуйся тут.');
}
function checkClaim() {
if (!run || !mode(run.mode).daily || claimMine(run.dayKey)) return;
var day = run.dayKey;
run = null;
st.run = null;
claimDrop(false);
saveState();
dailyBusy(day);
}
function abandonRun(r) {
if (r && mode(r.mode).daily) {
run = r;
run.busted = true;
finishStats(true);
}
dropRun();
}
function currentPlace() { return place(run.placeIdx[run.roundIdx]); }
function renderRound() {
var m = mode(run.mode);
var p = currentPlace();
guardInput();
resetScroll(); // M-A: раунд починається з питання, а не з мапи
lastAnchorKey = null;
el.hudRound.textContent = 'Раунд ' + (run.roundIdx + 1) + ' з ' + ROUNDS;
el.hudBudget.textContent = 'Запас: ' + fmt(Math.max(0, run.budgetLeft)) + ' км';
el.hudMode.textContent = m.daily ? m.label + ' №' + dailyNumber(run.dayKey) : m.label;
el.ask.textContent = p.name;
el.reveal.hidden = true;
el.submit.textContent = 'Ставлю шпильку';
el.submit.disabled = true;
el.submit.setAttribute('data-act', 'pin');
if (el.aimline) el.aimline.textContent = nudgeHint();
drawPlayer({});
renderLegend();
setNudges(false);
focusBoard();
focusGuard();
}
var coarsePointer = false;
try {
coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
} catch (e) { /* нема matchMedia — вважаємо, що клавіатура є */ }
function legendCaption() {
return 'Якорі мапи — тисни кнопку або цифру на клавіатурі:';
}
function nudgeHint() {
return coarsePointer
? 'Тицьни в мапу — далі підкрутиш стрілками під нею'
: 'Тицьни в мапу, натисни цифру якоря або веди хрестик стрілками';
}
function renderLegend() {
if (!el.legend || el.legend.childNodes.length) return;
var cap = $('.gm-legend__cap');
if (cap) cap.textContent = legendCaption();
anchorPts.forEach(function (a) {
var li = document.createElement('li');
var b = document.createElement('button');
b.type = 'button';
b.className = 'gm-legend__btn';
b.setAttribute('data-anchor', a.key);
var k = document.createElement('span');
k.className = 'gm-legend__key';
k.textContent = a.key;
var n = document.createElement('span');
n.textContent = a.name;
b.appendChild(k);
b.appendChild(n);
b.addEventListener('click', function () {
setAim({ lat: a.lat, lon: a.lon });
try { el.board.focus({ preventScroll: true }); } catch (e) { /* дарма */ }
});
li.appendChild(b);
el.legend.appendChild(li);
});
}
function setAim(pt, speak) {
if (phase !== 'aim' || !run) return;
aim = {
lat: clamp(pt.lat, B.latMin, B.latMax),
lon: clamp(pt.lon, B.lonMin, B.lonMax)
};
el.submit.disabled = false;
setNudges(true);
drawPlayer({ aim: aim });
if (!firstMoveSent) {
firstMoveSent = true;
track('start', { mode: run.mode, day_key: run.dayKey });
}
var say = aimSpeech();
if (el.aimline) el.aimline.textContent = say;
if (speak !== false) announce(say);
}
function wordsOf(s) {
var out = [], cur = '', i, ch;
for (i = 0; i < s.length; i++) {
ch = s.charAt(i);
if (ch === ' ' || ch === '-' || ch === ',' || ch === '.' ||
ch === '(' || ch === ')' || ch === '«' || ch === '»') {
if (cur) { out.push(cur); cur = ''; }
} else { cur += ch; }
}
if (cur) out.push(cur);
return out;
}
function echoesDistrict(name, district) {
var w = wordsOf(name), d = district.toLowerCase(), i, n;
for (i = 0; i < w.length; i++) {
n = Math.min(5, w[i].length, d.length);
if (n >= 4 && w[i].toLowerCase().slice(0, n) === d.slice(0, n)) return true;
}
return false;
}
function anchorSentence(pt, lead) {
var near = anchorsByDist(pt);
if (!near[0]) return null;
return lead + ' ' + distWord(near[0].km) + ' на ' + dirWord(near[0].a, pt) +
' від «' + near[0].a.name + '».';
}
function anchorsByDist(pt) {
return anchorPts.map(function (a) {
return { a: a, km: distKm(pt, a) };
}).sort(function (x, y) { return x.km - y.km; });
}
function vectorHint(p, real, raw, scored) {
var out = [];
if (raw > 0.3) {
out.push(raw - scored > 0.05
? 'Зараховано ' + fmt(scored) + ' км, а від шпильки до місця — ' +
distWord(raw) + ' на ' + dirWord(aim, real) + '.'
: 'Правильне місце — ' + distWord(raw) + ' на ' + dirWord(aim, real) + ' від твоєї шпильки.');
}
if (scored > 3) {
var near = anchorsByDist(real);
if (near[0]) {
out.push('Шукати треба ' + distWord(near[0].km) + ' на ' +
dirWord(near[0].a, real) + ' від «' + near[0].a.name + '».');
}
if (scored > 7 && near[1]) {
out.push('Це ще й ' + distWord(near[1].km) + ' на ' +
dirWord(near[1].a, real) + ' від «' + near[1].a.name + '».');
}
}
return out;
}
function revealMetaText(p, real, raw, scored) {
var meta = [];
if (p.official) meta.push('Офіційно — ' + p.official + '.');
if (!echoesDistrict(p.name, p.district)) meta.push(p.district + ' район.');
var out = meta.concat(vectorHint(p, real, raw, scored));
if (!out.length) {
var s = anchorSentence(real, 'Це');
if (s) out.push(s);
}
return out;
}
function nearestAnchor(pt) {
var best = null, bestKm = Infinity;
for (var i = 0; i < anchorPts.length; i++) {
var d = distKm(pt, anchorPts[i]);
if (d < bestKm) { bestKm = d; best = anchorPts[i]; }
}
return { a: best, km: bestKm };
}
function dirWord(from, to) {
var dx = (to.lon - from.lon) * M_PER_DEG_LON;
var dy = (to.lat - from.lat) * M_PER_DEG_LAT;
var ang = Math.atan2(dx, dy) * 180 / Math.PI;
var i = Math.round((((ang % 360) + 360) % 360) / 45) % 8;
return DIRS[i];
}
function distWord(km) {
if (km < 1) return Math.round(km * 1000) + ' м';
return fmt(km) + ' км';
}
function aimSpeech() {
if (!aim) return '';
var n = nearestAnchor(aim);
if (!n.a) return 'Хрестик поставлено';
var changed = lastAnchorKey !== null && lastAnchorKey !== n.a.key;
lastAnchorKey = n.a.key;
if (n.km < 0.03) return 'Хрестик: на місці «' + n.a.name + '»';
var tail = distWord(n.km) + ' на ' + dirWord(n.a, aim);
return changed
? 'Відлік тепер від «' + n.a.name + '»: ' + tail
: 'Хрестик: ' + tail + ' від «' + n.a.name + '»';
}
function boardPoint(ev) {
var v = viewScale();
if (!v) return null;
var x = (ev.clientX - v.ox) / v.s;
var y = (ev.clientY - v.oy) / v.s;
return { lat: latOf(clamp(y, 0, VB_H)), lon: lonOf(clamp(x, 0, VB_W)) };
}
function onBoardPointer(ev) {
if (phase !== 'aim' || !svg) return;
if (isEchoTap(ev)) { ev.preventDefault(); return; }
var pt = boardPoint(ev);
if (!pt) return;
ev.preventDefault();
lastBtnBox = null; // влучив у мапу — гард більше не потрібен
try { el.board.focus({ preventScroll: true }); } catch (e) { /* дарма */ }
setAim(pt);
}
function moveAim(dxm, dym) {
if (phase !== 'aim' || !run) return;
var base = aim || { lat: (B.latMin + B.latMax) / 2, lon: (B.lonMin + B.lonMax) / 2 };
var x = clamp(ux(base.lon) + dxm / M_PER_UNIT_X, 0, VB_W);
var y = clamp(uy(base.lat) + dym / M_PER_UNIT_Y, 0, VB_H);
setAim({ lat: latOf(y), lon: lonOf(x) });
}
function setNudges(on) {
$$('[data-nudge-dir]').forEach(function (b) { b.disabled = !on; });
}
function onNudge(dir) {
if (!aim) return; // нюдж зсуває хрестик, а не створює його
if (dir === 'w') moveAim(-STEP_M, 0);
else if (dir === 'e') moveAim(STEP_M, 0);
else if (dir === 'n') moveAim(0, -STEP_M);
else if (dir === 's') moveAim(0, STEP_M);
}
function onBoardKey(ev) {
if (ev.altKey || ev.ctrlKey || ev.metaKey) return;
if (phase !== 'aim') {
if (ev.key === 'Enter') { ev.preventDefault(); onSubmit(); }
return;
}
var k = ev.key;
if (k === 'Enter') {
if (aim) { ev.preventDefault(); onSubmit(); }
return;
}
for (var i = 0; i < anchorPts.length; i++) {
if (anchorPts[i].key === k) {
ev.preventDefault();
setAim({ lat: anchorPts[i].lat, lon: anchorPts[i].lon });
return;
}
}
var dxm = 0, dym = 0;
var stepM = ev.shiftKey ? STEP_BIG_M : STEP_M;
if (k === 'ArrowLeft') dxm = -stepM;
else if (k === 'ArrowRight') dxm = stepM;
else if (k === 'ArrowUp') dym = -stepM;
else if (k === 'ArrowDown') dym = stepM;
else return;
ev.preventDefault();
moveAim(dxm, dym);
}
function onDocKey(ev) {
if (ev.defaultPrevented || ev.altKey || ev.ctrlKey || ev.metaKey) return;
if (phase !== 'aim' || !run || !svg) return;
var screen = $('[data-screen="game"]');
if (!screen || screen.hidden) return;
if (el.board.contains(ev.target)) return; // мапа вже обробила сама
var t = ev.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
for (var i = 0; i < anchorPts.length; i++) {
if (anchorPts[i].key === ev.key) {
ev.preventDefault();
setAim({ lat: anchorPts[i].lat, lon: anchorPts[i].lon });
focusBoard();
return;
}
}
if (!t || !t.closest || !t.closest('[data-nudge-dir]')) return;
var s = ev.shiftKey ? STEP_BIG_M : STEP_M;
if (ev.key === 'ArrowLeft') moveAim(-s, 0);
else if (ev.key === 'ArrowRight') moveAim(s, 0);
else if (ev.key === 'ArrowUp') moveAim(0, -s);
else if (ev.key === 'ArrowDown') moveAim(0, s);
else return;
ev.preventDefault();
}
function onSubmit() {
var act = el.submit.getAttribute('data-act');
if (act === 'pin') { confirmPin(); return; }
if (act === 'next') { nextRound(); return; }
if (act === 'finish') { toResult(); return; }
}
function confirmPin() {
if (phase !== 'aim' || !aim || !run) return;
setPhase('reveal'); // захист від подвійного кліку
var wasFocused = document.activeElement === el.submit;
el.submit.disabled = true;
setNudges(false);
var p = currentPlace();
var real = { lat: p.lat, lon: p.lon };
var raw = distKm(aim, real);
var scored = Math.max(0, raw - p.tol);
var bi = bandOf(scored);
var last = run.roundIdx >= ROUNDS - 1;
run.guesses.push({ lat: aim.lat, lon: aim.lon, km: scored, b: bi });
run.budgetLeft = run.budgetLeft - scored;
if (run.budgetLeft < -1e-9) run.busted = true;
track('round_done', {
mode: run.mode, round: run.roundIdx + 1, km: Math.round(scored * 10) / 10,
band: BANDS[bi].id, tier: TIER_ID[p.tier], fame: p.fame
});
if (run.busted) {
track('budget_out', {
mode: run.mode, round: run.roundIdx + 1,
total_km: Math.round(totalKm() * 10) / 10
});
}
drawPlayer({ aim: aim, real: real, line: true, pinned: true });
el.hudBudget.textContent = 'Запас: ' + fmt(Math.max(0, run.budgetLeft)) + ' км';
var inArea = p.tier === 2 && scored === 0;
el.revealSq.textContent = sq(bi);
el.revealSq.className = 'gm-sq gm-sq--' + BANDS[bi].id;
el.revealKm.textContent = inArea
? '0 км — ти в межах ареалу'
: fmt(scored) + ' км · ' + BANDS[bi].label;
var vector = vectorHint(p, real, raw, scored);
el.revealMeta.textContent = revealMetaText(p, real, raw, scored).join(' ');
el.revealTol.hidden = p.tier !== 2 && raw - scored <= 0.05;
el.revealTol.textContent = p.tier === 2 ? TOL_NOTE : SIZE_NOTE;
el.reveal.hidden = false;
el.reveal.setAttribute('data-band', BANDS[bi].id);
el.submit.textContent = (run.busted || last) ? 'Підсумок' : 'Далі';
el.submit.setAttribute('data-act', (run.busted || last) ? 'finish' : 'next');
el.submit.disabled = false;
announce('Раунд ' + (run.roundIdx + 1) + ': ' +
(inArea ? '0 км, у межах ареалу' : fmt(scored) + ' км, ' + BANDS[bi].label.toLowerCase()) +
'. ' + vector.join(' ') +
' Запас ' + fmt(Math.max(0, run.budgetLeft)) + ' км.' +
(run.busted ? ' Запас вичерпано, партія обірвалась.' : ''));
if (wasFocused) { try { el.submit.focus({ preventScroll: true }); } catch (e) { /* дарма */ } }
keepActionVisible();
st.run = run;
saveState();
}
function nextRound() {
if (!run) return;
run.roundIdx++;
aim = null;
setPhase('aim');
st.run = run;
saveState();
renderRound();
announce('Раунд ' + (run.roundIdx + 1) + ' з ' + ROUNDS + '. Де це? ' + currentPlace().name);
}
function totalKm() {
var s = 0;
for (var i = 0; i < run.guesses.length; i++) s += run.guesses[i].km;
return s;
}
function worstDistrict() {
var worst = null, worstKm = -1;
for (var i = 0; i < run.guesses.length; i++) {
if (run.guesses[i].km > worstKm) {
worstKm = run.guesses[i].km;
worst = place(run.placeIdx[i]).district;
}
}
return worstKm >= 0.05 ? worst : null;
}
function onQuit() {
if (!run) { show('welcome'); return; }
var m = mode(run.mode);
var yes = true;
try {
yes = window.confirm(m.daily
? 'Кинути партію? Щоденну сьогодні не перезапустиш.'
: 'Кинути партію?');
} catch (e) { yes = true; }
if (!yes) return;
abandonRun(run);
renderWelcome();
show('welcome');
announce('Партію кинуто.');
}
function finishStats(quiet) {
var m = mode(run.mode);
var total = totalKm();
var basis = rankBasis(run.guesses);
var busted = !!run.busted;
if (!busted) st.finished++;
if (m.daily) {
var prev = st.prevDailyKey;
if (busted) st.streak = 0;
else st.streak = (prev && prev === shiftDay(run.dayKey, -1)) ? st.streak + 1 : 1;
if (st.streak > st.maxStreak) st.maxStreak = st.streak;
st.lastDailyKey = run.dayKey;
st.lastDailyResult = {
rounds: run.guesses.map(function (g) { return { km: g.km, b: g.b }; }),
total: total, busted: busted, dayKey: run.dayKey,
rank: busted ? null : rankOf(basis).label,
worst: worstDistrict()
};
}
if (!busted) {
var b = st.best[m.id];
if (typeof b !== 'number' || total < b) st.best[m.id] = total;
}
if (!quiet) {
track('finish', {
mode: m.id, total_km: Math.round(total * 10) / 10, rounds: run.guesses.length,
rank: busted ? '' : rankOf(basis).label,
seconds: Math.round((Date.now() - run.startedAt) / 1000), busted: busted
});
}
saveState();
}
function toResult() {
if (!run) return;
var m = mode(run.mode);
var total = totalKm();
var basis = rankBasis(run.guesses);
var busted = !!run.busted;
lastResult = {
mode: m.id, modeLabel: m.label, daily: m.daily, dayKey: run.dayKey,
rounds: run.guesses.map(function (g) { return { km: g.km, b: g.b }; }),
total: total, busted: busted, roundsPlayed: run.guesses.length,
rank: busted ? null : rankOf(basis).label,
rankLine: busted ? null : rankLine(rankOf(basis), run.guesses),
worst: worstDistrict(), view: false
};
finishStats(false);
dropRun();
renderResult(lastResult);
show('result');
}
function showDailyResult() {
var r = st.lastDailyResult;
if (!r) return;
var m = mode('daily');
lastResult = {
mode: 'daily', modeLabel: m.label, daily: true, dayKey: r.dayKey,
rounds: r.rounds || [], total: r.total, busted: !!r.busted,
roundsPlayed: (r.rounds || []).length,
rank: r.rank,
rankLine: r.rank ? rankLine(rankOf(rankBasis(r.rounds || [])), r.rounds || []) : null,
worst: r.worst || null, view: true
};
renderResult(lastResult);
show('result');
announce('Сьогоднішній набір ти вже пройшов. Новий — після півночі за київським часом.');
}
function renderResult(r) {
el.rTitle.textContent = r.busted
? 'Шпильки скінчились на ' + ordinal(r.roundsPlayed)
: (r.rank || '');
el.rSub.textContent = r.busted
? 'Запас ' + fmt(mode(r.mode).budget) + ' км — усе. Київ великий, а вулиці однакові тільки на табличках.'
: (r.rankLine || '');
el.rStrip.textContent = '';
r.rounds.forEach(function (g) {
var li = document.createElement('li');
var s = document.createElement('span');
s.className = 'gm-sq gm-sq--' + BANDS[g.b].id;
s.textContent = sq(g.b);
var t = document.createElement('span');
t.textContent = fmt(g.km) + ' км · ' + BANDS[g.b].label;
li.appendChild(s);
li.appendChild(t);
el.rStrip.appendChild(li);
});
var runLabel = r.daily ? 'набір №' + dailyNumber(r.dayKey) : 'тренування';
el.rTotal.textContent = 'Разом: ' + fmt(r.total) + ' км · ' + r.modeLabel + ' · ' + runLabel;
if (r.worst) {
el.rWorst.hidden = false;
el.rWorst.textContent = 'Найбільший промах — ' + r.worst + ' район';
} else {
el.rWorst.hidden = true;
}
var link = null;
for (var i = 0; i < LINKS.length; i++) if (LINKS[i][0] === r.worst) link = LINKS[i];
if (link) {
el.crosslink.textContent = link[1];
el.crosslink.href = link[2];
el.crosslink.setAttribute('data-district', link[0]);
} else {
el.crosslink.textContent = 'Куди піти в Києві';
el.crosslink.href = 'https://my-kiev.com/catalog/';
el.crosslink.setAttribute('data-district', '');
}
el.replay.textContent = r.daily ? 'Тренуватись' : 'Ще раз';
el.shareFb.hidden = true;
var bits = [];
if (!r.busted && r.rounds.length > 1) {
bits.push('Звання — за чотирма найкращими раундами, найгірший не рахуємо');
}
if (typeof st.best[r.mode] === 'number') bits.push('Найкраща сума в цьому режимі: ' + fmt(st.best[r.mode]) + ' км');
bits.push('Стрік щоденного: ' + st.streak + (st.maxStreak ? ' (максимум ' + st.maxStreak + ')' : ''));
el.rStats.textContent = bits.join(' · ');
announce(r.busted
? 'Партія обірвалась на ' + ordinal(r.roundsPlayed) + ' раунді. Разом ' + fmt(r.total) + ' км.'
: 'Готово. Разом ' + fmt(r.total) + ' км, звання «' + r.rank + '».');
}
function shareText(r) {
var url = location.origin + location.pathname;
var runLabel = r.daily ? '№' + dailyNumber(r.dayKey) : 'тренування';
var lines = ['Шпилька · Київ · ' + runLabel + ' — вгадай місце на мапі'];
r.rounds.forEach(function (g) { lines.push(sq(g.b) + ' ' + fmt(g.km) + ' км'); });
if (r.busted) lines.push('Запас скінчився на ' + r.roundsPlayed + '-му раунді');
lines.push('Разом: ' + fmt(r.total) + ' км · ' + r.modeLabel);
if (!r.busted) lines.push('Звання: «' + r.rank + '»');
lines.push(url);
return lines.join(NL);
}
function fallbackShare(text) {
el.shareFb.hidden = false;
el.shareFb.value = text;
try { el.shareFb.focus(); el.shareFb.select(); } catch (e) { /* дарма */ }
toast('Скопіюй вручну');
}
function doShare() {
if (!lastResult) return;
var text = shareText(lastResult);
if (navigator.share) {
track('share', { mode: lastResult.mode, channel: 'web-share' });
try {
navigator.share({ text: text }).catch(function () { fallbackShare(text); });
} catch (e) { fallbackShare(text); }
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
track('share', { mode: lastResult.mode, channel: 'clipboard' });
navigator.clipboard.writeText(text).then(function () {
toast('Скопійовано');
announce('Результат скопійовано');
}, function () { fallbackShare(text); });
return;
}
track('share', { mode: lastResult.mode, channel: 'textarea' });
fallbackShare(text);
}
function renderWelcome() {
var dayKey = kyivDayKey();
wireLede();
$$('[data-mode]').forEach(function (b) {
var m = mode(b.getAttribute('data-mode'));
var can = modeAvailable(m);
b.disabled = !can;
var hint = b.querySelector('[data-mode-hint]');
if (!can && hint) hint.textContent = 'Замало звірених точок';
});
var expired = null;
if (st.run && mode(st.run.mode).daily && st.run.dayKey !== dayKey) {
expired = st.run;
abandonRun(st.run);
}
if (el.dailyNote) {
var inPlay = st.run && mode(st.run.mode).daily && st.run.dayKey === dayKey;
if (claimBusy(dayKey)) {
el.dailyNote.textContent = busyNote(dayKey);
} else if (inPlay) {
el.dailyNote.textContent = 'Набір №' + dailyNumber(dayKey) +
' · партія почата, продовж її вище';
} else if (st.lastDailyKey === dayKey && st.lastDailyResult) {
var r = st.lastDailyResult;
el.dailyNote.textContent = 'Сьогодні: ' + fmt(r.total) + ' км' +
(r.rank ? ', «' + r.rank + '»' : ', партія обірвалась') +
' · новий набір після півночі за Києвом';
} else {
el.dailyNote.textContent = 'Набір №' + dailyNumber(dayKey) + ' · однаковий для всіх';
}
}
if (expired && el.expired) {
el.expired.hidden = false;
el.expired.textContent = 'Щоденна за ' + expired.dayKey + ' лишилась недограною на ' +
(expired.roundIdx + 1) + '-му раунді: опівночі за Києвом набір змінюється, ' +
'і той уже не повернути. Сьогоднішній — нижче.';
} else if (el.expired) {
el.expired.hidden = true;
}
var hasRun = !!st.run;
if (hasRun) {
el.resume.hidden = false;
el.resumeTxt.textContent = 'Незакінчена партія: раунд ' + (st.run.roundIdx + 1) +
' з ' + ROUNDS + ', запас ' + fmt(Math.max(0, st.run.budgetLeft)) + ' км';
} else {
el.resume.hidden = true;
}
if (el.storeNote) el.storeNote.hidden = storageOk;
el.stats.textContent = '';
if (!st.plays) {
var p = document.createElement('p');
p.className = 'gm-stats__empty';
p.textContent = 'Ще жодної партії. Перша — легка, обіцяємо.';
el.stats.appendChild(p);
return;
}
var row = document.createElement('dl');
row.className = 'gm-stats__row';
[['Зіграно', st.plays], ['Дійшло до фіналу', st.finished], ['Стрік', st.streak]]
.forEach(function (pair) {
var d = document.createElement('div');
var dt = document.createElement('dt'); dt.textContent = pair[0];
var dd = document.createElement('dd'); dd.textContent = pair[1];
d.appendChild(dt); d.appendChild(dd);
row.appendChild(d);
});
el.stats.appendChild(row);
var bestBits = [];
MODES.forEach(function (m) {
if (typeof st.best[m.id] === 'number') bestBits.push(m.label + ' — ' + fmt(st.best[m.id]) + ' км');
});
if (bestBits.length) {
var bp = document.createElement('p');
bp.className = 'gm-stats__best';
bp.textContent = 'Найкраща сума: ' + bestBits.join(' · ');
el.stats.appendChild(bp);
}
}
function fail(e) {
if (el.err) {
el.err.hidden = false;
el.err.textContent = 'Не змогли зібрати мапу. Онови сторінку — решта сайту на місці.';
}
$$('[data-mode]').forEach(function (b) { b.disabled = true; });
if (window.console && e) window.console.error('shpylka:', e);
}
function init() {
loadState();
if (!PLACES.length || !GEO.city.length) { fail(null); return; }
$$('[data-mode]').forEach(function (b) {
b.addEventListener('click', function () {
var id = b.getAttribute('data-mode');
track('mode_pick', { mode: id });
startRun(id);
});
});
$('[data-resume-go]').addEventListener('click', function () { resumeRun(); });
$('[data-resume-drop]').addEventListener('click', function () {
var daily = st.run && mode(st.run.mode).daily;
abandonRun(st.run);
renderWelcome();
focusTitle('welcome');
announce(daily
? 'Щоденну партію кинуто. Сьогодні вона вже не перезапуститься.'
: 'Незакінчену партію кинуто.');
});
root.addEventListener('pointerdown', function (ev) {
var t = ev.target;
var b = t && t.closest ? t.closest('button') : null;
if (b) noteBtn(b);
}, true);
root.addEventListener('pointermove', function (ev) {
if (lastBtnBox && !inBtnBox(ev)) lastBtnBox = null;
}, true);
if (el.board.getAttribute('tabindex') === null) el.board.setAttribute('tabindex', '0');
el.board.addEventListener('pointerdown', onBoardPointer);
el.board.addEventListener('keydown', onBoardKey);
$$('[data-nudge-dir]').forEach(function (b) {
b.addEventListener('click', function () { onNudge(b.getAttribute('data-nudge-dir')); });
});
el.submit.addEventListener('click', onSubmit);
el.quit.addEventListener('click', onQuit);
el.share.addEventListener('click', doShare);
el.replay.addEventListener('click', function () {
if (!lastResult || lastResult.daily) { renderWelcome(); show('welcome'); return; }
startRun(lastResult.mode);
});
el.crosslink.addEventListener('click', function () {
track('crosslink_click', { district: el.crosslink.getAttribute('data-district') || '' });
});
document.addEventListener('keydown', onDocKey);
window.addEventListener('storage', function (ev) {
if (ev && ev.key === CLAIM_KEY) { checkClaim(); return; }
if (ev && ev.key && ev.key !== CFG.storeKey) return;
var o = readStored();
if (!o) return;
mergeFrom(o);
if (!run) {
st.run = o.run || null;
renderWelcome();
}
});
window.addEventListener('resize', onViewportChange);
window.addEventListener('orientationchange', function () { setTimeout(onViewportChange, 120); });
window.addEventListener('beforeunload', function (ev) {
if (!storageOk && run && !run.busted && run.guesses.length < ROUNDS) {
ev.preventDefault();
ev.returnValue = '';
return '';
}
});
renderWelcome();
show('welcome');
if ('IntersectionObserver' in window) {
var io = new IntersectionObserver(function (entries) {
for (var i = 0; i < entries.length; i++) {
if (entries[i].isIntersecting) { track('view'); io.disconnect(); return; }
}
}, { threshold: 0.4 });
io.observe(root);
} else {
track('view');
}
}
window.mkvShpylka = {
events: events,
poolVersion: POOL_VERSION,
viewBox: [VB_W, VB_H],
metersPerUnit: [M_PER_UNIT_X, M_PER_UNIT_Y]
};
try {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
} catch (e) { fail(e); }
})();