{"context":{"domain":"natura-geomarketing"},"layers":{"ApplicationLayer":{"reference":"ApplicationLayer","availableInstances":[{"reference":"ComponentsDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"component":"data2-core@DataSet","reference":"SystemsDataSet","params":{"filter":{"domain":"data2"},"pagination":{"size":1},"datasource":{"component":"data2-core-types@ProcessReference","params":{"reference":"SystemsDataSource"}}}},{"reference":"ExternalComponentInstancesDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"SystemsDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"5e3b796d6228227ca637d0f4","name":"data2","domain":"data2","organization":"62292184611f480b3191c37c","visibility":"public","updatedAt":"2026-07-27T14:19:36.810Z"}]}},{"reference":"OrganizationsDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"DataComponentInstancesDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"component":"data2-core@DataSet","reference":"OneFileComponentInstancesDataSet","params":{"pagination":{"size":1},"master":{"dataset":{"component":"data2-core-types@ProcessReference","params":{"reference":"SystemsDataSet"}},"localField":"system","masterField":"_id"},"datasource":{"component":"data2-core-types@ProcessReference","params":{"reference":"FileComponentInstancesDataSource"}}}},{"reference":"ApplicationsDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"ComponentInstancesDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"reference":"ServiceComponentInstancesDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"component":"data2-core@Data2TableDataSource","reference":"FileComponentInstancesDataSource","params":{"table":"fileComponentInstances","schema":[{"component":"data2-core-types@Field","params":{"key":"component","title":"Component","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"system","title":"System","component":"data2-core-primitives@ObjectId"}},{"component":"data2-core-types@Field","params":{"key":"displayName","title":"Display Name","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"params","title":"Params","component":"data2-core-primitives@Object"}},{"component":"data2-core-types@Field","params":{"key":"reference","title":"Reference","component":"data2-core-primitives@String"}}]}},{"component":"data2-core@MemoryDataSource","reference":"EmptyDataSource","params":{"initialData":[]}},{"reference":"ComponentsDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"UserComponentsDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"DataComponentInstancesDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"ServiceComponentInstancesDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"ComponentInstancesDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"ExternalComponentInstancesDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}},{"reference":"ApplicationsDataSet","component":"data2-core@DataSet","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"EmptyDataSource"}}}}],"processesParams":{}},"HostApplicationLayer":{"reference":"HostApplicationLayer","availableInstances":[{"_id":"5f15ff190528b92601890f71","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"SystemsTableDataSource"}},"filter":{"domain":{"component":"data2-core-types@Reference","params":{"reference":"domain"}}},"pk":"_id","fields":[{"component":"data2-core-types@FieldReference","params":{"key":"_id"}},{"component":"data2-core-types@FieldReference","params":{"key":"name"}},{"component":"data2-core-types@FieldReference","params":{"key":"domain"}},{"component":"data2-core-types@FieldReference","params":{"key":"visibility"}},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"customDomains"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"organization._id"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"organization.name"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"organization.billingStatus"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"organization.publicTrial.active"}}},"break":true},{"component":"data2-core-types@FieldReference","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"settings.defaultAccessPolicy"}}},"break":true}],"pagination":{"size":{"component":"data2-core-primitives@Number","params":{"content":1}}},"staticPagination":{"size":{"component":"data2-core-primitives@Number","params":{"content":1}}},"policies":[{"component":"data2-core-auth@Policy","params":{"allow":{"params":{"actions":[{"params":{"components":[{"params":{"query":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$params.query"}}}},"field":{"component":"data2-core-primitives@String","params":{"content":"_id"}},"fieldType":"string"},"component":"data2@7KkQq0vK4a7D2gg6IVJT7"},{"params":{"query":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$params.query"}}}},"field":{"component":"data2-core-primitives@String","params":{"content":"domain"}},"fieldType":"string"},"component":"data2@7KkQq0vK4a7D2gg6IVJT7"},{"params":{"query":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$params.query"}}}},"field":{"component":"data2-core-primitives@String","params":{"content":"customDomains"}},"fieldType":"string"},"component":"data2@7KkQq0vK4a7D2gg6IVJT7"}]},"component":"data2-core-logical-operators@Or"}]},"component":"data2-core-flow@Series"}}}]},"reference":"SystemsDataSet","updatedAt":"2026-08-07T21:51:24.037Z","createdAt":"2020-07-20T20:31:21.838Z"},{"_id":"5f1601ce0528b92601890f7c","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"pagination":{"size":1000},"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ComponentsTableDataSource"}},"sort":{"params.package":1,"params.component":1},"pk":"_id","autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"staticFilter":{"system":{"exists":{"params":{"content":false},"component":"data2-core-primitives@Boolean"}}},"filter":{"system":{"exists":{"params":{"content":false},"component":"data2-core-primitives@Boolean"}}}},"reference":"ComponentsDataSet","updatedAt":"2025-11-05T19:37:01.935Z","createdAt":"2020-07-20T20:42:54.898Z"},{"_id":"5f9041dbc8da12be8b94aea4","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"pagination":{"size":2000},"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ComponentsTableDataSource"}},"pk":"_id","autoEdit":true,"master":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"UserComponentsDataSet","updatedAt":"2025-11-06T21:06:58.095Z","createdAt":"2020-07-20T20:44:09.350Z"},{"_id":"65d8c4fabc9dbb8f94de8d53","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ExternalComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"ExternalComponentInstancesDataSet","updatedAt":"2025-11-06T21:07:23.742Z","createdAt":"2024-02-23T16:17:01.816Z"},{"reference":"SystemsTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"6a57cfe9673eb7e3c5e57784","name":"Geomarketing","domain":"natura-geomarketing","visibility":"public","settings":{"defaultAccessPolicy":"private"},"updatedAt":"2026-08-14T20:13:15.077Z","createdAt":"2026-07-15T18:22:33.158Z"}]}},{"reference":"ComponentsTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"5f52a278a575399a45d239b4","component":"data2-core-types@Component","params":{"package":"data2-core-primitives","component":"Router","interactions":{"component":"data2-core-types@FieldSet","params":{"fields":{"goBack":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-types@Function"}},"definition":{"component":"data2-core-functions@Function","params":{"body":{"preventEvaluation":true,"component":"data2-core-types@Getter","params":{"module":{"component":"data2-core-types@This","params":{}},"prop":"history.goBack","args":[]}}}},"icon":{"component":"data2-core-primitives@String","params":{"content":"arrow-left"}},"visibility":"public"}},"goTo":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"definition":{"component":"data2-core-functions@Function","params":{"body":{"preventEvaluation":true,"component":"data2-core-types@Getter","params":{"module":{"component":"data2-core-types@This","params":{}},"prop":"history.push","args":[{"component":"data2-core-types@Reference","params":{"reference":"url"}},{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"preserveQuery"}}}}]}},"parameters":[{"component":"data2-core-primitives@String","params":{"content":"url"}},{"component":"data2-core-primitives@String","params":{"content":"preserveQuery"}}]},"break":true},"icon":{"component":"data2-core-primitives@String","params":{"content":"share"}},"visibility":"public","arguments":[{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Path to route"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Path to redirect user"}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2-core-types@ComponentParameter"},{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Boolean"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Preserve Query"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Preserve query string parameters  preserve query parameters on navigating."}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2-core-types@ComponentParameter"}]}},"open":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-types@Function"}},"definition":{"component":"data2-core-functions@Function","params":{"body":{"preventEvaluation":true,"component":"data2-core-types@Getter","params":{"module":{"component":"data2-core-types@This","params":{}},"prop":"history.push","args":[{"pathname":{"component":"data2-core-types@Reference","params":{"reference":"value"}}}]}},"parameters":["value"]}},"icon":{"component":"data2-core-primitives@String","params":{"content":"share"}},"visibility":"public","arguments":[{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Link to open"}},"description":{"component":"data2-core-primitives@String","params":{"content":""}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2-core-types@ComponentParameter"},{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Target"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Where to open the link"}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"oneOf":[{"params":{"content":"_blank"},"component":"data2-core-primitives@String"},{"params":{"content":"_self"},"component":"data2-core-primitives@String"},{"params":{"content":"_parent"},"component":"data2-core-primitives@String"},{"params":{"content":"_top"},"component":"data2-core-primitives@String"}]},"component":"data2-core-types@ComponentParameter"}]}},"close":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-types@Function"}},"definition":{"component":"data2-core-functions@Function","params":{"body":{"preventEvaluation":true,"component":"data2-core-types@Getter","params":{"module":{"params":{},"component":"data2-core-web@Window"},"prop":"close"}},"parameters":["value"]}},"icon":{"component":"data2-core-primitives@String","params":{"content":"times"}},"visibility":"public","arguments":[{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Link to open"}},"description":{"component":"data2-core-primitives@String","params":{"content":""}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2-core-types@ComponentParameter"},{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Target"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Where to open the link"}},"visibility":"public","required":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"oneOf":[{"params":{"content":"_blank"},"component":"data2-core-primitives@String"},{"params":{"content":"_self"},"component":"data2-core-primitives@String"},{"params":{"content":"_parent"},"component":"data2-core-primitives@String"},{"params":{"content":"_top"},"component":"data2-core-primitives@String"}]},"component":"data2-core-types@ComponentParameter"}],"description":{"component":"data2-core-primitives@String","params":{"content":"Closes a window that was opened via router.open."}}}}}}}}},{"_id":"620d28658271aa4129919d89","component":"data2-core-types@Component","params":{"package":"data2-interface-services","component":"DeviceController","interactions":{"params":{"fields":{"postMessage":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":"data2-core-primitives@String","title":{"component":"data2-core-primitives@String","params":{"content":"data"}}}}]},"component":"data2-core-types@ComponentParameter"},"print":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Object"}},"flat":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"parameters":{"zoom":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Number"}},"title":{"component":"data2-core-primitives@String","params":{"content":"zoom"}},"visibility":"public"}}},"visibility":"public"},"component":"data2-core-types@ComponentParameter"}],"visibility":"public"},"component":"data2-core-types@ComponentParameter"},"share":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}}},"component":"data2-core-types@ComponentParameter"},"clipboard.copy":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}}}}]},"component":"data2-core-types@ComponentParameter"},"clipboard.paste":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}}}}]},"component":"data2-core-types@ComponentParameter"},"download":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"public","arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Filename"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Content"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Object"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Options"}},"parameters":{"params":{"fields":{"type":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Mimetype"}}}}}},"component":"data2-core-types@FieldSet"}}}]},"component":"data2-core-types@ComponentParameter"},"downloadUrl":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"public","arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Filename"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Url"}}}}]},"component":"data2-core-types@ComponentParameter"},"promptFiles":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":"data2-core-primitives@Object","flat":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"parameters":{"params":{"fields":{"multiple":{"component":"data2-core-types@ComponentParameter","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"Multiple"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Whether or not to allow selecting multiple files"}},"component":"data2-core-primitives@Boolean"}},"acceptedFormats":{"component":"data2-core-types@ComponentParameter","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"Accepted Formats"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Accepted file formats"}},"component":"data2-core-primitives@String","list":{"component":"data2-core-primitives@Boolean","params":{"content":true}}}},"timeout":{"component":"data2-core-types@ComponentParameter","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"Timeout"}},"description":{"component":"data2-core-primitives@String","params":{"content":"Max milliseconds to wait for file upload"}},"component":"data2-core-primitives@Number"}}}},"component":"data2-core-types@FieldSet"}}}],"visibility":"public"},"component":"data2-core-types@ComponentParameter"},"meetingRecord":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"public","definition":{"params":{"actions":[{"params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"meeting"}},"script":{"component":"data2-core-primitives@String","params":{"content":"(function() {\n  let mediaRecorder;\n  let audioChunks = [];\n  let combinedStream;\n  let audioContext;\n  \n  window.meetingCapture = async function() {\n    try {\n      // Solicitar acesso ao áudio da guia (e vídeo, que será ignorado depois)\n      const tabStream = await navigator.mediaDevices.getDisplayMedia({ \n        video: true, // Necessário para alguns navegadores\n        audio: true\n      });\n      \n      // Remover a trilha de vídeo\n      const tabAudioTrack = tabStream.getAudioTracks()[0];\n      \n      // Solicitar acesso ao microfone\n      const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });\n      const micAudioTrack = micStream.getAudioTracks()[0];\n      \n      // Criar um novo MediaStream com ambas as trilhas de áudio\n      combinedStream = new MediaStream([tabAudioTrack, micAudioTrack]);\n      \n      // Criar um contexto de áudio para misturar os streams\n      audioContext = new AudioContext();\n      const destination = audioContext.createMediaStreamDestination();\n      \n      // Conectar a trilha de áudio da guia\n      const tabSource = audioContext.createMediaStreamSource(new MediaStream([tabAudioTrack]));\n      tabSource.connect(destination);\n      \n      // Conectar a trilha de áudio do microfone\n      const micSource = audioContext.createMediaStreamSource(new MediaStream([micAudioTrack]));\n      micSource.connect(destination);\n      \n      // Iniciar gravação com o stream misturado\n      mediaRecorder = new MediaRecorder(destination.stream);\n      audioChunks = [];\n      mediaRecorder.ondataavailable = (e) => audioChunks.push(e.data);\n      mediaRecorder.start();\n      \n      console.log('Gravação iniciada. Chame meetingCaptureStop() para finalizar.');\n    } catch (error) {\n      console.error('Erro ao iniciar a captura:', error);\n    }\n  };\n  \n  window.meetingCaptureStop = function() {\n    return new Promise((resolve, reject) => {\n      if (!mediaRecorder) {\n        reject('Nenhuma gravação em andamento.');\n        return;\n      }\n      \n      mediaRecorder.onstop = () => {\n        const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });\n        combinedStream.getTracks().forEach(track => track.stop());\n        audioContext.close();\n        \n        resolve(audioBlob);\n        console.log('Gravação finalizada. O arquivo de áudio está pronto para upload.');\n      };\n      \n      mediaRecorder.stop();\n    });\n  };\n})();"}}},"component":"data2-core-web@Script"},{"component":"data2-core-types@Getter","params":{"module":{"params":{},"component":"data2-core-web@Window"},"prop":{"component":"data2-core-primitives@String","params":{"content":"meetingCapture"}}}}]},"component":"data2-core-flow@Series"}},"component":"data2-core-types@ComponentParameter"},"meetingRecordStop":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"public","arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Filename"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Content"}}}},{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Object"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Options"}},"parameters":{"params":{"fields":{"type":{"component":"data2-core-types@ComponentParameter","params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@String"}},"title":{"component":"data2-core-primitives@String","params":{"content":"Mimetype"}}}}}},"component":"data2-core-types@FieldSet"}}}],"definition":{"params":{"actions":[{"component":"data2-core-types@Getter","params":{"module":{"params":{},"component":"data2-core-web@Window"},"prop":{"component":"data2-core-primitives@String","params":{"content":"meetingCaptureStop"}}}}]},"component":"data2-core-flow@Series"}},"component":"data2-core-types@ComponentParameter"},"notify":{"params":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core-primitives@Function"}},"visibility":"private","arguments":[{"component":"data2-core-types@ComponentParameter","params":{"component":"data2-core-primitives@Object","parameters":{"title":{"params":{"component":"data2-core-primitives@String","index":{"component":"data2-core-primitives@Number","params":{"content":0}},"title":{"component":"data2-core-primitives@String","params":{"content":"title"}}},"component":"data2-core-types@ComponentParameter"},"sound":{"params":{"component":"data2-core-types@FileComponentUrlReference","index":{"component":"data2-core-primitives@Number","params":{"content":1}},"title":{"component":"data2-core-primitives@String","params":{"content":"sound"}}},"component":"data2-core-types@ComponentParameter"}},"flat":{"component":"data2-core-primitives@Boolean","params":{"content":true}}}}]},"component":"data2-core-types@ComponentParameter"}}},"component":"data2-core-types@FieldSet"}}},{"_id":"6a6997e39279f4a770dce0a0","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"yJwIwtjYNeYGoGF68aYQM","package":"natura-geomarketing","extends":"data2-core-primitives@Block","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const UF_CODE={RO:'11',AC:'12',AM:'13',RR:'14',PA:'15',AP:'16',TO:'17',MA:'21',PI:'22',CE:'23',RN:'24',PB:'25',PE:'26',AL:'27',SE:'28',BA:'29',MG:'31',ES:'32',RJ:'33',SP:'35',PR:'41',SC:'42',RS:'43',MS:'50',MT:'51',GO:'52',DF:'53'};\nconst UF_BORDERS={AC:['AM','RO'],AL:['BA','PE','SE'],AM:['AC','RO','RR','PA','MT'],AP:['PA'],\n BA:['AL','SE','PE','PI','TO','GO','MG','ES'],CE:['PI','PB','PE','RN'],DF:['GO','MG'],\n ES:['BA','MG','RJ'],GO:['DF','MT','MS','MG','BA','TO'],MA:['PA','TO','PI'],\n MG:['BA','GO','DF','MS','SP','RJ','ES'],MS:['MT','GO','MG','SP','PR'],MT:['RO','AM','PA','TO','GO','MS'],\n PA:['AP','AM','MT','TO','MA','RR'],PB:['RN','CE','PE'],PE:['PB','CE','PI','BA','AL'],\n PI:['MA','TO','BA','PE','CE'],PR:['SP','MS','SC'],RJ:['ES','MG','SP'],RN:['CE','PB'],\n RO:['AC','AM','MT'],RR:['AM','PA'],RS:['SC'],SC:['PR','RS'],SE:['BA','AL'],\n SP:['MG','RJ','PR','MS'],TO:['PA','MA','PI','BA','GO','MT']};\nconst PALETTE=['#8b7fd6','#d1b048','#67bd5b','#5aa9d6','#d67fa8','#c98a4b'];\n\nexport default {\n  css: `\n    :host{display:block;width:100%;height:100%;box-sizing:border-box}\n    .wrap{position:relative;height:100%;display:flex;flex-direction:column;font:12px -apple-system,BlinkMacSystemFont,\"Segoe UI\",Inter,sans-serif;background:#f4f4f8}\n    .stage{flex:1;min-height:0;position:relative;cursor:grab}\n    .stage:active{cursor:grabbing}\n    svg{width:100%;height:100%;display:block}\n    .muni{stroke:#0b0d12;stroke-width:1.1;transition:filter .1s}\n    .muni.ctx{cursor:default}\n    .muni:not(.ctx){cursor:pointer}\n    .muni:not(.ctx):hover{filter:brightness(1.25)}\n    .wedge{cursor:pointer;stroke:#fff;stroke-width:0.6;transition:filter .1s}\n    .wedge:hover{filter:brightness(1.25)}\n    .bg{fill:#ffffff;stroke:#1a1a1a;stroke-width:.5;opacity:.5}\n    .mlabel{fill:#1a1a1a;font-size:9px;font-weight:700;pointer-events:none;user-select:none;text-anchor:middle;paint-order:stroke;stroke:rgba(255,255,255,.6);stroke-width:0.5px}\n    .legend{display:flex;gap:10px;flex-wrap:wrap;padding:8px 10px;border-top:1px solid #e7e7ef;background:#fff}\n    .legend span{display:inline-flex;align-items:center;gap:5px}\n    .legend b{width:10px;height:10px;border-radius:3px;display:inline-block}\n    .load{position:absolute;inset:0;background:rgba(244,244,248,.92);display:flex;align-items:center;justify-content:center;z-index:5;color:#71717f;text-align:center;padding:16px;cursor:default}\n    .load.err{cursor:pointer;text-decoration:underline;color:#c8104e}\n    .load.hidden{display:none}\n    .tip{position:fixed;pointer-events:none;background:#000;color:#fff;border-radius:7px;padding:6px 8px;font-size:11px;opacity:0;transition:opacity .1s;z-index:9;max-width:220px}\n  `,\n  render(){\n    return `<div class=\"wrap\">\n      <div class=\"stage\"><svg id=\"map\" viewBox=\"0 0 700 500\" preserveAspectRatio=\"xMidYMid meet\"></svg></div>\n      <div class=\"legend\" id=\"legend\"></div>\n      <div class=\"tip\" id=\"tip\"></div>\n      <div class=\"load\" id=\"loader\">carregando malhas…</div>\n    </div>`;\n  },\n  mounted(d2, root){\n\n    const $ = s => root.querySelector(s);\n    const NS = 'http://www.w3.org/2000/svg';\n    const norm = s => (s||'').normalize('NFKD').replace(/[̀-ͯ]/g,'').toUpperCase().replace(/[^A-Z0-9]+/g,' ').trim();\n    const esc = s => (s==null?'':String(s)).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\n    const splitUfs = s => (s||'').split(',').map(x=>x.trim().toUpperCase()).filter(Boolean);\n\n    async function geoUF(uf){\n      const code = UF_CODE[uf] || uf;\n      const r = await fetch('https://raw.githubusercontent.com/tbrugz/geodata-br/master/geojson/geojs-'+code+'-mun.json');\n      if (!r.ok) throw new Error('geo ' + uf);\n      return r.json();\n    }\n    function buildAdj(features){\n      const pts = f => {\n        const g=f.geometry, ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates], s=new Set();\n        ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>s.add(Math.round(p[0]*1000)/1000+','+Math.round(p[1]*1000)/1000))));\n        return s;\n      };\n      const feat = {}, idx = {};\n      features.forEach(f => { const nm = norm(f.properties.name || f.properties.NOME || f.properties.nome); feat[nm] = { f, pts: pts(f) }; });\n      for (const nm in feat) feat[nm].pts.forEach(p => (idx[p] = idx[p] || []).push(nm));\n      const nbrs = nm => {\n        const c = {};\n        feat[nm].pts.forEach(p => idx[p].forEach(o => { if (o !== nm) c[o] = (c[o] || 0) + 1; }));\n        return Object.entries(c).filter(([, n]) => n >= 2).map(([o]) => o);\n      };\n      return { feat, nbrs };\n    }\n    function project(feats){\n      let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n      const each = cb => feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(p=>p.forEach(r=>r.forEach(cb)));});\n      each(p=>{mnx=Math.min(mnx,p[0]);mxx=Math.max(mxx,p[0]);mny=Math.min(mny,p[1]);mxy=Math.max(mxy,p[1]);});\n      const mlat=(mny+mxy)/2, kx=Math.cos(mlat*Math.PI/180), W=700,H=500,pad=22;\n      const gw=(mxx-mnx)*kx, gh=(mxy-mny), sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n      const ox=(W-gw*sc)/2, oy=(H-gh*sc)/2;\n      const pr = p=>[ox+(p[0]-mnx)*kx*sc, oy+(mxy-p[1])*sc];\n      pr.scale = sc;\n      return pr;\n    }\n    function pathFor(f,pr){\n      const g=f.geometry, ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      return ps.map(poly=>poly.map(r=>'M'+r.map(p=>{const q=pr(p);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');\n    }\n    function cxy(f,pr){\n      let x=0,y=0,n=0; const g=f.geometry, ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      ps.forEach(poly=>poly[0].forEach(p=>{const q=pr(p);x+=q[0];y+=q[1];n++;}));\n      return [x/n, y/n];\n    }\n    // Clips a projected ring (screen-space [x,y] points) against a single straight half-plane\n    // (side(x,y) >= 0 is kept) via Sutherland-Hodgman. Unlike a wedge radiating from one apex —\n    // which only covers a shape the apex can \"see\" the whole border of — a straight-line cut is\n    // exact for ANY simple polygon, convex or concave, so stacking these never leaves a gap.\n    function clipHalfPlane(ring, side){\n      const out = [];\n      const n = ring.length;\n      for (let i = 0; i < n; i++){\n        const curr = ring[i], prev = ring[(i - 1 + n) % n];\n        const sCurr = side(curr[0], curr[1]);\n        const sPrev = side(prev[0], prev[1]);\n        const currIn = sCurr >= -1e-9;\n        const prevIn = sPrev >= -1e-9;\n        if (currIn) {\n          if (!prevIn) out.push(intersectHalfPlane(prev, curr, side));\n          out.push(curr);\n        } else if (prevIn) {\n          out.push(intersectHalfPlane(prev, curr, side));\n        }\n      }\n      return out;\n    }\n    function intersectHalfPlane(p1, p2, side){\n      const s1 = side(p1[0], p1[1]), s2 = side(p2[0], p2[1]);\n      const t = s1 / (s1 - s2);\n      return [p1[0] + t * (p2[0] - p1[0]), p1[1] + t * (p2[1] - p1[1])];\n    }\n    // Full projected bounding box of a município — used to lay bands across its ENTIRE geometry\n    // (all MultiPolygon parts included), so the bands always tile the whole shape, islands too.\n    function bboxOf(f, pr){\n      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;\n      const g = f.geometry, ps = g.type === 'MultiPolygon' ? g.coordinates : [g.coordinates];\n      ps.forEach(poly => poly.forEach(ring => ring.forEach(p => {\n        const q = pr(p);\n        minX = Math.min(minX, q[0]); maxX = Math.max(maxX, q[0]);\n        minY = Math.min(minY, q[1]); maxY = Math.max(maxY, q[1]);\n      })));\n      return [minX, minY, maxX, maxY];\n    }\n    // A vertical band [x0,x1) of the município, clipped against its REAL border on both sides.\n    function stripeClipPath(f, pr, x0, x1){\n      const g = f.geometry, ps = g.type === 'MultiPolygon' ? g.coordinates : [g.coordinates];\n      let d = '';\n      ps.forEach(poly => poly.forEach(ring => {\n        let pts = ring.map(p => pr(p));\n        pts = clipHalfPlane(pts, (x) => x - x0);\n        if (pts.length) pts = clipHalfPlane(pts, (x) => x1 - x);\n        if (pts.length >= 3) d += 'M' + pts.map(q => q[0].toFixed(1) + ',' + q[1].toFixed(1)).join('L') + 'Z';\n      }));\n      return d;\n    }\n    const loader = (on, t, isErr) => {\n      const l = $('#loader');\n      l.classList.toggle('hidden', !on);\n      l.classList.toggle('err', !!isErr);\n      if (t != null) l.textContent = t;\n    };\n\n    let S = { assign: { ...(d2.param('assignments', {}) || {}) }, targets: new Set(), clickable: new Set(), closeFeats: {}, nbrFeats: [], labels: {}, pr: null, view: { scale: 1, tx: 0, ty: 0 }, groupAssign: {} };\n    let dragStart = null, dragMoved = false;\n\n    // Live sector for a município: the Store (if bound) is the source of truth —\n    // reads are tracked, so an external change to the Store re-renders this map.\n    // `S.assign` is only the bootstrap default (from `assignments`) before the\n    // Store has an entry, or the fallback when no `store` param is bound at all.\n    function getAssign(key){\n      const storeRef = d2.param('store');\n      if (!storeRef) return S.assign[key];\n      const prop = d2.param('prop');\n      if (prop) {\n        const obj = d2.store(storeRef).get(prop, {}) || {};\n        return key in obj ? obj[key] : S.assign[key];\n      }\n      return d2.store(storeRef).get(key, S.assign[key]);\n    }\n\n    // Writes back through the same `prop` indirection as getAssign(): when `prop` is set,\n    // values land on store[prop][key] (a nested object) instead of store[key] directly —\n    // the same shape a Setter targeting `@Store.prop` would produce.\n    function setAssign(key, value){\n      S.assign[key] = value;\n      const storeRef = d2.param('store');\n      if (!storeRef) return;\n      const prop = d2.param('prop');\n      if (prop) {\n        const obj = { ...(d2.store(storeRef).get(prop, {}) || {}) };\n        obj[key] = value;\n        d2.store(storeRef).set(prop, obj);\n      } else {\n        d2.store(storeRef).set(key, value);\n      }\n    }\n\n    // A município can hold MULTIPLE sectors at once — stored as an array instead of\n    // a single string. cycleAt() advances just one slot of that array (one pie wedge)\n    // through `groups`, padding the array if the clicked index didn't exist yet.\n    // `groups` is now an array of { sector, municipio } — sector is the value cycled\n    // through on click; municipio (comma-separated) statically pins that sector's color\n    // onto municípios that aren't part of the interactive store-backed assignment.\n    function sectorNames(){\n      return (d2.param('groups', []) || []).map(g => g.sector);\n    }\n\n    function cycleAt(key, idx, arrLen){\n      const gs = sectorNames();\n      if (!gs.length) return null;\n      let arr = getAssign(key);\n      arr = Array.isArray(arr) ? [...arr] : [];\n      while (arr.length < arrLen) arr.push('');\n      const next = gs[(gs.indexOf(arr[idx]) + 1) % gs.length];\n      arr[idx] = next;\n      setAssign(key, arr);\n      return next;\n    }\n\n    function svgPoint(evt){\n      const svg = $('#map');\n      const pt = svg.createSVGPoint();\n      pt.x = evt.clientX; pt.y = evt.clientY;\n      const ctm = svg.getScreenCTM();\n      if (!ctm) return { x: 0, y: 0 };\n      const p = pt.matrixTransform(ctm.inverse());\n      return { x: p.x, y: p.y };\n    }\n    function applyTransform(){\n      const g = $('#viewport');\n      if (g) g.setAttribute('transform', `translate(${S.view.tx} ${S.view.ty}) scale(${S.view.scale})`);\n    }\n    d2.on('.stage', 'wheel', e => {\n      e.preventDefault();\n      const p = svgPoint(e);\n      const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;\n      const next = Math.min(8, Math.max(0.15, S.view.scale * factor));\n      const k = next / S.view.scale;\n      S.view.tx = p.x - k * (p.x - S.view.tx);\n      S.view.ty = p.y - k * (p.y - S.view.ty);\n      S.view.scale = next;\n      applyTransform();\n    });\n    d2.on('.stage', 'mousedown', e => {\n      if (e.button !== 0) return;\n      const p = svgPoint(e);\n      dragStart = { x: p.x, y: p.y, tx: S.view.tx, ty: S.view.ty };\n      dragMoved = false;\n    });\n    d2.on(window, 'mousemove', e => {\n      if (!dragStart) return;\n      const p = svgPoint(e);\n      const dx = p.x - dragStart.x, dy = p.y - dragStart.y;\n      if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) dragMoved = true;\n      S.view.tx = dragStart.tx + dx;\n      S.view.ty = dragStart.ty + dy;\n      applyTransform();\n    });\n    d2.on(window, 'mouseup', () => { dragStart = null; });\n\n    // This component paints imperatively (mounted-driven SVG, not the reactive\n    // render() string), so a Store read here isn't auto-tracked the way it would\n    // be inside render(). Poll the bound Store's sector values every frame and\n    // redraw when any of them changed — this is what makes an EXTERNAL write to\n    // the Store (not just this map's own clicks) show up on the map.\n    let lastAssignSnapshot = {};\n    d2.raf(() => {\n      const storeRef = d2.param('store');\n      if (!storeRef) return;\n      let changed = false;\n      Object.keys(S.closeFeats).forEach(key => {\n        const v = getAssign(key);\n        // Arrays (multi-sector municípios) are compared by a joined signature, not by\n        // reference — a store that clones on read would otherwise look \"changed\" every frame.\n        const sig = Array.isArray(v) ? v.join('\u0001') : v;\n        if (lastAssignSnapshot[key] !== sig) { changed = true; lastAssignSnapshot[key] = sig; }\n      });\n      if (changed) drawMap();\n    });\n\n    function drawMap(){\n      const svg = $('#map'); svg.innerHTML = '';\n      const viewport = document.createElementNS(NS, 'g');\n      viewport.setAttribute('id', 'viewport');\n      svg.appendChild(viewport);\n      applyTransform();\n      const groupDefs = d2.param('groups', []) || [];\n      const sectors = groupDefs.map(g => g.sector);\n      const colors = d2.param('colors', {});\n      const colorFor = g => colors[g] || PALETTE[sectors.indexOf(g) % PALETTE.length] || '#94a3b8';\n      // Base label size tracks the projection's own fit scale, so labels stay proportionate\n      // to the shapes whether the auto-fit is tight (one neighborhood) or wide (distant targets).\n      const labelFontSize = Math.max(4, Math.min(14, 9 * ((S.pr.scale || 1000) / 1000)));\n\n      S.nbrFeats.forEach(f => {\n        const p = document.createElementNS(NS, 'path');\n        p.setAttribute('d', pathFor(f, S.pr));\n        p.setAttribute('class', 'bg');\n        p.setAttribute('data-key', 'bg-' + (f.properties.name || f.properties.NOME || f.properties.nome || ''));\n        viewport.appendChild(p);\n      });\n\n      Object.entries(S.closeFeats).forEach(([key, f]) => {\n        const clickable = S.clickable.has(key);\n        const groupSectors = !clickable ? S.groupAssign[key] : null;\n        const groupSector = groupSectors ? groupSectors[0] : null;\n        const currentVal = getAssign(key);\n        const arrVal = Array.isArray(currentVal) ? currentVal : null;\n        const isPie = !!(arrVal && arrVal.length >= 2 && clickable);\n        // Static group pins can also stack more than one sector onto the same município\n        // (e.g. two `groups[]` entries both listing it) — split those into a pie too.\n        const isGroupPie = !!(groupSectors && groupSectors.length >= 2);\n        const soloSector = arrVal ? arrVal[0] : currentVal;\n        const p = document.createElementNS(NS, 'path');\n        p.setAttribute('d', pathFor(f, S.pr));\n        p.setAttribute('data-key', key);\n        p.setAttribute('class', ['muni', clickable ? '' : 'ctx'].filter(Boolean).join(' '));\n\n        if (isPie || isGroupPie) {\n          // Multiple sectors share this município — the shape itself is a neutral\n          // safety-net backdrop; the wedges below (clipped to the REAL border, one per\n          // array entry) carry the actual colors and fully cover the município's area.\n          const pieSectors = isPie ? arrVal : groupSectors;\n          p.setAttribute('fill', '#eef0f5');\n          p.onmousemove = e => {\n            const tip = $('#tip');\n            tip.style.opacity = 1; tip.style.left = (e.clientX + 12) + 'px'; tip.style.top = (e.clientY + 12) + 'px';\n            const names = pieSectors.map(v => v || 'sem destino').join(', ');\n            tip.innerHTML = `<b>${esc(S.labels[key] || key)}</b><br>${pieSectors.length} setores: ${esc(names)}`;\n          };\n          p.onmouseleave = () => { $('#tip').style.opacity = 0; };\n        } else {\n          if (groupSector) {\n            p.setAttribute('fill', colorFor(groupSector));\n            p.setAttribute('fill-opacity', '0.3');\n          } else {\n            p.setAttribute('fill', soloSector ? colorFor(soloSector) : (clickable ? '#94a3b8' : '#dcdfe8'));\n          }\n          if (clickable) {\n            p.onclick = () => {\n              if (dragMoved) { dragMoved = false; return; }\n              let next;\n              if (arrVal) {\n                next = cycleAt(key, 0, arrVal.length || 1);\n              } else {\n                const gs = sectorNames();\n                if (!gs.length) return;\n                next = gs[(gs.indexOf(getAssign(key)) + 1) % gs.length];\n                setAssign(key, next);\n              }\n              drawMap();\n              d2.emit('onChange', arrVal ? { municipio: key, sectorIndex: 0, setor: next } : { municipio: key, setor: next });\n            };\n          }\n          p.onmousemove = e => {\n            const tip = $('#tip');\n            tip.style.opacity = 1; tip.style.left = (e.clientX + 12) + 'px'; tip.style.top = (e.clientY + 12) + 'px';\n            const label = groupSector || soloSector || (clickable ? 'sem destino' : 'apenas contexto');\n            tip.innerHTML = `<b>${esc(S.labels[key] || key)}</b><br>${esc(label)}`;\n          };\n          p.onmouseleave = () => { $('#tip').style.opacity = 0; };\n        }\n        viewport.appendChild(p);\n\n        const [cx, cy] = cxy(f, S.pr);\n\n        if (isPie || isGroupPie) {\n          const wedgeSectors = isPie ? arrVal : groupSectors;\n          const n = wedgeSectors.length;\n          // Bands across the FULL bounding box of this município, not wedges around a center —\n          // a center point can't always \"see\" a concave shape's whole border, which used to leave\n          // slivers uncovered; straight parallel cuts always tile the entire shape exactly.\n          const [bx0, , bx1] = bboxOf(f, S.pr);\n          const stripeW = (bx1 - bx0) / n;\n          wedgeSectors.forEach((sector, i) => {\n            const d = stripeClipPath(f, S.pr, bx0 + i * stripeW, bx0 + (i + 1) * stripeW);\n            if (!d) return;\n            const wedge = document.createElementNS(NS, 'path');\n            wedge.setAttribute('d', d);\n            wedge.setAttribute('class', 'wedge');\n            wedge.setAttribute('fill', sector ? colorFor(sector) : '#94a3b8');\n            // Static group pins (`groups[]`) aren't interactable — keep them at ~30% opacity so\n            // they read as context, distinct from a real Store-backed sector assignment.\n            wedge.setAttribute('fill-opacity', isPie ? '1' : '0.3');\n            wedge.style.cursor = isPie ? 'pointer' : 'default';\n            if (isPie) {\n              wedge.onclick = () => {\n                if (dragMoved) { dragMoved = false; return; }\n                const next = cycleAt(key, i, n);\n                drawMap();\n                d2.emit('onChange', { municipio: key, sectorIndex: i, setor: next });\n              };\n            }\n            wedge.onmousemove = e => {\n              const tip = $('#tip');\n              tip.style.opacity = 1; tip.style.left = (e.clientX + 12) + 'px'; tip.style.top = (e.clientY + 12) + 'px';\n              tip.innerHTML = `<b>${esc(S.labels[key] || key)}</b> — setor ${i + 1}/${n}<br>${esc(sector || 'sem destino')}`;\n            };\n            wedge.onmouseleave = () => { $('#tip').style.opacity = 0; };\n            viewport.appendChild(wedge);\n          });\n        }\n\n        const t = document.createElementNS(NS, 'text');\n        t.setAttribute('class', 'mlabel'); t.setAttribute('x', cx); t.setAttribute('y', cy);\n        t.style.fontSize = labelFontSize + 'px';\n        t.textContent = (S.labels[key] || key).slice(0, 13);\n        viewport.appendChild(t);\n      });\n\n      $('#legend').innerHTML = sectors.map(g => `<span><b style=\"background:${colorFor(g)}\"></b>${esc(g)}</span>`).join('');\n    }\n\n    async function load(){\n      loader(true, 'carregando malhas…', false);\n      try {\n        const ufs = splitUfs(d2.param('ufs', 'PR'));\n        if (!ufs.length) throw new Error('informe ao menos uma UF em \"ufs\"');\n        const manualNbr = splitUfs(d2.param('neighborUfs', ''));\n        const autoNbr = ufs.flatMap(u => UF_BORDERS[u] || []);\n        const nbrUfs = [...new Set(manualNbr.length ? manualNbr : autoNbr)].filter(u => !ufs.includes(u));\n        const allUfs = [...new Set([...ufs, ...nbrUfs])];\n        const geos = await Promise.all(allUfs.map(u => geoUF(u).catch(() => null)));\n\n        const closeUfSet = new Set(ufs);\n        const allFeats = [];\n        const stateNames = [];\n        allUfs.forEach((u, i) => {\n          const g = geos[i]; if (!g) return;\n          const inClose = closeUfSet.has(u);\n          g.features.forEach(f => {\n            allFeats.push(f);\n            if (inClose) stateNames.push(norm(f.properties.name || f.properties.NOME || f.properties.nome || ''));\n          });\n        });\n        if (!allFeats.length) throw new Error('nenhuma malha carregada para essas UFs');\n\n        const adj = buildAdj(allFeats);\n\n        const targetsRaw = (d2.param('municipio', '') || '').split(',').map(x => x.trim()).filter(Boolean);\n        const targetNames = targetsRaw.map(norm);\n        const foundTargets = targetNames.filter(t => adj.feat[t]);\n        if (targetNames.length && !foundTargets.length) throw new Error('município(s) não encontrado(s) na(s) UF(s) informada(s): ' + targetsRaw.join(', '));\n        S.targets = new Set(foundTargets);\n\n        // Interactive scope (\"close\"): each target município (comma-separated in `municipio`)\n        // + its direct neighbors — NOT the whole state. Falls back to the whole state when no\n        // `municipio` is set.\n        const neighborhoodNames = foundTargets.length\n          ? [...new Set(foundTargets.flatMap(t => [t, ...adj.nbrs(t)]))]\n          : stateNames;\n\n        // Statically pins a sector's color onto whatever municípios are listed in that\n        // group's `municipio` field — these are display-only, not interactively assignable.\n        // A município can be listed by more than one group (e.g. Astorga under both Setor A\n        // and Setor B) — keep every sector that claims it so drawMap() can split it into a pie.\n        const groupAssign = {};\n        (d2.param('groups', []) || []).forEach(g => {\n          (g.municipio || '').split(',').map(x => x.trim()).filter(Boolean).forEach(n => {\n            const key = norm(n);\n            (groupAssign[key] = groupAssign[key] || []).push(g.sector);\n          });\n        });\n\n        // A município already carrying a sector assignment joins the interactive set even when\n        // it's not adjacent to the target — this is what `neighborUfs` is for: letting a sector\n        // reach into a município from a bordering state, as long as that UF was loaded.\n        const assignedSet = new Set(Object.keys(S.assign).map(norm));\n        const assignedLoaded = [...assignedSet].filter(n => adj.feat[n]);\n        const groupLoaded = Object.keys(groupAssign).filter(n => adj.feat[n]);\n        const closingNames = [...new Set([...neighborhoodNames, ...assignedLoaded, ...groupLoaded])];\n        const closingSet = new Set(closingNames);\n\n        S.closeFeats = {}; S.labels = {};\n        closingNames.forEach(n => { if (adj.feat[n]) { S.closeFeats[n] = adj.feat[n].f; S.labels[n] = adj.feat[n].f.properties.name || n; } });\n\n        S.clickable = new Set(closingNames.filter(n => assignedSet.has(n)));\n        S.groupAssign = groupAssign;\n\n        // Everything else loaded (rest of the state / neighbor states) is faint background context.\n        const restNames = new Set();\n        allFeats.forEach(f => {\n          const n = norm(f.properties.name || f.properties.NOME || f.properties.nome || '');\n          if (adj.feat[n] && !closingSet.has(n)) restNames.add(n);\n        });\n        S.nbrFeats = [...restNames].map(n => adj.feat[n].f);\n\n        const zoomFeats = Object.values(S.closeFeats);\n        S.pr = project(zoomFeats.length ? zoomFeats : allFeats);\n        S.view = { scale: 1, tx: 0, ty: 0 };\n        drawMap();\n        loader(false);\n      } catch (e) {\n        loader(true, 'erro: ' + e.message + ' (clique p/ tentar de novo)', true);\n      }\n    }\n\n    $('#loader').onclick = () => {\n      if (!$('#loader').classList.contains('err')) return;\n      S.closeFeats = {}; S.nbrFeats = []; S.labels = {};\n      load();\n    };\n\n    load();\n  },\n};"}},"assignments":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"assignments"}}}},"colors":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"colors"}}}},"groups":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"groups"}}}},"municipio":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"municipio"}}}},"neighborUfs":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"neighborUfs"}}}},"onChange":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"onChange"}}}},"store":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"store"}}}},"ufs":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"ufs"}}}},"prop":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"prop"}}}}}}}}}},{"_id":"6a71ddb4a9d7cf0ea99224f1","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"FjpgPxu84sgMMjMKJhyUm","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const UF_CODE={RO:'11',AC:'12',AM:'13',RR:'14',PA:'15',AP:'16',TO:'17',MA:'21',PI:'22',CE:'23',RN:'24',PB:'25',PE:'26',AL:'27',SE:'28',BA:'29',MG:'31',ES:'32',RJ:'33',SP:'35',PR:'41',SC:'42',RS:'43',MS:'50',MT:'51',GO:'52',DF:'53'};\nconst UF_BORDERS={AC:['AM','RO'],AL:['BA','PE','SE'],AM:['AC','RO','RR','PA','MT'],AP:['PA'],\n BA:['AL','SE','PE','PI','TO','GO','MG','ES'],CE:['PI','PB','PE','RN'],DF:['GO','MG'],\n ES:['BA','MG','RJ'],GO:['DF','MT','MS','MG','BA','TO'],MA:['PA','TO','PI'],\n MG:['BA','GO','DF','MS','SP','RJ','ES'],MS:['MT','GO','MG','SP','PR'],MT:['RO','AM','PA','TO','GO','MS'],\n PA:['AP','AM','MT','TO','MA','RR'],PB:['RN','CE','PE'],PE:['PB','CE','PI','BA','AL'],\n PI:['MA','TO','BA','PE','CE'],PR:['SP','MS','SC'],RJ:['ES','MG','SP'],RN:['CE','PB'],\n RO:['AC','AM','MT'],RR:['AM','PA'],RS:['SC'],SC:['PR','RS'],SE:['BA','AL'],\n SP:['MG','RJ','PR','MS'],TO:['PA','MA','PI','BA','GO','MT']};\nconst UF_NAMES={RO:'Rondônia',AC:'Acre',AM:'Amazonas',RR:'Roraima',PA:'Pará',AP:'Amapá',TO:'Tocantins',\n MA:'Maranhão',PI:'Piauí',CE:'Ceará',RN:'Rio Grande do Norte',PB:'Paraíba',PE:'Pernambuco',AL:'Alagoas',\n SE:'Sergipe',BA:'Bahia',MG:'Minas Gerais',ES:'Espírito Santo',RJ:'Rio de Janeiro',SP:'São Paulo',\n PR:'Paraná',SC:'Santa Catarina',RS:'Rio Grande do Sul',MS:'Mato Grosso do Sul',MT:'Mato Grosso',\n GO:'Goiás',DF:'Distrito Federal'};\nconst REGION_UFS={N:['RO','AC','AM','RR','PA','AP','TO'],NE:['MA','PI','CE','RN','PB','PE','AL','SE','BA'],\n CO:['MS','MT','GO','DF'],SE:['MG','ES','RJ','SP'],S:['PR','SC','RS']};\nconst REGION_NAMES={N:'Norte',NE:'Nordeste',CO:'Centro-Oeste',SE:'Sudeste',S:'Sul'};\nconst PALETTE=['#8b7fd6','#d1b048','#67bd5b','#5aa9d6','#d67fa8','#c98a4b'];\n\nexport default {\n  css: `\n    :host{display:block;width:100%;height:100%;box-sizing:border-box}\n    .wrap{position:relative;height:100%;display:flex;flex-direction:column;font:13px -apple-system,BlinkMacSystemFont,\"SF Pro Text\",Inter,sans-serif;background:transparent;overflow:hidden;border-radius:0px;border:0px solid rgba(0,0,0,.08);box-shadow:0 6px 20px rgba(0,0,0,.12)}\n    .stage{flex:1;min-height:0;position:relative;cursor:grab;touch-action:none}\n    .stage:active{cursor:grabbing}\n    svg{width:100%;height:100%;display:block}\n    .area{stroke:rgba(255,255,255,.2);stroke-width:.85;stroke-linejoin:round;stroke-linecap:round}\n    .wedge{stroke:rgba(255,255,255,.7);stroke-width:.5;stroke-linejoin:round;cursor:pointer;transition:filter .15s ease}\n    .wedge:hover{filter:brightness(1.16)}\n    .wedge.touchy{stroke-width:.9}\n    .lbl{fill:#1D1D1F;font-size:9px;font-weight:600;pointer-events:none;user-select:none;text-anchor:middle;paint-order:stroke;stroke:rgba(255,255,255,.7);stroke-width:.6px}\n    .legend{display:none!important;position:absolute;left:10px;bottom:10px;max-width:64%;max-height:70%;overflow:auto;flex-direction:column;gap:8px;padding:10px 12px;border-radius:12px;background:rgba(255,255,255,.78);-webkit-backdrop-filter:blur(30px) saturate(180%);backdrop-filter:blur(30px) saturate(180%);border:1px solid rgba(0,0,0,.08);font-size:11px;box-shadow:0 8px 24px rgba(0,0,0,.20)}\n    .legend .grp{display:flex;flex-direction:column;gap:3px}\n    .legend .grpname{font-weight:700;color:rgba(0,0,0,.5);font-size:10px;text-transform:uppercase;letter-spacing:.06em}\n    .legend .row{display:flex;align-items:center;gap:6px;color:#1D1D1F}\n    .legend b{width:10px;height:10px;border-radius:3px;display:inline-block;flex:0 0 auto;box-shadow:inset 0 0 0 1px rgba(0,0,0,.1)}\n    .tip{display:none!important;position:fixed;pointer-events:none;background:rgba(255,255,252,.95);-webkit-backdrop-filter:blur(20px) saturate(150%);backdrop-filter:blur(20px) saturate(150%);color:#1D1D1F;border:1px solid rgba(0,0,0,.08);border-radius:6px;padding:6px 9px;font-size:11px;opacity:0;transition:opacity .12s ease;box-shadow:0 2px 8px rgba(0,0,0,.15);z-index:9;max-width:220px}\n    .infobar{display:none!important;position:absolute;left:0;right:0;bottom:0;background:rgba(246,246,247,.85);-webkit-backdrop-filter:blur(20px) saturate(180%);backdrop-filter:blur(20px) saturate(180%);border-top:1px solid rgba(0,0,0,.1);color:#1D1D1F;padding:10px 14px;font-size:13px;display:flex;justify-content:space-between;align-items:center;gap:10px;transform:translateY(100%);transition:transform .18s ease;z-index:8}\n    .infobar.show{transform:translateY(0)}\n    .infobar .x{opacity:.5;cursor:pointer;padding:2px 7px;border-radius:5px;transition:background .12s ease,opacity .12s ease}\n    .infobar .x:hover{opacity:.9;background:rgba(0,0,0,.06)}\n    .load{position:absolute;inset:0;background:rgba(236,236,236,.94);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;z-index:5;color:rgba(0,0,0,.5);text-align:center;padding:16px;cursor:default;font-size:13px}\n    .load.err{cursor:pointer;text-decoration:underline;color:#FF3B30}\n    .empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:rgba(0,0,0,.5);text-align:center;padding:24px;font-size:13px}\n    .hidden{display:none}\n  `,\n  render(){\n    return `<div class=\"wrap\">\n      <div class=\"stage\"><svg id=\"map\" viewBox=\"0 0 700 500\" preserveAspectRatio=\"xMidYMid meet\"></svg></div>\n      <div class=\"legend hidden\" id=\"legend\"></div>\n      <div class=\"tip\" id=\"tip\"></div>\n      <div class=\"infobar\" id=\"infobar\"><span id=\"infobarText\"></span><span class=\"x\" id=\"infobarClose\">✕</span></div>\n      <div class=\"load\" id=\"loader\">carregando malhas do Brasil…</div>\n      <div class=\"empty hidden\" id=\"empty\">Nenhuma localização declarada — informe country_list, region_list, state_list, city_list ou municipality_list.</div>\n    </div>`;\n  },\n  mounted(d2, root){\n\n    const $ = s => root.querySelector(s);\n    const NS = 'http://www.w3.org/2000/svg';\n    const norm = s => (s||'').normalize('NFKD').replace(/[̀-ͯ]/g,'').toUpperCase().replace(/[^A-Z0-9]+/g,' ').trim();\n    const esc = s => (s==null?'':String(s)).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\n    const toArr = v => Array.isArray(v) ? v : [];\n\n    function matchUf(idOrName){\n      if(!idOrName) return null;\n      const up = String(idOrName).trim().toUpperCase();\n      if (UF_CODE[up]) return up;\n      const n = norm(idOrName);\n      const hit = Object.keys(UF_NAMES).find(code => norm(UF_NAMES[code]) === n);\n      return hit || null;\n    }\n    function matchRegion(idOrName){\n      if(!idOrName) return null;\n      const up = String(idOrName).trim().toUpperCase();\n      if (REGION_UFS[up]) return up;\n      const n = norm(idOrName);\n      const hit = Object.keys(REGION_NAMES).find(code => norm(REGION_NAMES[code]) === n);\n      return hit || null;\n    }\n\n    async function geoUF(uf){\n      const code = UF_CODE[uf] || uf;\n      const r = await fetch('https://raw.githubusercontent.com/tbrugz/geodata-br/master/geojson/geojs-'+code+'-mun.json');\n      if (!r.ok) throw new Error('geo ' + uf);\n      return r.json();\n    }\n    function ptsOf(f){\n      const g=f.geometry, ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates], s=new Set();\n      ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>s.add(Math.round(p[0]*1000)/1000+','+Math.round(p[1]*1000)/1000))));\n      return s;\n    }\n    function buildIndex(features){\n      const recs = features.map(f => ({\n        name: norm(f.properties.name || f.properties.NOME || f.properties.nome || ''),\n        label: f.properties.name || f.properties.NOME || f.properties.nome || '',\n        uf: f.__uf, f, pts: ptsOf(f)\n      }));\n      const ptIdx = {};\n      recs.forEach((r,i) => r.pts.forEach(p => (ptIdx[p]=ptIdx[p]||[]).push(i)));\n      const nbrsOf = i => {\n        const c={};\n        recs[i].pts.forEach(p => ptIdx[p].forEach(j => { if (j!==i) c[j]=(c[j]||0)+1; }));\n        return Object.entries(c).filter(([,n])=>n>=2).map(([j])=>+j);\n      };\n      const byUfName = {};\n      recs.forEach((r,i) => { byUfName[r.uf]=byUfName[r.uf]||{}; byUfName[r.uf][r.name]=i; });\n      const anyName = {};\n      recs.forEach((r,i) => { (anyName[r.name]=anyName[r.name]||[]).push(i); });\n      return { recs, nbrsOf, byUfName, anyName };\n    }\n    function project(feats){\n      let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n      const each = cb => feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(p=>p.forEach(r=>r.forEach(cb)));});\n      each(p=>{mnx=Math.min(mnx,p[0]);mxx=Math.max(mxx,p[0]);mny=Math.min(mny,p[1]);mxy=Math.max(mxy,p[1]);});\n      const mlat=(mny+mxy)/2, kx=Math.cos(mlat*Math.PI/180), W=700,H=500,pad=22;\n      const gw=(mxx-mnx)*kx, gh=(mxy-mny), sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n      const ox=(W-gw*sc)/2, oy=(H-gh*sc)/2;\n      const pr = p=>[ox+(p[0]-mnx)*kx*sc, oy+(mxy-p[1])*sc];\n      pr.scale = sc;\n      return pr;\n    }\n    function pathFor(f,pr){\n      const g=f.geometry, ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      return ps.map(poly=>poly.map(r=>'M'+r.map(p=>{const q=pr(p);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');\n    }\n    function cxy(f,pr){\n      let x=0,y=0,n=0; const g=f.geometry, ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      ps.forEach(poly=>poly[0].forEach(p=>{const q=pr(p);x+=q[0];y+=q[1];n++;}));\n      return [x/n, y/n];\n    }\n    function ringsOf(f){\n      const g = f.geometry, ps = g.type==='MultiPolygon' ? g.coordinates : [g.coordinates];\n      const rings = [];\n      ps.forEach(poly => poly.forEach(ring => rings.push(ring)));\n      return rings;\n    }\n    const quant = p => [Math.round(p[0]*1000)/1000, Math.round(p[1]*1000)/1000];\n    const keyOf = p => p[0] + ',' + p[1];\n    // Dissolves the municipio polygons in `idxs` into their outer boundary (+ any real holes)\n    // by cancelling every edge shared by two included polygons — the border between two\n    // adjacent included municipios disappears, leaving only the union's true edge. Exact for\n    // this dataset because neighboring municipio files share literal boundary coordinates (the\n    // same tolerance ptsOf already relies on for adjacency).\n    function dissolveRings(idxs, recs){\n      if (idxs.length === 1) return ringsOf(recs[idxs[0]].f);\n      const edgeCount = new Map(), edges = [];\n      idxs.forEach(i => {\n        ringsOf(recs[i].f).forEach(ring => {\n          const q = ring.map(quant);\n          for (let k = 0; k < q.length - 1; k++){\n            const a = q[k], b = q[k+1], ka = keyOf(a), kb = keyOf(b);\n            if (ka === kb) continue;\n            const uk = ka < kb ? ka+'|'+kb : kb+'|'+ka;\n            edgeCount.set(uk, (edgeCount.get(uk)||0) + 1);\n            edges.push({ a, b, ka, kb, uk });\n          }\n        });\n      });\n      const kept = edges.filter(e => edgeCount.get(e.uk) === 1);\n      const byStart = new Map();\n      kept.forEach(e => {\n        if (!byStart.has(e.ka)) byStart.set(e.ka, []);\n        byStart.get(e.ka).push(e);\n      });\n      const used = new Set(), rings = [];\n      kept.forEach(seed => {\n        if (used.has(seed)) return;\n        const ring = [seed.a];\n        let cur = seed, guard = 0;\n        while (guard++ < 50000) {\n          used.add(cur);\n          ring.push(cur.b);\n          if (cur.kb === seed.ka) break;\n          const next = (byStart.get(cur.kb) || []).find(e => !used.has(e));\n          if (!next) break;\n          cur = next;\n        }\n        if (ring.length >= 4 && keyOf(ring[0]) === keyOf(ring[ring.length-1])) rings.push(ring);\n      });\n      return rings;\n    }\n    function pathFromRings(rings, pr){\n      return rings.map(ring => 'M'+ring.map(p=>{const q=pr(p);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('');\n    }\n    function stripeClipRings(rings, pr, x0, x1){\n      let d = '';\n      rings.forEach(ring => {\n        let pts = ring.map(p => pr(p));\n        pts = clipHalfPlane(pts, (x) => x - x0);\n        if (pts.length) pts = clipHalfPlane(pts, (x) => x1 - x);\n        if (pts.length >= 3) d += 'M' + pts.map(q => q[0].toFixed(1) + ',' + q[1].toFixed(1)).join('L') + 'Z';\n      });\n      return d;\n    }\n    function mergeCentroid(idxs, pr, recs){\n      let x=0,y=0,n=0;\n      idxs.forEach(i=>{ const [cx,cy]=cxy(recs[i].f,pr); x+=cx; y+=cy; n++; });\n      return n ? [x/n, y/n] : [0,0];\n    }\n    function mergedBbox(idxs, pr, recs){\n      let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;\n      idxs.forEach(i => {\n        const g=recs[i].f.geometry, ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>{\n          const q=pr(p);\n          minX=Math.min(minX,q[0]); maxX=Math.max(maxX,q[0]);\n          minY=Math.min(minY,q[1]); maxY=Math.max(maxY,q[1]);\n        })));\n      });\n      return [minX,minY,maxX,maxY];\n    }\n    // Sutherland-Hodgman clip against a straight half-plane (side(x,y)>=0 kept). A straight-line\n    // cut is exact for ANY simple polygon, convex or concave, so stacking bands never leaves a gap\n    // — unlike an angular wedge radiating from one apex, which can't \"see\" a concave shape's whole border.\n    function clipHalfPlane(ring, side){\n      const out = [];\n      const n = ring.length;\n      for (let i = 0; i < n; i++){\n        const curr = ring[i], prev = ring[(i - 1 + n) % n];\n        const sCurr = side(curr[0], curr[1]);\n        const sPrev = side(prev[0], prev[1]);\n        const currIn = sCurr >= -1e-9;\n        const prevIn = sPrev >= -1e-9;\n        if (currIn) {\n          if (!prevIn) out.push(intersectHalfPlane(prev, curr, side));\n          out.push(curr);\n        } else if (prevIn) {\n          out.push(intersectHalfPlane(prev, curr, side));\n        }\n      }\n      return out;\n    }\n    function intersectHalfPlane(p1, p2, side){\n      const s1 = side(p1[0], p1[1]), s2 = side(p2[0], p2[1]);\n      const t = s1 / (s1 - s2);\n      return [p1[0] + t * (p2[0] - p1[0]), p1[1] + t * (p2[1] - p1[1])];\n    }\n\n    const loader = (on, t, isErr) => {\n      const l = $('#loader');\n      l.classList.toggle('hidden', !on);\n      l.classList.toggle('err', !!isErr);\n      if (t != null) l.textContent = t;\n    };\n\n    let IDX = null;\n    let S = { pr: null, view: { scale: 1, tx: 0, ty: 0 }, focus: [], ctx: [] };\n    let dragStart = null, dragMoved = false;\n    let pointers = new Map();\n    let pinchDist = null;\n\n    function svgPoint(evt){\n      const svg = $('#map');\n      const pt = svg.createSVGPoint();\n      pt.x = evt.clientX; pt.y = evt.clientY;\n      const ctm = svg.getScreenCTM();\n      if (!ctm) return { x: 0, y: 0 };\n      const p = pt.matrixTransform(ctm.inverse());\n      return { x: p.x, y: p.y };\n    }\n    function applyTransform(){\n      const g = $('#viewport');\n      if (g) g.setAttribute('transform', `translate(${S.view.tx} ${S.view.ty}) scale(${S.view.scale})`);\n    }\n    function zoomAt(p, factor){\n      const next = Math.min(8, Math.max(0.15, S.view.scale * factor));\n      const k = next / S.view.scale;\n      S.view.tx = p.x - k * (p.x - S.view.tx);\n      S.view.ty = p.y - k * (p.y - S.view.ty);\n      S.view.scale = next;\n      applyTransform();\n    }\n    d2.on('.stage', 'wheel', e => { e.preventDefault(); zoomAt(svgPoint(e), e.deltaY < 0 ? 1.15 : 1/1.15); });\n    d2.on('.stage', 'pointerdown', (e, el) => {\n      pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });\n      if (pointers.size === 1) {\n        const p = svgPoint(e);\n        dragStart = { x: p.x, y: p.y, tx: S.view.tx, ty: S.view.ty, pointerId: e.pointerId, el, sx: e.clientX, sy: e.clientY };\n        dragMoved = false;\n      } else {\n        dragStart = null;\n        pinchDist = null;\n      }\n    });\n    d2.on(window, 'pointermove', e => {\n      if (!pointers.has(e.pointerId)) return;\n      pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });\n      if (pointers.size === 2) {\n        const pts = [...pointers.values()];\n        const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n        if (pinchDist) {\n          const mid = { clientX: (pts[0].x + pts[1].x) / 2, clientY: (pts[0].y + pts[1].y) / 2 };\n          zoomAt(svgPoint(mid), dist / pinchDist);\n          dragMoved = true;\n        }\n        pinchDist = dist;\n        return;\n      }\n      if (!dragStart) return;\n      const p = svgPoint(e);\n      const dx = p.x - dragStart.x, dy = p.y - dragStart.y;\n      // Capture the pointer only once real drag movement is detected. Capturing eagerly on\n      // every pointerdown (the previous behavior) redirects the native `click` event's target\n      // to the capturing element (a browser click-synthesis quirk with setPointerCapture), so a\n      // plain tap on a color wedge never reached its own onclick. Deferring capture until the\n      // gesture crosses the drag threshold lets a no-movement click hit-test normally.\n      //\n      // The threshold is measured in SCREEN pixels (clientX/clientY), not viewBox units: a mouse\n      // cursor is sub-pixel precise, but a real finger tap naturally drifts several pixels, and\n      // the old 0.5-viewBox-unit threshold (~0.6 screen px, shrinking further when zoomed in)\n      // misread almost every touch tap as a drag — silently swallowing the color's onClick.\n      // Only widen the tolerance when the instance opts into touch_controls — that param is\n      // already the explicit signal this instance is being driven by touch, so gate on it\n      // instead of sniffing e.pointerType (unreliable on hybrid/stylus devices anyway).\n      if (!dragMoved) {\n        const sdx = e.clientX - dragStart.sx, sdy = e.clientY - dragStart.sy;\n        const touchMode = !!d2.param('touch_controls', false);\n        const threshold = touchMode ? 10 : 4;\n        if (Math.hypot(sdx, sdy) > threshold) {\n          dragMoved = true;\n          try { dragStart.el.setPointerCapture?.(dragStart.pointerId); } catch (_) {}\n        }\n      }\n      if (dragMoved) {\n        S.view.tx = dragStart.tx + dx; S.view.ty = dragStart.ty + dy;\n        applyTransform();\n      }\n    });\n    d2.on(window, 'pointerup', e => { pointers.delete(e.pointerId); pinchDist = null; if (!pointers.size) dragStart = null; });\n    d2.on(window, 'pointercancel', e => { pointers.delete(e.pointerId); pinchDist = null; dragStart = null; });\n\n    function showInfo(desc, evt){\n      const touch = !!d2.param('touch_controls', false);\n      const sub = desc.color ? esc(desc.color.color_label || desc.color.color_id) : 'apenas contexto';\n      if (touch) {\n        $('#infobarText').textContent = `${desc.label}${desc.color ? ' · ' + sub : ''}`;\n        $('#infobar').classList.add('show');\n      } else {\n        const tip = $('#tip');\n        if (evt) { tip.style.left = (evt.clientX + 12) + 'px'; tip.style.top = (evt.clientY + 12) + 'px'; }\n        tip.innerHTML = `<b>${esc(desc.label)}</b>${desc.color ? '<br>' + sub : ''}`;\n        tip.style.opacity = 1;\n      }\n    }\n    function hideInfo(){ $('#tip').style.opacity = 0; }\n    $('#infobarClose').onclick = () => $('#infobar').classList.remove('show');\n\n    function addCountry(list, focus, usedUfs){\n      list.forEach((entry, ei) => {\n        if (!entry) return;\n        if (String(entry.location_id||'').trim().toUpperCase() !== 'BR') {\n          d2.log('multiescala: country_list só suporta BR por enquanto (' + entry.location_id + ')');\n          return;\n        }\n        const ufs = Object.keys(UF_CODE);\n        ufs.forEach(u => usedUfs.add(u));\n        const idxs = ufs.flatMap(u => IDX.byUfName[u] ? Object.values(IDX.byUfName[u]) : []);\n        if (!idxs.length) return;\n        focus.push({ key: `country-${ei}`, idxs, level: 'country', entry, label: entry.location_label || 'Brasil', colors: toArr(entry.location_colors) });\n      });\n    }\n    function addRegion(list, focus, usedUfs, usedRegions){\n      list.forEach((entry, ei) => {\n        if (!entry) return;\n        const rc = matchRegion(entry.location_id);\n        if (!rc) { d2.log('multiescala: region_list id não reconhecido (' + entry.location_id + ')'); return; }\n        usedRegions.add(rc);\n        REGION_UFS[rc].forEach(u => usedUfs.add(u));\n        const idxs = REGION_UFS[rc].flatMap(u => IDX.byUfName[u] ? Object.values(IDX.byUfName[u]) : []);\n        if (!idxs.length) return;\n        focus.push({ key: `region-${ei}`, idxs, level: 'region', entry, label: entry.location_label || REGION_NAMES[rc], colors: toArr(entry.location_colors) });\n      });\n    }\n    function addState(list, focus, usedUfs){\n      list.forEach((entry, ei) => {\n        if (!entry) return;\n        const uf = matchUf(entry.location_id);\n        if (!uf) { d2.log('multiescala: state_list id não reconhecido (' + entry.location_id + ')'); return; }\n        usedUfs.add(uf);\n        const idxs = IDX.byUfName[uf] ? Object.values(IDX.byUfName[uf]) : [];\n        if (!idxs.length) return;\n        focus.push({ key: `state-${ei}`, idxs, level: 'state', entry, label: entry.location_label || UF_NAMES[uf] || uf, colors: toArr(entry.location_colors) });\n      });\n    }\n    function addLeaf(list, level, focus, usedUfs){\n      list.forEach((entry, ei) => {\n        if (!entry) return;\n        const n = norm(entry.location_id);\n        const matches = IDX.anyName[n];\n        if (!matches || !matches.length) { d2.log('multiescala: ' + level + ' não encontrado (' + entry.location_id + ')'); return; }\n        const i = matches[0];\n        usedUfs.add(IDX.recs[i].uf);\n        focus.push({ key: `${level}-${ei}`, idxs: [i], level, entry, label: entry.location_label || IDX.recs[i].label, colors: toArr(entry.location_colors) });\n      });\n    }\n\n    function buildScene(){\n      const countryList = toArr(d2.param('country_list', []));\n      const regionList = toArr(d2.param('region_list', []));\n      const stateList = toArr(d2.param('state_list', []));\n      const cityList = toArr(d2.param('city_list', []));\n      const muniList = toArr(d2.param('municipality_list', []));\n      const wantNeighbors = !!d2.param('should_render_neighbors', false);\n\n      const focus = [];\n      const usedUfs = new Set();\n      const usedRegions = new Set();\n      const lowest = (cityList.length || muniList.length) ? 'leaf'\n        : stateList.length ? 'state'\n        : regionList.length ? 'region'\n        : countryList.length ? 'country' : null;\n\n      addCountry(countryList, focus, usedUfs);\n      addRegion(regionList, focus, usedUfs, usedRegions);\n      addState(stateList, focus, usedUfs);\n      addLeaf(cityList, 'city', focus, usedUfs);\n      addLeaf(muniList, 'municipality', focus, usedUfs);\n\n      const ctx = [];\n      const focusIdxSet = new Set(focus.flatMap(s => s.idxs));\n      if (wantNeighbors && lowest) {\n        if (lowest === 'leaf') {\n          const nbrIdx = new Set();\n          focus.filter(s => s.level === 'municipality' || s.level === 'city')\n            .forEach(s => IDX.nbrsOf(s.idxs[0]).forEach(j => { if (!focusIdxSet.has(j)) nbrIdx.add(j); }));\n          if (nbrIdx.size) ctx.push({ idxs: [...nbrIdx] });\n        } else if (lowest === 'state') {\n          const nbrUfs = new Set([...usedUfs].flatMap(u => UF_BORDERS[u] || []).filter(u => !usedUfs.has(u)));\n          nbrUfs.forEach(u => { if (IDX.byUfName[u]) ctx.push({ idxs: Object.values(IDX.byUfName[u]) }); });\n        } else if (lowest === 'region') {\n          const nbrRegions = new Set();\n          [...usedUfs].forEach(u => (UF_BORDERS[u] || []).forEach(b => {\n            const br = Object.keys(REGION_UFS).find(r => REGION_UFS[r].includes(b));\n            if (br && !usedRegions.has(br)) nbrRegions.add(br);\n          }));\n          nbrRegions.forEach(r => {\n            const idxs = REGION_UFS[r].flatMap(u => IDX.byUfName[u] ? Object.values(IDX.byUfName[u]) : []);\n            if (idxs.length) ctx.push({ idxs });\n          });\n        }\n      }\n\n      S.focus = focus; S.ctx = ctx;\n\n      if (!focus.length && !ctx.length) {\n        $('#empty').classList.remove('hidden');\n        $('#legend').classList.add('hidden');\n        $('#map').innerHTML = '';\n        return;\n      }\n      $('#empty').classList.add('hidden');\n\n      const zoomIdxs = focus.length ? focus.flatMap(s => s.idxs) : ctx.flatMap(s => s.idxs);\n      S.pr = project(zoomIdxs.map(i => IDX.recs[i].f));\n      S.view = { scale: 1, tx: 100, ty: 0 };\n      drawMap();\n    }\n\n    function drawMap(){\n      const svg = $('#map'); svg.innerHTML = '';\n      const viewport = document.createElementNS(NS, 'g');\n      viewport.setAttribute('id', 'viewport');\n      svg.appendChild(viewport);\n      applyTransform();\n      const touch = !!d2.param('touch_controls', false);\n      const labelFontSize = Math.max(4, Math.min(14, 9 * ((S.pr && S.pr.scale || 1000) / 1000)));\n\n      S.ctx.forEach((c, i) => {\n        const p = document.createElementNS(NS, 'path');\n        p.setAttribute('d', pathFromRings(dissolveRings(c.idxs, IDX.recs), S.pr));\n        p.setAttribute('data-key', 'ctx-' + i);\n        p.setAttribute('class', 'area');\n        p.setAttribute('fill', '#dcdfe8');\n        p.setAttribute('opacity', '.55');\n        viewport.appendChild(p);\n      });\n\n      const legendGroups = [];\n      S.focus.forEach(item => {\n        const rings = dissolveRings(item.idxs, IDX.recs);\n        const dFull = pathFromRings(rings, S.pr);\n        const base = document.createElementNS(NS, 'path');\n        base.setAttribute('d', dFull);\n        base.setAttribute('data-key', item.key + '-base');\n        base.setAttribute('class', 'area');\n        base.setAttribute('fill', item.colors.length ? '#eef0f5' : '#94a3b8');\n        base.onmousemove = e => showInfo({ label: item.label, color: null }, e);\n        base.onmouseleave = hideInfo;\n        viewport.appendChild(base);\n\n        if (item.colors.length) {\n          const [bx0, , bx1] = mergedBbox(item.idxs, S.pr, IDX.recs);\n          const n = item.colors.length;\n          const stripeW = (bx1 - bx0) / n;\n          item.colors.forEach((c, ci) => {\n            const d = stripeClipRings(rings, S.pr, bx0 + ci * stripeW, bx0 + (ci + 1) * stripeW);\n            if (!d) return;\n            const wedge = document.createElementNS(NS, 'path');\n            wedge.setAttribute('d', d);\n            wedge.setAttribute('data-key', item.key + '-w' + ci);\n            wedge.setAttribute('class', 'wedge' + (touch ? ' touchy' : ''));\n            wedge.setAttribute('fill', c.color || PALETTE[ci % PALETTE.length]);\n            wedge.onclick = (e) => {\n              if (dragMoved) { dragMoved = false; return; }\n              if (touch) showInfo({ label: item.label, color: c });\n              // The ONLY click entry point — a color with no `onClick` just isn't actionable.\n              // Augment the REAL click event (not a bare payload object) so APIs that need a\n              // genuine DOM event as their first arg (e.g. @__DialogsService, which walks\n              // $event.parentElement to find the modal mount point) still work, while\n              // `$event.color_id` etc. remain readable.\n              if (typeof c.onClick === 'function') {\n                c.onClick(Object.assign(e, {\n                  level: item.level,\n                  location_id: item.entry.location_id,\n                  location_label: item.entry.location_label,\n                  color_id: c.color_id,\n                  color_label: c.color_label,\n                  color: c.color,\n                }));\n              }\n            };\n            if (!touch) {\n              wedge.onmousemove = e => showInfo({ label: item.label, color: c }, e);\n              wedge.onmouseleave = hideInfo;\n            }\n            viewport.appendChild(wedge);\n          });\n        }\n\n        const [cx, cy] = item.idxs.length > 1 ? mergeCentroid(item.idxs, S.pr, IDX.recs) : cxy(IDX.recs[item.idxs[0]].f, S.pr);\n        const t = document.createElementNS(NS, 'text');\n        t.setAttribute('class', 'lbl'); t.setAttribute('x', cx); t.setAttribute('y', cy);\n        t.style.fontSize = labelFontSize + 'px';\n        t.textContent = (item.label || '').slice(0, 16);\n        viewport.appendChild(t);\n\n        if (item.colors.length) legendGroups.push({ entry: item.entry, items: item.colors });\n      });\n\n      renderLegend(legendGroups);\n    }\n    function renderLegend(groups){\n      const el = $('#legend');\n      if (!groups.length) { el.classList.add('hidden'); return; }\n      el.classList.remove('hidden');\n      el.innerHTML = groups.map(g => {\n        const rows = g.items.map(c => `<div class=\"row\"><b style=\"background:${esc(c.color)}\"></b>${esc(c.color_label || c.color_id)}</div>`).join('');\n        return `<div class=\"grp\"><div class=\"grpname\">${esc(g.entry.location_label || g.entry.location_id || '')}</div>${rows}</div>`;\n      }).join('');\n    }\n\n    async function load(){\n      loader(true, 'carregando malhas do Brasil (27 UFs)…', false);\n      try {\n        const ufs = Object.keys(UF_CODE);\n        const geos = await Promise.all(ufs.map(u => geoUF(u).catch(() => null)));\n        const allFeat = [];\n        ufs.forEach((u, i) => { const g = geos[i]; if (!g) return; g.features.forEach(f => { f.__uf = u; allFeat.push(f); }); });\n        if (!allFeat.length) throw new Error('nenhuma malha carregada');\n        IDX = buildIndex(allFeat);\n        buildScene();\n        loader(false);\n      } catch (e) {\n        loader(true, 'erro: ' + e.message + ' (clique p/ tentar de novo)', true);\n      }\n    }\n    $('#loader').onclick = () => { if (!$('#loader').classList.contains('err')) return; IDX = null; load(); };\n\n    load();\n  },\n};"}},"country_list":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"country_list"}}}},"region_list":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"region_list"}}}},"state_list":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"state_list"}}}},"city_list":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"city_list"}}}},"municipality_list":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"municipality_list"}}}},"should_render_neighbors":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"should_render_neighbors"}}}},"touch_controls":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"touch_controls"}}}}}}}},"extends":"data2-core-primitives@Block"}},{"_id":"6a7381e544b23b3f85949815","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"syKO63JC71F1tqr5T_HJx","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"export default {\n  css: `\n  :host{display:block;height:100%;--bg:#f4f4f8;--panel:#ffffff;--panel2:#f7f7fb;--line:#e7e7ef;--ink:#1c1c2b;--mut:#71717f;--acc:#c8104e; --closing:#c8104e;--ok:#1f9d57;--grad:linear-gradient(100deg,#c8104e,#8f0b39)}\n  *{box-sizing:border-box}\n  .simroot{position:relative;height:100%;overflow:auto;font:13.5px/1.45 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif;background:var(--bg);color:var(--ink)}\n  header{padding:14px 20px;background:var(--grad);color:#fff;display:flex;align-items:center;gap:12px;flex-wrap:wrap;box-shadow:0 2px 14px rgba(140,11,57,.25)}\n  header h1{font-size:16px;margin:0;font-weight:700;letter-spacing:.2px}\n  header select,header input{background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.3);color:#fff}\n  header select option{color:#1c1c2b}\n  header .status{color:rgba(255,255,255,.85)}\n  .howto{padding:9px 20px;background:#fff;border-bottom:1px solid var(--line);display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:var(--mut);align-items:center}\n  .howto b{color:var(--ink)} .howto span{max-width:340px}\n  .pill{font-size:11px;padding:3px 9px;border-radius:99px;border:1px solid rgba(255,255,255,.35);color:rgba(255,255,255,.92);background:rgba(255,255,255,.12)}\n  .pill.live{color:#fff}.pill.ro{color:#fff}\n  header button.act{background:#fff;color:var(--acc)}\n  header button.ghost{background:rgba(255,255,255,.14);color:#fff;border-color:rgba(255,255,255,.3)}\n  .status{font-size:11px;color:var(--mut)}.status.err{color:var(--closing)}\n  select,input,textarea,button{font:inherit}\n  select,input{background:var(--panel2);border:1px solid var(--line);color:var(--ink);border-radius:7px;padding:6px 8px}\n  button.act{background:var(--acc);color:#fff;border:0;border-radius:8px;padding:7px 12px;font-weight:700;cursor:pointer}\n  button.ghost{background:transparent;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 11px;cursor:pointer}\n  button.ghost.on{border-color:var(--acc);color:var(--acc)}\n  .wrap{display:grid;grid-template-columns:minmax(360px,1fr) 430px;gap:14px;padding:14px 18px;align-items:start}\n  @media(max-width:1150px){.wrap{grid-template-columns:1fr}}\n  .card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin-bottom:14px;box-shadow:0 1px 3px rgba(20,20,40,.05),0 6px 20px rgba(20,20,40,.04)}\n  .card h2{font-size:11.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin:0 0 9px;font-weight:700}\n  svg{width:100%;height:auto;display:block}\n  .muni{stroke:#0b0d12;stroke-width:1.1;cursor:pointer;transition:filter .1s}.muni:hover{filter:brightness(1.28)}\n  .muni.sel{stroke:#fff;stroke-width:2.3}\n  .nbr{fill:#e6e6ee;stroke:#cfcfda;stroke-width:.8;opacity:.95}\n  .mlabel{fill:#0d0f14;font-size:10px;font-weight:700;pointer-events:none;text-anchor:middle;paint-order:stroke;stroke:rgba(255,255,255,.55);stroke-width:2.4px}\n  .nlabel{fill:#6b7183;font-size:8.5px;pointer-events:none;text-anchor:middle}\n  .legend{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;font-size:12px}\n  .legend b{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:5px;vertical-align:-1px}\n  .kpis{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-bottom:12px}\n  .kpi{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:9px 11px}\n  .kpi .n{font-size:20px;font-weight:700}.kpi .l{font-size:10.5px;color:var(--mut);margin-top:2px}\n  .kpi.warn{border-color:#f0c2cb}.kpi.warn .n{color:var(--closing)}.kpi.ok{border-color:#bfe6cd}.kpi.ok .n{color:var(--ok)}\n  .bar-row{display:flex;align-items:center;gap:8px;margin:6px 0}\n  .bar-row .tag{width:42px;font-weight:700;font-size:12px}\n  .bar-track{flex:1;height:19px;background:var(--panel2);border-radius:5px;overflow:hidden;display:flex}\n  .bar-base{height:100%;opacity:.4}.bar-inc{height:100%}\n  .bar-val{width:132px;text-align:right;font-size:11px;color:var(--mut);font-variant-numeric:tabular-nums}\n  table{border-collapse:collapse;width:100%;font-size:11.5px}\n  th,td{padding:5px 6px;text-align:right;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}\n  th:first-child,td:first-child{text-align:left}\n  thead th{color:var(--mut);font-weight:600;font-size:10.5px;position:sticky;top:0;background:var(--panel)}\n  .home{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:4px}\n  .muni-item{display:flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:5px 8px;margin-bottom:6px}\n  .muni-item .nm{flex:1;font-weight:600;font-size:12px}.muni-item .ct{color:var(--mut);font-size:11px}\n  .seg{display:flex;border:1px solid var(--line);border-radius:7px;overflow:hidden}\n  .seg button{border:0;background:transparent;color:var(--mut);padding:4px 8px;font-size:11px;font-weight:700;cursor:pointer}\n  .seg button.on{color:#fff}\n  .toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}\n  .toggle{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;font-size:12px}\n  .toggle button{background:transparent;border:0;color:var(--mut);padding:6px 10px;cursor:pointer;font-weight:600}\n  .toggle button.on{background:var(--panel2);color:var(--ink)}\n  .note{color:var(--mut);font-size:10.5px;margin-top:7px;line-height:1.5}\n  .cell{font-variant-numeric:tabular-nums}\n  .scroll{max-height:240px;overflow:auto}\n  .tip{position:fixed;pointer-events:none;background:#000;color:#fff;border-radius:8px;padding:7px 9px;font-size:11px;opacity:0;transition:opacity .1s;z-index:9;max-width:230px}\n  .spark{display:flex;align-items:flex-end;gap:3px;height:44px}\n  .spark .b{flex:1;background:var(--acc);border-radius:2px 2px 0 0;min-height:2px;opacity:.85}\n  .delta{font-size:10px}.delta.up{color:var(--ok)}.delta.dn{color:var(--closing)}\n  .tabs{display:flex;gap:6px;margin-bottom:10px}\n  .tabs button{background:transparent;border:1px solid var(--line);border-radius:8px;color:var(--mut);padding:6px 12px;cursor:pointer;font-weight:600}\n  .tabs button.on{background:var(--panel2);color:var(--ink);border-color:var(--acc)}\n  .hidden{display:none}\n  .sqlbox{width:100%;height:60px;font-family:ui-monospace,Menlo,monospace;font-size:11px;resize:vertical}\n  .load{position:absolute;inset:0;background:rgba(248,248,251,.85);display:flex;align-items:center;justify-content:center;z-index:20;font-size:14px;color:var(--acc);font-weight:600}\n  .load.hidden{display:none}\n  .badge{font-size:10px;padding:1px 6px;border-radius:5px;background:var(--panel2);border:1px solid var(--line);color:var(--mut)}\n`,\n  render(d2){\n    return `<div class=\"simroot\"><header>\n  <h1>Simulador de Realocação de Setores</h1>\n  <select id=\"sectorPick\" style=\"min-width:230px\"></select>\n  <button class=\"ghost\" id=\"rand\">🎲 aleatório</button>\n  <button class=\"act\" id=\"go\">Fechar setor ▶</button>\n  <span class=\"pill live\" id=\"liveState\">● dado ao vivo</span>\n  <span class=\"pill ro\">read-only · nunca escreve</span>\n  <span class=\"status\" id=\"ds\"></span>\n</header>\n\n<div class=\"howto\">\n  <b>Como usar &nbsp;→&nbsp;</b>\n  <span><b>1.</b> Escolha um setor pra <b>fechar</b> (ou 🎲).</span>\n  <span><b>2.</b> O simulador puxa o dado real, acha os <b>setores vizinhos</b> (geografia, cruzando estados) e sugere o destino de cada município.</span>\n  <span><b>3.</b> Clique <b style=\"color:var(--acc)\">★ Modelo ÓTIMO</b> p/ minimizar quebra de relação, ou ajuste no <b>mapa</b>/botões.</span>\n  <span><b>4.</b> Veja o impacto <b>antes → depois</b> no relatório.</span>\n</div>\n<div class=\"wrap\">\n  <div>\n    <div class=\"card\">\n      <div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:9px\">\n        <h2 id=\"mapTitle\" style=\"margin:0\">Mapa — clique num município p/ trocar o setor destino</h2>\n        <div class=\"toggle\" id=\"viewToggle\" title=\"ASIS = estrutura atual (setor inteiro) · TOBE = após a redistribuição\">\n          <button data-v=\"asis\">◧ ASIS (hoje)</button>\n          <button data-v=\"tobe\" class=\"on\">◨ TOBE (simulação)</button>\n        </div>\n      </div>\n      <svg id=\"map\" viewBox=\"0 0 700 500\" preserveAspectRatio=\"xMidYMid meet\"></svg>\n      <div class=\"legend\" id=\"legend\"></div>\n      <div class=\"note\">Coloridos = municípios do setor fechado (cor = destino). Cinza = vizinhos (contexto). Shapes reais IBGE/tbrugz.</div>\n    </div>\n    <div class=\"card\">\n      <div class=\"tabs\">\n        <button data-tab=\"matrix\" class=\"on\">Matriz ASIS</button>\n        <button data-tab=\"report\">Relatório de impacto (6 ciclos · antes/depois)</button>\n        <button data-tab=\"sql\">Console SQL</button>\n      </div>\n      <div id=\"tab-matrix\"><div class=\"scroll\"><table id=\"matrix\"></table></div></div>\n      <div id=\"tab-report\" class=\"hidden\"></div>\n      <div id=\"tab-sql\" class=\"hidden\">\n        <textarea class=\"sqlbox\" id=\"sqlIn\">SELECT DS_MUNICIPIO, COUNT(DISTINCT CD_CONSULTORA) qtd FROM product.perfil_cb.crm_perfil_cb_diaria_historico WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=3504 AND DT_HISTORICO=(SELECT MAX(DT_HISTORICO) FROM product.perfil_cb.crm_perfil_cb_diaria_historico WHERE CD_PAIS=1) GROUP BY DS_MUNICIPIO ORDER BY qtd DESC</textarea>\n        <div class=\"toolbar\" style=\"margin:8px 0\"><button class=\"act\" id=\"runSql\">▶ Rodar</button><span class=\"status\" id=\"sqlStatus\"></span></div>\n        <div class=\"scroll\"><table id=\"sqlOut\"></table></div>\n      </div>\n    </div>\n  </div>\n\n  <div>\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"margin-bottom:11px\">\n        <button class=\"ghost\" id=\"optim\" title=\"testa todas alocações contíguas e escolhe a que equilibra a carga entre setores + minimiza a quebra (evita empilhar tudo num setor)\">★ Modelo ÓTIMO (equilíbrio + quebra)</button>\n        <button class=\"ghost\" id=\"autoTobe\" title=\"preenche cada município pelo setor vizinho mais próximo (geografia)\">TOBE por adjacência</button>\n        <span class=\"badge\" id=\"optBadge\"></span>\n      </div>\n      <div class=\"note\" style=\"margin:-4px 0 10px\"><b style=\"color:var(--ink)\">★ ÓTIMO</b> = equilibra a carga entre os setores <b>e</b> minimiza a quebra (contíguo; não empilha tudo num setor). <b style=\"color:var(--ink)\">TOBE</b> = sugestão por vizinhança. Indicadores atualizam ao vivo.</div>\n      <div class=\"kpis\" id=\"kpis\"></div>\n      <div id=\"contig\"></div>\n      <h2>Span por setor (CBs) — base + entrantes</h2>\n      <div id=\"spans\"></div>\n      <div style=\"display:flex;justify-content:space-between;align-items:center;margin:14px 0 7px\">\n        <h2 style=\"margin:0\">CBs deslocadas</h2>\n        <div class=\"toggle\" id=\"policy\">\n          <button data-p=\"fora\" class=\"on\">= fora do setor</button>\n          <button data-p=\"quebra\">= quebra de relação</button>\n        </div>\n      </div>\n      <div class=\"note\" id=\"policyNote\"></div>\n    </div>\n\n    <div class=\"card\">\n      <h2>Municípios → setor destino</h2>\n      <div class=\"note\" style=\"margin:-4px 0 9px\">Clique no <b>código do setor</b> pra mandar o município <b>inteiro</b> pra lá (regra de negócio). A cor acompanha o mapa.</div>\n      <div id=\"muniList\"></div>\n    </div>\n\n    <div class=\"card\">\n      <h2>Líderes — destino e CBs deslocadas</h2>\n      <div class=\"note\" style=\"margin:-4px 0 9px\"><b>Home</b> = setor que ficou com a maioria das CBs da líder. <b style=\"color:var(--closing)\">Deslocadas</b> = CBs dela que caíram noutro setor (viram “fora do setor” ou “quebra de relação”).</div>\n      <div class=\"scroll\"><table id=\"leaders\"></table></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"justify-content:space-between\">\n        <h2 style=\"margin:0\">Plano de migração</h2>\n        <div><button class=\"ghost\" id=\"copyPlan\">Copiar</button> <button class=\"ghost\" id=\"csvPlan\">CSV</button></div>\n      </div>\n      <div id=\"plan\" class=\"note\"></div>\n    </div>\n  </div>\n</div>\n\n<div class=\"tip\" id=\"tip\"></div>\n<div class=\"load hidden\" id=\"loader\">carregando…</div></div>`;\n  },\n  mounted(d2, root){\n\nconst T='product.perfil_cb.crm_perfil_cb_diaria_historico';\nconst ELO='product.elo_modelo_combinado.commercial_consultant_index';\nconst SNAP=`(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)`;\nconst PALETTE=['#8b7fd6','#d1b048','#67bd5b','#5aa9d6','#d67fa8','#c98a4b'];\nconst CLOSE_COLOR='#475569';\nconst UF_CODE={RO:'11',AC:'12',AM:'13',RR:'14',PA:'15',AP:'16',TO:'17',MA:'21',PI:'22',CE:'23',RN:'24',PB:'25',PE:'26',AL:'27',SE:'28',BA:'29',MG:'31',ES:'32',RJ:'33',SP:'35',PR:'41',SC:'42',RS:'43',MS:'50',MT:'51',GO:'52',DF:'53'};\nconst UF_BORDERS={AC:['AM','RO'],AL:['BA','PE','SE'],AM:['AC','RO','RR','PA','MT'],AP:['PA'],\n BA:['AL','SE','PE','PI','TO','GO','MG','ES'],CE:['PI','PB','PE','RN'],DF:['GO','MG'],\n ES:['BA','MG','RJ'],GO:['DF','MT','MS','MG','BA','TO'],MA:['PA','TO','PI'],\n MG:['BA','GO','DF','MS','SP','RJ','ES'],MS:['MT','GO','MG','SP','PR'],MT:['RO','AM','PA','TO','GO','MS'],\n PA:['AP','AM','MT','TO','MA','RR'],PB:['RN','CE','PE'],PE:['PB','CE','PI','BA','AL'],\n PI:['MA','TO','BA','PE','CE'],PR:['SP','MS','SC'],RJ:['ES','MG','SP'],RN:['CE','PB'],\n RO:['AC','AM','MT'],RR:['AM','PA'],RS:['SC'],SC:['PR','RS'],SE:['BA','AL'],\n SP:['MG','RJ','PR','MS'],TO:['PA','MA','PI','BA','GO','MT']};\nconst $=s=>root.querySelector(s);\nconst fmt=n=>n==null?'–':Math.round(+n).toLocaleString('pt-BR');\nconst money=n=>{n=+n||0;return n>=1e6?'R$'+(n/1e6).toFixed(1)+'M':n>=1e3?'R$'+(n/1e3).toFixed(0)+'k':'R$'+Math.round(n)};\nconst norm=s=>(s||'').normalize('NFKD').replace(/[̀-ͯ]/g,'').toUpperCase().replace(/[^A-Z0-9]+/g,' ').trim();\nconst NIVEIS=['Bronze','Cristal','Prata','Ouro','Diamante','Diamante+'];\n\nlet S={sector:null, matrix:[], munis:{}, leaders:[], color:{}, dest:[], assign:{}, allowed:{},\n       geoFeat:{}, nbrFeat:[], baseMetrics:{}, closingMetrics:{}, trend:[], levels:[], policy:'fora', selected:null, sectorsList:[], view:'tobe'};\nconst closingCbs=()=>Object.entries(S.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,[,o])=>a+o.cbs,0);\n\nasync function q(sql){ return await d2.rush('A0kvMekKfk4Yk0da7NLET').invoke({sql, skip_monitoring:true, country:'BR'}); }\nfunction rows(resp){const c=(resp.manifest?.schema?.columns||[]).map(x=>x.name);\n  return (resp.result?.data_array||[]).map(a=>Object.fromEntries(c.map((k,i)=>[k,a[i]])));}\nasync function geoUF(uf){const code=UF_CODE[uf]||uf;const r=await fetch('https://raw.githubusercontent.com/tbrugz/geodata-br/master/geojson/geojs-'+code+'-mun.json');if(!r.ok)throw new Error('geo '+uf);return r.json();}\nconst loader=(on,t)=>{const l=$('#loader');l.classList.toggle('hidden',!on);if(t)l.textContent=t;};\n\nfunction buildAdj(features){\n  const pts=f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates],s=new Set();\n    ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>s.add(Math.round(p[0]*1000)/1000+','+Math.round(p[1]*1000)/1000))));return s;};\n  const feat={},idx={};\n  features.forEach(f=>{const nm=norm(f.properties.name||f.properties.NOME||f.properties.nome);feat[nm]={f,pts:pts(f)};});\n  for(const nm in feat) feat[nm].pts.forEach(p=>(idx[p]=idx[p]||[]).push(nm));\n  const nbrs=nm=>{const c={};feat[nm].pts.forEach(p=>idx[p].forEach(o=>{if(o!==nm)c[o]=(c[o]||0)+1;}));\n    return Object.entries(c).filter(([,n])=>n>=2).map(([o,n])=>({o,w:n}));};\n  return {feat,nbrs};\n}\nfunction centroidLL(f){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  ps.forEach(poly=>poly[0].forEach(p=>{x+=p[0];y+=p[1];n++;}));return[x/n,y/n];}\n\nasync function closeSector(sec){\n  loader(true,'fechando setor '+sec+' — puxando dado real…');\n  $('#liveState').textContent='● consultando';$('#ds').classList.remove('err');\n  try{\n    S.sector=sec;\n    const mSQL=`SELECT DS_MUNICIPIO municipio, CD_GRUPO grupo, MAX(DS_LEADER_NAME) lider,\n       COUNT(DISTINCT CD_CONSULTORA) cbs, ROUND(SUM(VL_PEDIDOS_CICLO),0) receita,\n       ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat, ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon,\n       ROUND(SUM(QT_PONTOS_CICLO),0) pontos, ROUND(SUM(QT_PEDIDOS_CICLO),0) pedidos,\n       SUM(CASE WHEN ST_FEZ_PED_CICLO_ATUAL THEN 1 ELSE 0 END) ativas\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${sec} AND DT_HISTORICO=${SNAP}\n      GROUP BY DS_MUNICIPIO, CD_GRUPO`;\n    const ufSQL=`SELECT DS_ESTADO uf, COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${sec} AND DT_HISTORICO=${SNAP} GROUP BY DS_ESTADO ORDER BY n DESC`;\n    const trendSQL=`SELECT operational_cycle ciclo, COUNT(DISTINCT person_code) cbs,\n       COUNT(DISTINCT CASE WHEN registered_bc_ind=1 THEN person_code END) ativas,\n       ROUND(SUM(revenue_nat_ss_amt),0) receita, ROUND(SUM(points_nat_ss_amt),0) pontos\n      FROM ${ELO} WHERE country_code=1 AND sector_code=${sec} GROUP BY operational_cycle ORDER BY operational_cycle DESC LIMIT 8`;\n    const lvlSQL=`SELECT DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs,\n       ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat, ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${sec} AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY DS_NVL_PLAN_CRESC ORDER BY cbs DESC`;\n    const lvlMuniSQL=`SELECT DS_MUNICIPIO municipio, DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${sec} AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY DS_MUNICIPIO, DS_NVL_PLAN_CRESC`;\n    const [mR,ufR,tR,lR,lmR]=await Promise.all([q(mSQL),q(ufSQL),q(trendSQL),q(lvlSQL),q(lvlMuniSQL)]);\n    S.matrix=rows(mR).filter(r=>+r.cbs>0);\n    S.levelByMuni={}; rows(lmR).forEach(r=>{const k=norm(r.municipio);(S.levelByMuni[k]=S.levelByMuni[k]||{})[r.nivel]=+r.cbs;});\n    S.trend=rows(tR).map(r=>({...r,cbs:+r.cbs,ativas:+r.ativas,receita:+r.receita,pontos:+r.pontos})).filter(r=>r.cbs>10).reverse();\n    S.levels=rows(lR);\n    const ufList=rows(ufR).map(r=>r.uf).filter(Boolean);\n    const domUF=ufList[0]||'PR';\n\n    const ufs=[domUF,...(UF_BORDERS[domUF]||[])];\n    loader(true,'malhas '+ufs.join('/')+' + adjacência…');\n    const geos=await Promise.all(ufs.map(u=>geoUF(u).catch(()=>null)));\n    const allFeat=geos.filter(Boolean).flatMap(g=>g.features);\n    const adj=buildAdj(allFeat);\n    const totBy={}; S.matrix.forEach(r=>{const n=norm(r.municipio);totBy[n]=(totBy[n]||0)+ +r.cbs;});\n    const totalSec=Object.values(totBy).reduce((a,b)=>a+b,0);\n    const floor=Math.max(10, totalSec*0.008);\n    const footprint=new Set(Object.entries(totBy).filter(([,c])=>c>=floor).sort((a,b)=>b[1]-a[1]).slice(0,15).map(([m])=>m));\n    const closingNames=[...footprint].filter(n=>adj.feat[n]);\n    const closingSet=new Set(closingNames);\n    S.geoFeat={};closingNames.forEach(n=>S.geoFeat[n]=adj.feat[n].f);\n    const extVotes={}; const nbrNames=new Set();\n    closingNames.forEach(n=>{adj.nbrs(n).forEach(({o,w})=>{ if(!closingSet.has(o)){ nbrNames.add(o); (extVotes[n]=extVotes[n]||[]).push({o,w}); } });});\n    S.nbrFeat=[...nbrNames].map(n=>adj.feat[n].f);\n    let neigh=[];\n    if(nbrNames.size){\n      const inList=[...nbrNames].map(n=>`'${n.replace(/'/g,\"''\")}'`).join(',');\n      const nSQL=`SELECT DS_MUNICIPIO municipio, CD_SETOR setor, COUNT(DISTINCT CD_CONSULTORA) cbs\n        FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR<>${sec} AND CD_SETOR>0 AND DT_HISTORICO=${SNAP} AND DS_MUNICIPIO IN (${inList}) GROUP BY DS_MUNICIPIO, CD_SETOR`;\n      neigh=rows(await q(nSQL));\n    }\n    const domSecOf={};\n    const byM={}; neigh.forEach(r=>{const m=norm(r.municipio);(byM[m]=byM[m]||[]).push(r);});\n    Object.entries(byM).forEach(([m,rs])=>{rs.sort((a,b)=>+b.cbs-+a.cbs);domSecOf[m]=String(rs[0].setor);});\n    S.allowed={}; const secWeight={};\n    closingNames.forEach(n=>{const v={};(extVotes[n]||[]).forEach(({o,w})=>{const s=domSecOf[o];if(s){v[s]=(v[s]||0)+w;secWeight[s]=(secWeight[s]||0)+w;}});S.allowed[n]=v;});\n    S.dest=Object.keys(secWeight).sort((a,b)=>secWeight[b]-secWeight[a]).slice(0,6);\n    if(!S.dest.length){\n      S.dest=[...new Set(Object.values(domSecOf))].slice(0,6);\n      closingNames.forEach(n=>{if(!Object.keys(S.allowed[n]).length){S.dest.forEach(s=>S.allowed[n][s]=1);}});\n    }\n    S.color={};S.dest.forEach((s,i)=>S.color[s]=PALETTE[i%PALETTE.length]);\n    const acc={};Object.entries(domSecOf).forEach(([nb,s])=>{if(!S.dest.includes(s))return;const f=adj.feat[nb]?.f;if(!f)return;const c=centroidLL(f);acc[s]=acc[s]||[0,0,0];acc[s][0]+=c[0];acc[s][1]+=c[1];acc[s][2]++;});\n    S.secCentroid={};S.dest.forEach(s=>{if(acc[s])S.secCentroid[s]=[acc[s][0]/acc[s][2],acc[s][1]/acc[s][2]];});\n    S.muniCentroid={};closingNames.forEach(n=>{S.muniCentroid[n]=centroidLL(adj.feat[n].f);});\n    S.closeAdj={};closingNames.forEach(n=>{S.closeAdj[n]=adj.nbrs(n).filter(({o})=>closingSet.has(o)).map(({o})=>o);});\n    const comp={};let ci=0;closingNames.forEach(n=>{if(comp[n]!=null)return;const st=[n];comp[n]=ci;while(st.length){const x=st.pop();(S.closeAdj[x]||[]).forEach(o=>{if(comp[o]==null){comp[o]=ci;st.push(o);}});}ci++;});\n    const compAllowed={};closingNames.forEach(n=>{const cid=comp[n];(compAllowed[cid]=compAllowed[cid]||new Set());Object.keys(S.allowed[n]||{}).forEach(s=>compAllowed[cid].add(s));});\n    S.reachable={};closingNames.forEach(n=>{S.reachable[n]=[...(compAllowed[comp[n]]||[])].filter(s=>S.dest.includes(s));});\n\n    loader(true,'métricas base dos setores receptores…');\n    const rList=S.dest.join(',');\n    const bSQL=`SELECT CD_SETOR setor, COUNT(DISTINCT CD_CONSULTORA) cbs,\n       SUM(CASE WHEN ST_FEZ_PED_CICLO_ATUAL THEN 1 ELSE 0 END) ativas,\n       ROUND(SUM(VL_PEDIDOS_CICLO),0) receita, ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat,\n       ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon, ROUND(SUM(QT_PONTOS_CICLO),0) pontos, ROUND(SUM(QT_PEDIDOS_CICLO),0) pedidos\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${rList}) AND DT_HISTORICO=${SNAP} GROUP BY CD_SETOR`;\n    const blSQL=`SELECT CD_SETOR setor, DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${rList}) AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY CD_SETOR, DS_NVL_PLAN_CRESC`;\n    const [bR,blR]=await Promise.all([q(bSQL),q(blSQL)]);\n    S.baseMetrics={};rows(bR).forEach(r=>S.baseMetrics[String(r.setor)]=r);\n    S.baseLevel={};rows(blR).forEach(r=>{const s=String(r.setor);(S.baseLevel[s]=S.baseLevel[s]||{})[r.nivel]=+r.cbs;});\n\n    aggregate();\n    S.assign=optimize();\n    S.selected=null;\n    const dt=new Date().toLocaleTimeString('pt-BR');\n    $('#ds').textContent=`setor ${sec} · UF ${domUF} · ${closingNames.length} munic. · atualizado ${dt}`;\n    $('#liveState').textContent='● dado ao vivo';\n    $('#mapTitle').textContent=`Fechando setor ${sec} — clique num município p/ trocar destino`;\n    renderAll();\n  }catch(e){ $('#ds').textContent='erro: '+e.message; $('#ds').classList.add('err'); $('#liveState').textContent='● erro'; }\n  finally{ loader(false); }\n}\n\nfunction aggregate(){\n  const munis={}, leaders={};\n  const acc=(o,r)=>{o.cbs+=+r.cbs;o.receita+=+r.receita||0;o.rec_nat+=+r.rec_nat||0;o.rec_avon+=+r.rec_avon||0;o.pontos+=+r.pontos||0;o.pedidos+=+r.pedidos||0;o.ativas+=+r.ativas||0;};\n  const blank=()=>({cbs:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0,ativas:0});\n  S.matrix.forEach(r=>{\n    const m=norm(r.municipio); if(!S.geoFeat[m]) {r._out=true;}\n    const mm=r._out?'__OUTROS__':m;\n    munis[mm]=munis[mm]||{...blank(),name:mm}; acc(munis[mm],r);\n    const g=String(r.grupo); leaders[g]=leaders[g]||{grupo:g,lider:r.lider,total:0,byMuni:{}};\n    leaders[g].total+=+r.cbs; leaders[g].byMuni[mm]=(leaders[g].byMuni[mm]||0)+ +r.cbs;\n  });\n  S.munis=munis; S.leaders=Object.values(leaders).sort((a,b)=>b.total-a.total);\n}\nfunction autoAssign(){\n  const a={};\n  Object.keys(S.munis).forEach(m=>{ if(m==='__OUTROS__')return;\n    const v=S.allowed[m]||{}; const votes=Object.keys(v);\n    if(votes.length){ let best=votes[0],bw=-1; for(const s of votes) if(v[s]>bw){bw=v[s];best=s;} a[m]=best; }\n    else { const c=S.muniCentroid[m]; let best=S.dest[0],bd=Infinity;\n      for(const s of S.dest){ const sc=S.secCentroid[s]; if(!sc)continue; const d=(c[0]-sc[0])**2+(c[1]-sc[1])**2; if(d<bd){bd=d;best=s;} }\n      a[m]=best; }\n  });\n  return a;\n}\nfunction deslocFor(assign){\n  let d=0;\n  S.leaders.forEach(L=>{const byS={};let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=assign[m];if(s){byS[s]=(byS[s]||0)+c;tot+=c;}}\n    const home=S.dest.reduce((a,s)=>(byS[s]||0)>(byS[a]||0)?s:a,S.dest[0]);\n    d+=tot-(byS[home]||0);});\n  return d;\n}\nfunction spanSpread(assign){\n  const inc={};S.dest.forEach(s=>inc[s]=0);\n  Object.entries(S.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;if(assign[m])inc[assign[m]]+=o.cbs;});\n  const sp=S.dest.map(s=>(+ (S.baseMetrics[s]?.cbs||0))+inc[s]);\n  return Math.max(...sp)-Math.min(...sp);\n}\nfunction contigBad(assign){\n  const bySec={}; for(const m in assign){(bySec[assign[m]]=bySec[assign[m]]||[]).push(m);}\n  const bad=[];\n  for(const s in bySec){const grp=bySec[s],inGrp=new Set(grp);\n    const seeds=grp.filter(m=>(S.allowed[m]||{})[s]);\n    if(!seeds.length){bad.push(s);continue;}\n    const seen=new Set(seeds),st=[...seeds];\n    while(st.length){const m=st.pop();(S.closeAdj[m]||[]).forEach(o=>{if(inGrp.has(o)&&!seen.has(o)){seen.add(o);st.push(o);}});}\n    if(seen.size!==grp.length)bad.push(s);}\n  return bad;\n}\nfunction maxSpanOf(a){const inc={};S.dest.forEach(s=>inc[s]=0);Object.entries(S.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;if(a[m])inc[a[m]]+=o.cbs;});return Math.max(...S.dest.map(s=>(+ (S.baseMetrics[s]?.cbs||0))+inc[s]));}\nconst OPT_K=1.5;\nfunction optScore(a){return maxSpanOf(a)+OPT_K*deslocFor(a);}\nfunction optimize(){\n  const ms=Object.keys(S.munis).filter(m=>m!=='__OUTROS__');\n  const opts=ms.map(m=>{const al=(S.reachable[m]&&S.reachable[m].length)?S.reachable[m]:S.dest.slice();return al;});\n  const combos=opts.reduce((a,o)=>a*o.length,1);\n  if(combos>300000){ $('#optBadge').textContent='espaço grande ('+fmt(combos)+') — heurística'; return greedy(ms,opts); }\n  let best=null,bestSc=Infinity,bestD=Infinity,valid=0;\n  const rec=(i,cur)=>{ if(i===ms.length){ if(contigBad(cur).length) return; valid++; const sc=optScore(cur),d=deslocFor(cur); if(sc<bestSc||(sc===bestSc&&d<bestD)){bestSc=sc;bestD=d;best={...cur};}return;}\n    for(const s of opts[i]){cur[ms[i]]=s;rec(i+1,cur);} };\n  rec(0,{});\n  if(!best){ $('#optBadge').textContent='sem solução contígua — heurística'; return greedy(ms,opts); }\n  $('#optBadge').textContent=`ótimo · pico ${fmt(maxSpanOf(best))} CBs · quebra ${fmt(bestD)} · ${fmt(valid)}/${fmt(combos)} contíguas`;\n  return best;\n}\nfunction greedy(ms,opts){const a={};ms.forEach((m,i)=>{let best=opts[i][0],bd=Infinity;for(const s of opts[i]){a[m]=s;const d=optScore(a)+(contigBad(a).length?1e9:0);if(d<bd){bd=d;best=s;}}a[m]=best;});return a;}\n\nfunction compute(){\n  const inc={},gained={};S.dest.forEach(s=>{inc[s]=0;gained[s]=0;});\n  Object.entries(S.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=S.assign[m];if(s)inc[s]+=o.cbs;});\n  let desloc=0;\n  const L2=S.leaders.map(L=>{const byS={};S.dest.forEach(s=>byS[s]=0);let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=S.assign[m];if(s){byS[s]+=c;tot+=c;}}\n    const home=S.dest.reduce((a,s)=>byS[s]>byS[a]?s:a,S.dest[0]);const dl=tot-byS[home];desloc+=dl;gained[home]++;\n    return {...L,byS,home,desloc:dl,assignedTotal:tot};});\n  const span={};S.dest.forEach(s=>span[s]=(+ (S.baseMetrics[s]?.cbs||0))+inc[s]);\n  return {inc,gained,desloc,leaders:L2,span};\n}\nfunction renderAll(){drawMap();renderKpis();renderContig();renderSpans();renderMuni();renderLeaders();renderMatrix();renderPolicy();renderReport();renderPlan();}\nfunction renderContig(){const el=$('#contig');if(!el)return;\n  if(S.view==='asis'){el.innerHTML='';return;}\n  const bad=contigBad(S.assign);\n  el.innerHTML=bad.length\n    ?`<div class=\"note\" style=\"color:var(--closing);font-weight:600;margin:-2px 0 6px\">⚠ Contiguidade quebrada em ${bad.join(', ')} — município(s) desconectado(s) do território do setor. Use ★ ÓTIMO ou reassocie no mapa.</div>`\n    :`<div class=\"note\" style=\"color:var(--ok);margin:-2px 0 6px\">✓ Contíguo — cada setor forma uma região conectada.</div>`;\n}\nfunction renderKpis(){const c=compute();\n  if(S.view==='asis'){const mc=Object.keys(S.munis).filter(m=>m!=='__OUTROS__').length;\n    $('#kpis').innerHTML=[\n      {n:fmt(closingCbs()),l:`CBs no setor ${S.sector}`,t:'Total de CBs cadastradas no setor hoje (estrutura atual).'},\n      {n:fmt(mc),l:'Municípios do setor',t:'Municípios que compõem o território do setor hoje.'},\n      {n:fmt(S.leaders.length),l:'Líderes (grupos)',t:'Líderes ativas dentro do setor hoje.'},\n      {n:fmt(S.dest.length),l:'Setores adjacentes',t:'Setores vizinhos que poderiam receber os municípios se o setor fechar.'},\n    ].map(k=>`<div class=\"kpi\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n    return;}\n  const sv=S.dest.map(s=>c.span[s]);const spread=Math.max(...sv)-Math.min(...sv);\n  const zero=S.dest.filter(s=>c.gained[s]===0);const totCbs=Object.entries(S.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,[,o])=>a+o.cbs,0);\n  $('#kpis').innerHTML=[\n    {n:fmt(c.desloc),l:S.policy==='fora'?'CBs fora do setor':'CBs c/ quebra de relação',cls:'warn',t:'CBs cuja líder acabou noutro setor. Menor = menos ruptura. É o custo humano da migração.'},\n    {n:fmt(spread),l:'Desequilíbrio (máx−mín CBs)',cls:spread>800?'warn':'ok',t:'Diferença de tamanho entre o maior e o menor setor depois. Menor = carga mais equilibrada entre as GNs.'},\n    {n:fmt(totCbs),l:'CBs realocadas',cls:'',t:`Total de CBs do setor ${S.sector} sendo redistribuídas para os setores vizinhos.`},\n    {n:zero.length?zero.join(', '):'nenhum',l:'Setor sem líder herdada',cls:zero.length?'warn':'ok',t:'Setor que recebe município(s) mas nenhuma líder — as CBs de lá terão que se ligar a uma líder pré-existente do setor.'},\n  ].map(k=>`<div class=\"kpi ${k.cls}\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n}\nfunction renderSpans(){const c=compute();\n  if(S.view==='asis'){const items=[{s:S.sector,val:closingCbs(),color:CLOSE_COLOR}].concat(S.dest.map(s=>({s,val:+(S.baseMetrics[s]?.cbs||0),color:S.color[s]})));\n    const max=Math.max(...items.map(x=>x.val),1);\n    $('#spans').innerHTML=items.map(x=>`<div class=\"bar-row\"><div class=\"tag\" style=\"color:${x.color}\">${x.s}</div><div class=\"bar-track\">\n      <div class=\"bar-inc\" style=\"width:${x.val/max*100}%;background:${x.color}\"></div></div>\n      <div class=\"bar-val\">${fmt(x.val)}</div></div>`).join('')\n      +`<div class=\"note\">Estrutura atual — o setor <b>${S.sector}</b> ainda existe. Alterne pra <b>TOBE</b> pra ver a redistribuição.</div>`;\n    return;}\n  const max=Math.max(...S.dest.map(s=>c.span[s]),1);\n  $('#spans').innerHTML=S.dest.map(s=>{const base=+(S.baseMetrics[s]?.cbs||0),i=c.inc[s];\n    return `<div class=\"bar-row\"><div class=\"tag\" style=\"color:${S.color[s]}\">${s}</div><div class=\"bar-track\">\n      <div class=\"bar-base\" style=\"width:${base/max*100}%;background:${S.color[s]}\"></div>\n      <div class=\"bar-inc\" style=\"width:${i/max*100}%;background:${S.color[s]}\"></div></div>\n      <div class=\"bar-val\">${fmt(c.span[s])} <span style=\"opacity:.6\">(+${fmt(i)})</span></div></div>`;}).join('')\n    +`<div class=\"note\">Claro = base atual · cheio = CBs entrantes do ${S.sector}.</div>`;\n}\nfunction renderMuni(){const c=compute();const ms=Object.keys(S.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>S.munis[b].cbs-S.munis[a].cbs);\n  if(S.view==='asis'){$('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(S.munis[m].cbs)} CBs</div><span class=\"badge\" style=\"background:${CLOSE_COLOR};color:#fff;border-color:${CLOSE_COLOR}\">setor ${S.sector}</span></div>`).join('')\n      +`<div class=\"note\">Hoje todos pertencem ao setor <b>${S.sector}</b>. No <b>TOBE</b> cada um vira botões de destino.</div>`;return;}\n  $('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(S.munis[m].cbs)} CBs</div>\n    <div class=\"seg\" data-m=\"${m}\">${S.dest.map(s=>`<button data-s=\"${s}\" class=\"${S.assign[m]===s?'on':''}\" style=\"${S.assign[m]===s?'background:'+S.color[s]:''}\">${s}</button>`).join('')}</div></div>`).join('')\n    +(S.munis.__OUTROS__?`<div class=\"note\">+ ${fmt(S.munis.__OUTROS__.cbs)} CBs em municípios sem shape na UF (tratar à parte).</div>`:'');\n  $('#muniList').querySelectorAll('.seg button').forEach(b=>b.onclick=()=>{S.assign[b.parentElement.dataset.m]=b.dataset.s;renderAll();});\n}\nfunction renderLeaders(){const c=compute();\n  if(S.view==='asis'){$('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>Grupo</th><th>CBs</th><th>Setor</th></tr></thead><tbody>`\n    +S.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${L.grupo}</td><td>${fmt(L.total)}</td><td><span class=\"home\" style=\"background:${CLOSE_COLOR}\"></span>${S.sector}</td></tr>`).join('')\n    +`<tr style=\"font-weight:700\"><td>TOTAL</td><td></td><td>${fmt(S.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td></tr></tbody>`;return;}\n  $('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>CBs</th><th>Home</th>${S.dest.map(s=>`<th>${s}</th>`).join('')}<th>Desloc.</th></tr></thead><tbody>`\n   +c.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${fmt(L.total)}</td>\n     <td><span class=\"home\" style=\"background:${S.color[L.home]}\"></span>${L.home}</td>\n     ${S.dest.map(s=>`<td class=\"cell\" style=\"${s===L.home?'color:'+S.color[s]+';font-weight:700':''}\">${L.byS[s]||'·'}</td>`).join('')}\n     <td style=\"color:${L.desloc?'var(--closing)':'var(--mut)'};font-weight:${L.desloc?700:400}\">${L.desloc||'0'}</td></tr>`).join('')\n   +`<tr style=\"font-weight:700\"><td>TOTAL</td><td>${fmt(c.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td>${S.dest.map(s=>`<td>${fmt(c.leaders.reduce((a,L)=>a+L.byS[s],0))}</td>`).join('')}<td style=\"color:var(--closing)\">${fmt(c.desloc)}</td></tr></tbody>`;\n}\nfunction renderMatrix(){const c=compute();const ms=Object.keys(S.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>S.munis[b].cbs-S.munis[a].cbs);\n  const cell=(m,g)=>{const r=S.matrix.find(x=>(x._out?'__OUTROS__':norm(x.municipio))===m&&String(x.grupo)===g);return r?+r.cbs:0;};\n  const mx=Math.max(...ms.flatMap(m=>S.leaders.map(L=>cell(m,L.grupo))),1);\n  $('#matrix').innerHTML=`<thead><tr><th>Município</th><th>→</th>${S.leaders.map(L=>`<th title=\"${L.lider}\">${L.grupo}</th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +ms.map(m=>`<tr><td>${m}</td><td><span class=\"home\" style=\"background:${S.color[S.assign[m]]}\"></span>${S.assign[m]||'–'}</td>`\n     +S.leaders.map(L=>{const v=cell(m,L.grupo);return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v||'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(S.munis[m].cbs)}</td></tr>`).join('')+`</tbody>`;\n}\nfunction renderPolicy(){const c=compute();\n  $('#policyNote').innerHTML=S.policy==='fora'\n    ?`<b style=\"color:var(--acc)\">Mantendo vínculo:</b> ${fmt(c.desloc)} CBs seguem com a líder mas ficam <b>fisicamente noutro setor</b>. Preserva relação; suja geografia.`\n    :`<b style=\"color:var(--acc)\">Quebrando vínculo:</b> ${fmt(c.desloc)} CBs <b>re-vinculam a uma líder do setor destino</b>. Geografia limpa; rompe relação.`;\n}\nfunction renderPlan(){const c=compute();const ms=Object.keys(S.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>a.localeCompare(b));\n  S._plan=ms.map(m=>({municipio:m,cbs:S.munis[m].cbs,destino:S.assign[m]}));\n  $('#plan').innerHTML=`Setor <b>${S.sector}</b> → ${ms.map(m=>`${m}→<b style=\"color:${S.color[S.assign[m]]}\">${S.assign[m]}</b>`).join(' · ')}<br>\n   Política: <b>${S.policy==='fora'?'manter vínculo (CBs fora do setor)':'quebrar relação'}</b> · ${fmt(c.desloc)} CBs impactadas.`;\n}\nfunction renderReport(){const c=compute();\n  S.metric=S.metric||'cbs';\n  const metrics=[['cbs','CBs'],['ativas','Ativas'],['pedidos','Pedidos'],['pontos','Pontos'],['receita','Receita','$'],['rec_nat','Rec. Natura','$'],['rec_avon','Rec. Avon','$']];\n  const KEYS=metrics.map(m=>m[0]);\n  const incBy={};S.dest.forEach(s=>incBy[s]=Object.fromEntries(KEYS.map(k=>[k,0])));\n  Object.entries(S.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=S.assign[m];if(!s)return;KEYS.forEach(k=>incBy[s][k]+=o[k]||0);});\n  const closeTot={};KEYS.forEach(k=>closeTot[k]=Object.entries(S.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,[,o])=>a+(o[k]||0),0));\n  const trend=S.trend.slice(-6);\n  const chartHTML=mk=>{\n    const isM=['receita','rec_nat','rec_avon'].includes(mk); const fN=isM?money:fmt;\n    const data=S.dest.map(s=>({label:s,color:S.color[s],antes:+(S.baseMetrics[s]?.[mk]||0),depois:+(S.baseMetrics[s]?.[mk]||0)+(incBy[s]?.[mk]||0)}));\n    data.push({label:S.sector+'✕',color:'#c8104e',antes:closeTot[mk]||0,depois:0});\n    const max=Math.max(...data.flatMap(d=>[d.antes,d.depois]),1);\n    const W=720,H=290,base=238,top=42,maxH=base-top,n=data.length,gw=(W-16)/n;\n    let g='';\n    data.forEach((d,i)=>{const cx=8+gw*i+gw/2,bw=Math.min(30,gw*0.30);\n      const ah=d.antes/max*maxH,dh=d.depois/max*maxH,ax=cx-bw-2,dx=cx+2;\n      g+=`<rect x=\"${ax.toFixed(1)}\" y=\"${(base-ah).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${ah.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\" opacity=\"0.35\"/>`;\n      g+=`<rect x=\"${dx.toFixed(1)}\" y=\"${(base-dh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${dh.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\"/>`;\n      g+=`<text x=\"${(ax+bw/2).toFixed(1)}\" y=\"${(base-ah-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9\" fill=\"#9a9aa8\">${fN(d.antes)}</text>`;\n      g+=`<text x=\"${(dx+bw/2).toFixed(1)}\" y=\"${(base-dh-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9.5\" font-weight=\"700\" fill=\"${d.color}\">${fN(d.depois)}</text>`;\n      const dp=d.antes?((d.depois-d.antes)/d.antes*100):(d.depois?100:0);\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"16\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"${dp>=0?'#1f9d57':'#c8104e'}\">${dp>0?'+':''}${dp.toFixed(0)}%</text>`;\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"${base+16}\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"#1c1c2b\">${d.label}</text>`;});\n    return `<svg viewBox=\"0 0 ${W} ${H}\" style=\"width:100%\"><line x1=\"8\" y1=\"${base}\" x2=\"${W-8}\" y2=\"${base}\" stroke=\"#e7e7ef\"/>${g}</svg>`;\n  };\n  let h=`<div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap\">\n      <h2 style=\"margin:0\">Impacto antes → depois por setor</h2>\n      <select id=\"metricSel\">${metrics.map(m=>`<option value=\"${m[0]}\" ${m[0]===S.metric?'selected':''}>${m[1]}</option>`).join('')}</select></div>\n    <div class=\"note\" style=\"margin:2px 0 4px\">Barra clara = <b>antes</b> · barra cheia = <b>depois</b> (base + entrantes do ${S.sector}) · <b style=\"color:var(--acc)\">${S.sector}✕</b> = setor que fecha · % = variação</div>\n    ${chartHTML(S.metric)}`;\n  h+=`<h2 style=\"margin-top:16px\">Antes → Depois por setor receptor (snapshot atual)</h2>\n    <div class=\"scroll\"><table><thead><tr><th>Setor</th>${metrics.map(m=>`<th>${m[1]}</th>`).join('')}</tr></thead><tbody>`;\n  S.dest.forEach(s=>{const b=S.baseMetrics[s]||{};const inc=incBy[s];\n    h+=`<tr><td><span class=\"home\" style=\"background:${S.color[s]}\"></span>${s}</td>`+\n      metrics.map(([k])=>{const bv=+b[k]||0,av=bv+ (inc[k]||0);const d=bv?((av-bv)/bv*100):0;const f=metrics.find(x=>x[0]===k)[2]==='$'?money:fmt;\n        return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;}).join('')+`</tr>`;});\n  h+=`<tr style=\"opacity:.8\"><td>${S.sector} (sai)</td>`+metrics.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(closeTot[k]||0)}</td>`).join('')+`</tr>`;\n  h+=`</tbody></table></div>`;\n  const incLvl={};S.dest.forEach(s=>incLvl[s]={});\n  Object.keys(S.munis).forEach(m=>{if(m==='__OUTROS__')return;const s=S.assign[m];if(!s)return;const lv=S.levelByMuni[m]||{};for(const nv in lv)incLvl[s][nv]=(incLvl[s][nv]||0)+lv[nv];});\n  h+=`<h2 style=\"margin-top:18px\">Consultoras por nível — antes → depois por setor receptor</h2>\n    <div class=\"scroll\"><table><thead><tr><th>Setor</th>${NIVEIS.map(n=>`<th>${n}</th>`).join('')}<th>Total</th></tr></thead><tbody>`;\n  S.dest.forEach(s=>{const base=S.baseLevel[s]||{},inc=incLvl[s]||{};let tb=0,ta=0;\n    h+=`<tr><td><span class=\"home\" style=\"background:${S.color[s]}\"></span>${s}</td>`+\n      NIVEIS.map(nv=>{const b=base[nv]||0,i=inc[nv]||0,a=b+i;tb+=b;ta+=a;\n        return `<td>${a?fmt(a):'·'}${i?`<div class=\"delta up\">+${fmt(i)}</div>`:''}</td>`;}).join('')+\n      `<td style=\"font-weight:700\">${fmt(ta)}${ta-tb?`<div class=\"delta up\">+${fmt(ta-tb)}</div>`:''}</td></tr>`;});\n  h+=`</tbody></table></div><div class=\"note\">Número = CBs por nível <b>depois</b> da migração · <span style=\"color:var(--ok)\">+N</span> = entrantes do ${S.sector}. Base + entrantes por nível.</div>`;\n  const spk=(key)=>{const vals=trend.map(t=>+t[key]||0);const mx=Math.max(...vals,1);\n    return `<div class=\"spark\">${vals.map(v=>`<div class=\"b\" style=\"height:${(v/mx*100).toFixed(0)}%\" title=\"${fmt(v)}\"></div>`).join('')}</div>\n      <div class=\"note\">${trend.map(t=>String(t.ciclo).slice(-2)).join(' · ')}</div>`;};\n  h+=`<h2 style=\"margin-top:18px\">Setor ${S.sector} — últimos ${trend.length} ciclos (tendência)</h2>\n    <div class=\"note\" style=\"margin:-4px 0 8px\">Como o setor que fecha vinha performando — pra dimensionar o que está sendo redistribuído.</div>\n    <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:10px\">\n    ${[['cbs','CBs'],['ativas','Ativas'],['receita','Receita'],['pontos','Pontos']].map(([k,l])=>`<div><div class=\"note\" style=\"margin:0 0 3px;font-weight:600;color:var(--ink)\">${l}</div>${spk(k)}</div>`).join('')}</div>`;\n  $('#tab-report').innerHTML=h;\n  const sel=$('#metricSel'); if(sel) sel.onchange=()=>{S.metric=sel.value; renderReport();};\n}\nfunction project(feats){let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  const each=cb=>feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(p=>p.forEach(r=>r.forEach(cb)));});\n  each(p=>{mnx=Math.min(mnx,p[0]);mxx=Math.max(mxx,p[0]);mny=Math.min(mny,p[1]);mxy=Math.max(mxy,p[1]);});\n  const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=700,H=500,pad=22;\n  const gw=(mxx-mnx)*kx,gh=(mxy-mny),sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n  const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n  return p=>[ox+(p[0]-mnx)*kx*sc, oy+(mxy-p[1])*sc];\n}\nfunction pathFor(f,pr){const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  return ps.map(poly=>poly.map(r=>'M'+r.map(p=>{const q=pr(p);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');}\nfunction cxy(f,pr){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(poly=>poly[0].forEach(p=>{const q=pr(p);x+=q[0];y+=q[1];n++;}));return[x/n,y/n];}\nfunction drawMap(){const c=compute();const NS='http://www.w3.org/2000/svg';const svg=$('#map');svg.innerHTML='';\n  const feats=[...S.nbrFeat,...Object.values(S.geoFeat)];if(!feats.length)return;const pr=project(feats);\n  S.nbrFeat.forEach(f=>{const p=document.createElementNS(NS,'path');p.setAttribute('d',pathFor(f,pr));p.setAttribute('class','nbr');svg.appendChild(p);\n    const ct=cxy(f,pr);const t=document.createElementNS(NS,'text');t.setAttribute('class','nlabel');t.setAttribute('x',ct[0]);t.setAttribute('y',ct[1]);t.textContent=(f.properties.name||'').slice(0,14);svg.appendChild(t);});\n  const asis=S.view==='asis';\n  Object.entries(S.geoFeat).forEach(([m,f])=>{const p=document.createElementNS(NS,'path');p.setAttribute('d',pathFor(f,pr));\n    p.setAttribute('class','muni'+(S.selected===m?' sel':''));p.setAttribute('fill',asis?CLOSE_COLOR:(S.color[S.assign[m]]||'#444'));\n    if(!asis) p.onclick=()=>{const i=S.dest.indexOf(S.assign[m]);S.assign[m]=S.dest[(i+1)%S.dest.length];S.selected=m;renderAll();};\n    p.style.cursor=asis?'default':'pointer';\n    p.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';\n      t.innerHTML=asis?`<b>${m}</b><br>${fmt(S.munis[m]?.cbs||0)} CBs · setor ${S.sector}`:`<b>${m}</b><br>${fmt(S.munis[m]?.cbs||0)} CBs → ${S.assign[m]}<br><span style=\"color:var(--mut)\">clique p/ trocar</span>`;};\n    p.onmouseleave=()=>$('#tip').style.opacity=0;svg.appendChild(p);\n    const ct=cxy(f,pr);const t=document.createElementNS(NS,'text');t.setAttribute('class','mlabel');t.setAttribute('x',ct[0]);t.setAttribute('y',ct[1]);\n    t.innerHTML=`${m.slice(0,13)}<tspan x=\"${ct[0]}\" dy=\"11\" font-size=\"8.5\">${fmt(S.munis[m]?.cbs||0)}${asis?'':'·'+S.assign[m]}</tspan>`;svg.appendChild(t);});\n  $('#legend').innerHTML=asis\n    ?`<span><b style=\"background:${CLOSE_COLOR}\"></b>Setor ${S.sector} (estrutura atual — ${Object.keys(S.geoFeat).length} municípios)</span>`\n    :S.dest.map(s=>`<span><b style=\"background:${S.color[s]}\"></b>Setor ${s} — ${c.gained[s]} líder(es)</span>`).join('');\n}\nasync function runSql(){const sql=$('#sqlIn').value.trim();$('#sqlStatus').textContent='rodando…';$('#sqlStatus').classList.remove('err');\n  try{const resp=await q(sql);const rs=rows(resp);const cols=(resp.manifest?.schema?.columns||[]).map(c=>c.name);\n    $('#sqlOut').innerHTML=`<thead><tr>${cols.map(c=>`<th>${c}</th>`).join('')}</tr></thead><tbody>`+rs.slice(0,200).map(r=>`<tr>${cols.map(c=>`<td>${r[c]??'·'}</td>`).join('')}</tr>`).join('')+`</tbody>`;\n    $('#sqlStatus').textContent=rs.length+' linha(s)';}\n  catch(e){$('#sqlStatus').textContent='bloqueado/erro: '+e.message;$('#sqlStatus').classList.add('err');}\n}\nasync function loadSectors(){\n  const sql=`SELECT CD_SETOR setor, MAX(DS_ESTADO) uf, COUNT(DISTINCT CD_CONSULTORA) cbs, COUNT(DISTINCT DS_MUNICIPIO) munis\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR>0 AND DT_HISTORICO=${SNAP} GROUP BY CD_SETOR ORDER BY CD_SETOR`;\n  S.sectorsList=rows(await q(sql)).map(r=>({setor:String(r.setor),uf:r.uf,cbs:+r.cbs,munis:+r.munis}));\n  const sel=$('#sectorPick');sel.innerHTML=S.sectorsList.map(s=>`<option value=\"${s.setor}\">${s.setor} · ${s.uf||'?'} · ${fmt(s.cbs)} CBs · ${s.munis} munic.</option>`).join('');\n  sel.value='3504';\n}\n$('#go').onclick=()=>closeSector($('#sectorPick').value);\n$('#rand').onclick=()=>{const cand=S.sectorsList.filter(s=>s.munis>=3&&s.munis<=10&&s.cbs>300);const pick=(cand.length?cand:S.sectorsList)[Math.floor(Math.random()*(cand.length?cand.length:S.sectorsList.length))];$('#sectorPick').value=pick.setor;closeSector(pick.setor);};\n$('#optim').onclick=()=>{S.assign=optimize();renderAll();};\n$('#autoTobe').onclick=()=>{S.assign=autoAssign();$('#optBadge').textContent='TOBE por adjacência';renderAll();};\n$('#viewToggle').querySelectorAll('button').forEach(b=>b.onclick=()=>{S.view=b.dataset.v;$('#viewToggle').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b));renderAll();});\n$('#policy').querySelectorAll('button').forEach(b=>b.onclick=()=>{S.policy=b.dataset.p;$('#policy').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b));renderKpis();renderPolicy();renderPlan();});\nroot.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('on',x===b));\n  ['matrix','report','sql'].forEach(t=>$('#tab-'+t).classList.toggle('hidden',t!==b.dataset.tab));});\n$('#runSql').onclick=runSql;\n$('#copyPlan').onclick=()=>{navigator.clipboard.writeText(JSON.stringify({setor:S.sector,politica:S.policy,plano:S._plan},null,2));$('#copyPlan').textContent='copiado ✓';setTimeout(()=>$('#copyPlan').textContent='Copiar',1500);};\n$('#csvPlan').onclick=()=>{const csv='municipio,cbs,setor_destino\\n'+S._plan.map(p=>`${p.municipio},${p.cbs},${p.destino}`).join('\\n');\n  const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));a.download=`plano_migracao_setor_${S.sector}.csv`;a.click();};\n\n(async()=>{loader(true,'carregando setores…');try{await loadSectors();await closeSector('3504');}catch(e){$('#ds').textContent='erro init: '+e.message;}loader(false);})();\n\n  }\n}"}}}}}},"extends":"data2-core-primitives@Block"}},{"_id":"6a74ae9d8bf67595bba6de82","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"extends":"data2-core-primitives@Block","component":"timeline-sim-login","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const AUTH='timelineSimAuthFinal';\n// SSP origin is derived at runtime from a known listener's resolved URL; this is only the fallback.\nconst SSP_FALLBACK='https://natura-geomarketing.data2apis.com';\nconst RUSH_REF='AwW_11Nkg-dKksP7ic8V8';\nconst ROUTER='timelineSimRouter';\n\nexport default {\n  css: `\n  :host{display:block;height:100%}\n  *{box-sizing:border-box}\n  .lroot{--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--acc2:#0060df;--closing:#ff3b30;--ok:#34c759;\n    min-height:100%;display:flex;align-items:center;justify-content:center;padding:36px 20px;background:var(--bg);\n    font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;color:var(--ink);-webkit-font-smoothing:antialiased}\n  .card{width:100%;max-width:400px;background:var(--panel);border:1px solid var(--line);border-radius:14px;\n    box-shadow:0 12px 34px rgba(0,0,0,.07),0 1px 2px rgba(0,0,0,.04);padding:26px 26px 22px}\n  .pill{display:inline-block;font-size:10.5px;padding:2.5px 9px;border-radius:99px;border:1px solid var(--line);color:var(--mut);background:var(--panel2)}\n  h1{font-size:17px;font-weight:600;letter-spacing:-.25px;margin:13px 0 5px}\n  .sub{font-size:12px;color:var(--mut);margin:0 0 20px;line-height:1.5}\n  .fl{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600;margin:0 0 5px}\n  .inp{width:100%;background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:9px 11px;font:inherit;font-size:13px}\n  .inp:focus{outline:2px solid rgba(0,122,255,.32);outline-offset:0;border-color:var(--acc)}\n  .inp:disabled{opacity:.55}\n  .row{margin-bottom:14px}\n  .act{width:100%;background:var(--acc);color:#fff;border:1px solid transparent;border-radius:8px;padding:10px 14px;font:inherit;font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 1px 1px rgba(0,0,0,.08);margin-top:4px}\n  .act:hover{background:var(--acc2)}\n  .act:disabled{opacity:.5;cursor:not-allowed}\n  .ghost{width:100%;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 12px;font:inherit;font-size:12.5px;cursor:pointer;margin-top:8px}\n  .ghost:hover{background:var(--panel2)}\n  .okbox{margin:12px 0 0;padding:8px 10px;border-radius:8px;background:#eefaf0;border:1px solid #bdeecb;color:#1c7c33;font-size:11.5px;font-weight:600}\n  .err{margin:12px 0 0;padding:8px 10px;border-radius:8px;background:#fff1f0;border:1px solid #ffd1cc;color:var(--closing);font-size:11.5px;font-weight:600}\n  .who{font-size:12px;color:var(--mut);margin:0 0 16px}\n  .who b{color:var(--ink)}\n  .foot{margin-top:18px;padding-top:14px;border-top:1px solid var(--line);font-size:10.5px;color:var(--mut);line-height:1.55}\n`,\n  render(d2){\n    const esc=s=>(s==null?'':String(s)).replace(/[&<>\"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]));\n    const busy=!!d2.state.busy;\n    const err=d2.state.error||'';\n    const a=d2.auth(AUTH);\n    if(!a){\n      return `<div class=\"lroot\"><div class=\"card\">\n        <span class=\"pill\">configuração</span>\n        <h1>Estratégia de autenticação não encontrada</h1>\n        <p class=\"sub\">Não foi possível resolver <b>${esc(AUTH)}</b>. Confirme a referência da AuthenticationStrategy do sistema.</p>\n      </div></div>`;\n    }\n    // Deliberately NOT branching on a.isAuthed. The Router renders this artboard ONLY from its\n    // `login` slot, i.e. only when the route policy rejected the caller — so anyone looking at it\n    // is unauthenticated by construction. isAuthed also reads TRUE under a builder token, which\n    // would show an \"already signed in\" card that bounces to / , gets rejected, and loops.\n    return `<div class=\"lroot\"><div class=\"card\">\n      <span class=\"pill\">acesso restrito</span>\n      <h1>Simulador de Timeline de Fechamentos</h1>\n      <p class=\"sub\">Entre com suas credenciais pra abrir a timeline de fechamentos de setores.</p>\n      <div class=\"row\">\n        <label class=\"fl\" for=\"em\">E-mail</label>\n        <input class=\"inp\" id=\"em\" type=\"email\" autocomplete=\"username\" autocapitalize=\"off\" autocorrect=\"off\" spellcheck=\"false\" placeholder=\"voce@natura.com.br\"${busy?' disabled':''}>\n      </div>\n      <div class=\"row\">\n        <label class=\"fl\" for=\"pw\">Senha</label>\n        <input class=\"inp\" id=\"pw\" type=\"password\" autocomplete=\"current-password\" placeholder=\"••••••••\"${busy?' disabled':''}>\n      </div>\n      <button class=\"act\" id=\"go\"${busy?' disabled':''}>${busy?'Entrando…':'Entrar'}</button>\n      ${err?`<div class=\"err\">⚠ ${esc(err)}</div>`:''}\n      ${d2.state.ok?`<div class=\"okbox\">✓ Autenticado. No app publicado o redirecionamento é automático.</div>`:''}\n      <div class=\"foot\">Geomarketing · Natura. O simulador é read-only sobre o Databricks — cada sessão salva fica presa ao snapshot congelado da simulação.</div>\n    </div></div>`;\n  },\n  mounted(d2, root){\n    // The router re-runs its policy hook when the session changes, so a successful login normally\n    // swaps the gated content on its own. If it does not (same path), reload as a last resort —\n    // cancelled on unmount, so the reload only ever fires when the swap did NOT happen.\n    // Login now runs through the strategy, so the runtime DOES hold the session in memory and the\n    // route policy would re-evaluate on its own. The hard navigation is kept deliberately: it is\n    // the one path that cannot land on stale client state. In the builder there is no app root to\n    // go to, so we just report success instead of navigating out of /preview/<ref>. [agent:ad78d19d]\n    const enter=()=>{\n      if(d2.context.isBuilder){ d2.setState({busy:false,error:'',ok:true}); return; }\n      try{ window.location.replace('/'); }\n      catch(e){ try{ window.location.reload(); }catch(e2){} }\n    };\n    // pt-BR (ABNT2) keyboards put ^ ~ ´ ¨ on DEAD keys: pressing one before a character that cannot\n    // take the accent (like @) emits the bare mark, or leaves a combining codepoint behind. Either\n    // way it must never reach /id/ln, so strip it plus zero-width paste junk and any whitespace.\n    // The PASSWORD is deliberately left untouched — these characters are legitimate in a password.\n    const cleanEmail=s=>String(s==null?'':s)\n      .normalize('NFKC')\n      .replace(/[̀-ͯ]/g,'')\n      .replace(/[​-‍﻿]/g,'')\n      .replace(/[\\^~´¨`]/g,'')\n      .replace(/\\s+/g,'');\n    // A failed login must NEVER navigate or reload: keep the card, keep what was typed (the morph\n    // patches in place so input values survive), show the error and put the cursor back in the\n    // password so it can be corrected immediately.\n    const failWith=msg=>{\n      d2.setState({busy:false,ok:false,error:msg});\n      setTimeout(()=>{ const p=d2.$('#pw'); if(p){ try{ p.focus(); p.select(); }catch(e){} } },0);\n    };\n    const submit=async()=>{\n      if(d2.state.busy) return;\n      const rawEm=(d2.$('#em')||{}).value||'';\n      const em=cleanEmail(rawEm);\n      if(em!==rawEm && d2.$('#em')) d2.$('#em').value=em;   // show the cleaned address back to the user\n      const pw=((d2.$('#pw')||{}).value||'');\n      if(!em||!pw){ failWith('Preencha e-mail e senha.'); return; }\n      d2.setState({busy:true,error:'',ok:false});\n      try{\n        // Direct interaction with the AuthenticationStrategy — no hand-rolled fetch. The runtime\n        // owns the session envelope, so IT attaches `authorization: Bearer <jwt>` (and the session\n        // cookie) to the login POST. The previous hand-rolled fetch could not: it derived the jwt\n        // from document.cookie on the APP origin while the cookie lives on the SSP origin, so the\n        // header was silently dropped on every attempt — the 401 in devtools. [agent:ad78d19d]\n        // ⚠️ `credentials` MUST stay NESTED — a flat {email,password} authenticates nobody and\n        // fails as an UNCATCHABLE 400/403 (no exception, no rejected promise).\n        // ⚠️ A rejected login is NOT an exception either — after the promise settles, read\n        // `isAuthed`. Do not gate the error UI on catch().\n        const a=d2.auth(AUTH);\n        if(!a){ failWith('Estratégia de autenticação não encontrada ('+AUTH+').'); return; }\n        await a.login({credentials:{email:em,password:pw}});\n        if(a.isAuthed){ enter(); return; }\n        failWith('E-mail ou senha inválidos.');\n      }catch(e){\n        failWith('Falha ao autenticar: '+((e&&e.message)||'erro desconhecido'));\n      }\n    };\n    d2.on('#go','click',submit);\n    d2.on('.inp','keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); submit(); } });\n  }\n}"}}}}}}}},{"_id":"6a74b62bdad5912765098ff0","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"extends":"data2-core-primitives@Block","component":"timeline_sim_session_read","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const NIVEIS=['Bronze','Cristal','Prata','Ouro','Diamante','Diamante+'];\n\nexport default {\n  css: `\n  :host{display:block;height:100%}\n  *{box-sizing:border-box}\n  .rroot{--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--closing:#ff3b30;--ok:#34c759;--warn:#ff9500;\n    height:100%;overflow:auto;background:var(--bg);color:var(--ink);\n    font:13px/1.5 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;-webkit-font-smoothing:antialiased}\n  .shell{max-width:1080px;margin:0 auto;padding:26px 22px 70px}\n  header.top{margin-bottom:20px}\n  .eyebrow{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);font-weight:600}\n  h1{font-size:25px;font-weight:600;letter-spacing:-.5px;margin:7px 0 10px}\n  .pills{display:flex;gap:7px;flex-wrap:wrap;align-items:center}\n  .pill{font-size:10.5px;padding:3px 9px;border-radius:99px;border:1px solid var(--line);color:var(--mut);background:var(--panel)}\n  .pill b{color:var(--ink);font-weight:600}\n  .pill.go{cursor:pointer;border-color:var(--acc);color:var(--acc);background:#eaf3ff;font-weight:600}\n  .pill.go:hover{background:#dbe9ff}\n  .goact{margin-top:15px;background:var(--acc);color:#fff;border:1px solid transparent;border-radius:8px;\n    padding:9px 16px;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer}\n  .goact:hover{background:#0060df}\n  .strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:18px 0 22px}\n  .st{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;box-shadow:0 1px 2px rgba(0,0,0,.04)}\n  .st .n{font-size:23px;font-weight:700;letter-spacing:-.5px}\n  .st .l{font-size:10.5px;color:var(--mut);margin-top:3px;line-height:1.4}\n  .st.warn .n{color:var(--closing)}\n  .stepper{position:sticky;top:0;z-index:5;display:flex;gap:7px;overflow-x:auto;padding:11px 0;margin-bottom:6px;\n    background:rgba(245,245,247,.9);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}\n  .sc{flex:0 0 auto;display:flex;align-items:center;gap:7px;background:var(--panel);border:1px solid var(--line);\n    border-radius:99px;padding:5px 12px 5px 6px;cursor:pointer;font-size:12px}\n  .sc:hover{border-color:var(--acc)}\n  .sc .k{width:19px;height:19px;border-radius:99px;background:var(--ink);color:#fff;font-size:10px;font-weight:700;\n    display:flex;align-items:center;justify-content:center}\n  .sc .arrow{color:var(--mut)}\n  .card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin-bottom:16px;box-shadow:0 1px 2px rgba(0,0,0,.04)}\n  .card > h2{font-size:15px;font-weight:600;letter-spacing:-.2px;margin:0 0 3px}\n  .card .sub{font-size:11.5px;color:var(--mut);margin:0 0 15px}\n  h3{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);margin:20px 0 9px;font-weight:600}\n  h3:first-of-type{margin-top:0}\n  .split{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:20px;align-items:start}\n  @media(max-width:820px){.split{grid-template-columns:1fr}}\n  svg.map{width:100%;height:auto;display:block;background:var(--panel2);border-radius:10px;border:1px solid var(--line);cursor:grab;touch-action:none}\n  svg.map.dragging{cursor:grabbing}\n  .map-wrap{position:relative}\n  .zoom-pill{position:absolute;left:10px;bottom:10px;display:flex;align-items:stretch;background:var(--panel);border:1px solid var(--line);border-radius:99px;box-shadow:0 2px 8px rgba(0,0,0,.14);overflow:hidden;z-index:3}\n  .zoom-pill button{width:30px;height:30px;border:0;background:transparent;color:var(--ink);font:16px/1 -apple-system,BlinkMacSystemFont,sans-serif;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center}\n  .zoom-pill button:hover{background:var(--panel2)}\n  .zoom-pill button:active{background:#e5e5ea}\n  .zoom-pill .zoom-div{width:1px;background:var(--line)}\n  .muni{stroke:#fff;stroke-width:1.1}\n  .nbr{fill:#e8e8ec;stroke:#d4d4d9;stroke-width:.7}\n  .exc{fill:#f7e7d0;stroke:var(--warn);stroke-width:.9;stroke-dasharray:2 2}\n  .mlabel{fill:#1c1c1e;font-size:9.5px;font-weight:600;text-anchor:middle;paint-order:stroke;stroke:rgba(255,255,255,.7);stroke-width:2.4px}\n  .nlabel{fill:#9a9aa0;font-size:7.5px;text-anchor:middle}\n  .legend{display:flex;gap:11px;flex-wrap:wrap;margin-top:9px;font-size:11px;color:var(--mut)}\n  .legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px}\n  .bar{display:flex;align-items:center;gap:9px;margin:7px 0}\n  .bar .tag{width:46px;font-weight:600;font-size:12px}\n  .bar .track{flex:1;height:17px;background:var(--panel2);border-radius:5px;overflow:hidden;display:flex}\n  .bar .b1{height:100%;opacity:.38}.bar .b2{height:100%}.bar .b3{height:100%;opacity:.7}\n  .bar .v{width:120px;text-align:right;font-size:11px;color:var(--mut);font-variant-numeric:tabular-nums}\n  .mrow{display:flex;align-items:center;gap:9px;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:6px 10px;margin-bottom:5px}\n  .mrow .nm{flex:1;font-weight:600;font-size:12px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n  .mrow .cb{color:var(--mut);font-size:11px;font-variant-numeric:tabular-nums}\n  .chip{font-size:11px;font-weight:700;color:#fff;border-radius:6px;padding:2px 8px}\n  table{border-collapse:collapse;width:100%;font-size:11.5px}\n  th,td{padding:6px 7px;text-align:right;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}\n  th:first-child,td:first-child{text-align:left}\n  thead th{color:var(--mut);font-weight:600;font-size:10.5px}\n  .dot{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px}\n  .delta{font-size:10px}.delta.up{color:var(--ok)}.delta.dn{color:var(--closing)}\n  .note{font-size:10.5px;color:var(--mut);line-height:1.55;margin-top:9px}\n  .flag{margin-top:11px;padding:9px 11px;border-radius:9px;font-size:11.5px;line-height:1.55;background:#fff8ef;border:1px solid #ffe0bd;color:#8a5200}\n  .scroll{max-height:300px;overflow:auto}\n  .center{display:flex;align-items:center;justify-content:center;height:100%;padding:40px}\n  .msg{max-width:420px;text-align:center}\n  .msg h2{font-size:17px;font-weight:600;margin:0 0 7px}\n  .msg p{font-size:12.5px;color:var(--mut);margin:0;line-height:1.55}\n`,\n  render(d2){\n    // Hierarquia mercado > GV > setor, espelhando o simulador ao vivo. Tudo pré-computado (label,\n    // minúscula, plural, gênero) pra nunca minusculizar \"GV\" (sigla) virando \"gv\", e pra concordar\n    // gênero certo em cada frase — GV é feminina (a Gerência de Vendas), o resto é masculino.\n    const LVL={\n      setor:{label:'Setor',low:'setor',fem:false, child:'Município',childLow:'município',childPlural:'Municípios',childFem:false, dest:'Setor',destLow:'setor'},\n      gv:{label:'GV',low:'GV',fem:true, child:'Setor',childLow:'setor',childPlural:'Setores',childFem:false, dest:'GV',destLow:'GV'},\n      mercado:{label:'Mercado',low:'mercado',fem:false, child:'GV',childLow:'GV',childPlural:'GVs',childFem:true, dest:'Mercado',destLow:'mercado'},\n    };\n    const lv=s=>LVL[(s&&s.level)||'setor'];\n    const esc=s=>(s==null?'':String(s)).replace(/[&<>\"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]));\n    const fmt=n=>n==null?'–':Math.round(+n).toLocaleString('pt-BR');\n    const money=n=>{n=+n||0;return n>=1e6?'R$'+(n/1e6).toFixed(1)+'M':n>=1e3?'R$'+(n/1e3).toFixed(0)+'k':'R$'+Math.round(n)};\n    const msg=(t,b,act)=>`<div class=\"rroot\"><div class=\"center\"><div class=\"msg\"><h2>${t}</h2><p>${b}</p>${act?`<button class=\"goact\" id=\"goSim\">Abrir o simulador</button>`:''}</div></div></div>`;\n\n    if(d2.state.error) return msg('Não foi possível abrir a sessão',esc(d2.state.error),true);\n    if(!d2.state.ready) return msg('Carregando sessão…','Buscando o registro compartilhado.');\n    const r=d2.state.rec;\n    if(!r) return msg('Sessão não encontrada','O link pode ter expirado ou o registro foi removido. Peça um novo link a quem compartilhou.',true);\n\n    const stages=Array.isArray(r.stages)?r.stages:[];\n    const sm=r.summary||{};\n    const st=r.settings||{};\n    const ruleDest=st.destRule==='dominant'?`destino: dominantes ≥${fmt(st.destMinCbs)} CBs`:'destino: qualquer município';\n    const ruleReal=st.realocRule==='relevant'?`realocação: ≥${fmt(st.realocMinCbs)} CBs`:'realocação: todos';\n    const spanBits=[];\n    if(st.targetSpan)spanBits.push('alvo '+fmt(st.targetSpan));\n    if(st.maxSpan)spanBits.push('máx '+fmt(st.maxSpan));\n    if(st.minSpan)spanBits.push('mín '+fmt(st.minSpan));\n\n    let h=`<div class=\"rroot\"><div class=\"shell\">\n      <header class=\"top\">\n        <div class=\"eyebrow\">Simulador de timeline de fechamentos · sessão compartilhada</div>\n        <h1>${esc(r.label||'Sessão sem nome')}</h1>\n        <div class=\"pills\">\n          <span class=\"pill\">● snapshot congelado · <b>${esc(r.snapshotRaw||'—')}</b></span>\n          <span class=\"pill\">◇ ${esc(ruleReal)}</span>\n          <span class=\"pill\">◈ ${esc(ruleDest)}</span>\n          <span class=\"pill\">◎ span: <b>${spanBits.length?esc(spanBits.join(' · ')):'livre'}</b></span>\n          ${r.sharedBy?`<span class=\"pill\">por <b>${esc(r.sharedBy)}</b></span>`:''}\n          ${r.sharedAt?`<span class=\"pill\">${esc(new Date(r.sharedAt).toLocaleString('pt-BR'))}</span>`:''}\n          <span class=\"pill go\" id=\"goSim\" role=\"button\" tabindex=\"0\" title=\"Ir para o simulador\">↗ abrir o simulador</span>\n        </div>\n      </header>`;\n\n    const sessLevel=(stages[0]&&stages[0].level)||'setor';\n    h+=`<div class=\"strip\">\n      <div class=\"st\"><div class=\"n\">${fmt(stages.length)}</div><div class=\"l\">Etapa(s) de fechamento</div></div>\n      <div class=\"st\"><div class=\"n\">${fmt(sm.totalCbs)}</div><div class=\"l\">CBs realocadas na cadeia</div></div>\n      ${sessLevel==='setor'?`<div class=\"st warn\"><div class=\"n\">${fmt(sm.totalDesloc)}</div><div class=\"l\">CBs deslocadas (quebra de relação)</div></div>`:`<div class=\"st\"><div class=\"n\">${fmt(sm.totalDesloc)}</div><div class=\"l\">CBs com líder dividida entre destinos</div></div>`}\n      <div class=\"st\"><div class=\"n\" style=\"font-size:15px;line-height:1.5;font-weight:600\">${esc((sm.sectors||[]).join(' → ')||'—')}</div><div class=\"l\">Ordem de fechamento</div></div>\n    </div>`;\n\n    if(stages.length>1){\n      h+=`<div class=\"stepper\">`+stages.map((s,i)=>`<div class=\"sc\" data-goto=\"stage-${i}\"><span class=\"k\">${i+1}</span>${lv(s).label} ${esc(s.sector)}<span class=\"arrow\">→</span>${esc((s.dest||[]).join(', ')||'–')}</div>`).join('')+`</div>`;\n    }\n\n    // ---- projection helpers (pure; mirrors the simulator's) ----\n    const project=feats=>{let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n      feats.forEach(f=>{const g=f&&f.geometry;if(!g)return;const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(p=>p.forEach(rg=>rg.forEach(pt=>{mnx=Math.min(mnx,pt[0]);mxx=Math.max(mxx,pt[0]);mny=Math.min(mny,pt[1]);mxy=Math.max(mxy,pt[1]);})));});\n      if(!isFinite(mnx)) return null;\n      const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=680,H=440,pad=16;\n      const gw=Math.max((mxx-mnx)*kx,1e-9),gh=Math.max(mxy-mny,1e-9),sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n      const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n      return {W,H,f:pt=>[ox+(pt[0]-mnx)*kx*sc, oy+(mxy-pt[1])*sc]};};\n    const pathFor=(f,pr)=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      return ps.map(poly=>poly.map(rg=>'M'+rg.map(pt=>{const q=pr(pt);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');};\n    const cxy=(f,pr)=>{let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      ps.forEach(poly=>poly[0].forEach(pt=>{const q=pr(pt);x+=q[0];y+=q[1];n++;}));return n?[x/n,y/n]:[0,0];};\n\n    stages.forEach((s,i)=>{\n      const color=s.color||{};\n      const k=s.kpis||{};\n      const LV=lv(s);\n      h+=`<div class=\"card\" id=\"stage-${i}\" data-key=\"stage-${i}\">\n        <h2>Etapa ${i+1} · ${LV.low} <b>${esc(s.sector)}</b> fechad${LV.fem?'a':'o'}</h2>\n        <p class=\"sub\">Território redistribuído para ${esc((s.dest||[]).join(', ')||'—')}${s.domUF?' · '+esc(s.domUF):''}${s.policy?' · política: '+(s.policy==='fora'?'manter vínculo':'quebrar relação'):''}</p>\n        <div class=\"strip\" style=\"margin:0 0 18px\">\n          <div class=\"st\"><div class=\"n\">${fmt(k.cbsRealocadas)}</div><div class=\"l\">CBs realocadas</div></div>\n          ${LV.label==='Setor'?`<div class=\"st warn\"><div class=\"n\">${fmt(k.desloc)}</div><div class=\"l\">CBs deslocadas</div></div>`:`<div class=\"st\"><div class=\"n\">${fmt(k.desloc)}</div><div class=\"l\">CBs com líder dividida</div></div>`}\n          <div class=\"st\"><div class=\"n\">${fmt(k.municipios)}</div><div class=\"l\">${LV.childPlural} movid${LV.childFem?'as':'os'}</div></div>\n          <div class=\"st\"><div class=\"n\">${fmt(k.lideres)}</div><div class=\"l\">Líderes envolvidas</div></div>\n        </div>\n        <div class=\"split\"><div>`;\n\n      // map\n      const gf=s.geoFeat||{}, nb=Array.isArray(s.nbrFeat)?s.nbrFeat:[], ex=Array.isArray(s.excludedFeat)?s.excludedFeat:[];\n      const all=[...nb,...ex,...Object.values(gf)].filter(Boolean);\n      const P=project(all);\n      const unitOf=m=>LV.label==='Setor'?m:((s.unitOfMuni||{})[m]||m);\n      if(P){\n        let g='';\n        nb.forEach(f=>{ if(!f||!f.geometry)return; g+=`<path class=\"nbr\" d=\"${pathFor(f,P.f)}\"/>`;\n          const c=cxy(f,P.f);const nm=(f.properties&&f.properties.name)||'';\n          if(nm) g+=`<text class=\"nlabel\" data-fs=\"7.5\" x=\"${c[0].toFixed(1)}\" y=\"${c[1].toFixed(1)}\">${esc(nm.slice(0,13))}</text>`; });\n        ex.forEach(f=>{ if(!f||!f.geometry)return; g+=`<path class=\"exc\" d=\"${pathFor(f,P.f)}\"/>`; });\n        if(LV.label==='Setor'){\n          Object.entries(gf).forEach(([m,f])=>{ if(!f||!f.geometry)return;\n            const dest=(s.assign||{})[m];\n            g+=`<path class=\"muni\" d=\"${pathFor(f,P.f)}\" fill=\"${esc(color[dest]||'#8e8e93')}\"/>`;\n            const c=cxy(f,P.f);\n            g+=`<text class=\"mlabel\" data-fs=\"9.5\" data-sw=\"2.4\" x=\"${c[0].toFixed(1)}\" y=\"${c[1].toFixed(1)}\">${esc(m.slice(0,14))}<tspan data-fs=\"8\" x=\"${c[0].toFixed(1)}\" dy=\"10\" font-size=\"8\">${esc(dest||'–')}</tspan></text>`; });\n        } else {\n          // GV/mercado: vários municípios do mesmo setor/GV são UMA unidade só na tela — mesma cor,\n          // mesmo stroke (a costura entre elas some), um rótulo só no centro do grupo em vez de um\n          // por município. Espelha o mapa ao vivo do simulador.\n          const groups={};\n          Object.entries(gf).forEach(([m,f])=>{ if(!f||!f.geometry)return; const u=unitOf(m); (groups[u]=groups[u]||[]).push(f); });\n          Object.entries(groups).forEach(([u,feats])=>{\n            const dest=(s.assign||{})[u]; const col=esc(color[dest]||'#8e8e93');\n            let cx=0,cy=0,n=0;\n            feats.forEach(f=>{ g+=`<path class=\"muni\" d=\"${pathFor(f,P.f)}\" fill=\"${col}\" stroke=\"${col}\"/>`;\n              const c=cxy(f,P.f); cx+=c[0]; cy+=c[1]; n++; });\n            if(n){ cx/=n; cy/=n;\n              g+=`<text class=\"mlabel\" data-fs=\"9.5\" data-sw=\"2.4\" x=\"${cx.toFixed(1)}\" y=\"${cy.toFixed(1)}\">${esc(u)}<tspan data-fs=\"8\" x=\"${cx.toFixed(1)}\" dy=\"10\" font-size=\"8\">${esc(dest||'–')}</tspan></text>`;\n            }\n          });\n        }\n        h+=`<h3>Mapa da redistribuição</h3><div class=\"map-wrap\">\n          <svg class=\"map\" id=\"map-${i}\" data-w=\"${P.W}\" data-h=\"${P.H}\" viewBox=\"0 0 ${P.W} ${P.H}\" preserveAspectRatio=\"xMidYMid meet\">${g}</svg>\n          <div class=\"zoom-pill\"><button type=\"button\" data-zoom=\"out\" data-i=\"${i}\" title=\"Diminuir zoom\">−</button><span class=\"zoom-div\"></span><button type=\"button\" data-zoom=\"in\" data-i=\"${i}\" title=\"Aumentar zoom\">+</button></div>\n          </div>\n          <div class=\"legend\">${(s.dest||[]).map(d=>`<span><i style=\"background:${esc(color[d]||'#8e8e93')}\"></i>${LV.dest} ${esc(d)}</span>`).join('')}\n          <span><i style=\"background:#e8e8ec;border:1px solid #d4d4d9\"></i>vizinhos (contexto)</span>\n          ${ex.length?`<span><i style=\"background:#f7e7d0;border:1px dashed #ff9500\"></i>fora da realocação</span>`:''}</div>`;\n      }\n      h+=`<h3>${LV.childPlural} → ${LV.destLow} destino</h3>`;\n      h+=(s.municipios||[]).map(m=>`<div class=\"mrow\"><span class=\"nm\">${esc(m.nome)}</span><span class=\"cb\">${fmt(m.cbs)} CBs</span>\n        <span class=\"chip\" style=\"background:${esc(color[m.destino]||'#8e8e93')}\">${esc(m.destino||'–')}</span></div>`).join('')||`<div class=\"note\">Nenhum${LV.childFem?'a':''} ${LV.childLow} realocad${LV.childFem?'a':'o'} nesta etapa.</div>`;\n\n      h+=`</div><div>`;\n      // spans\n      const ds=s.destinos||[];\n      const mx=Math.max(...ds.map(d=>+d.span||0),1);\n      h+=`<h3>Tamanho d${LV.fem?'a':'o'} ${LV.destLow} depois da etapa</h3>`;\n      h+=ds.map(d=>{const base=+d.base||0,inc=+d.inc||0,orph=+d.orph||0;\n        return `<div class=\"bar\"><span class=\"tag\" style=\"color:${esc(color[d.s]||'#8e8e93')}\">${esc(d.s)}</span>\n          <span class=\"track\"><span class=\"b1\" style=\"width:${(base/mx*100).toFixed(1)}%;background:${esc(color[d.s]||'#8e8e93')}\"></span><span class=\"b2\" style=\"width:${(inc/mx*100).toFixed(1)}%;background:${esc(color[d.s]||'#8e8e93')}\"></span>${orph?`<span class=\"b3\" style=\"width:${(orph/mx*100).toFixed(1)}%;background:${esc(color[d.s]||'#8e8e93')}\"></span>`:''}</span>\n          <span class=\"v\">${fmt(d.span)} <span style=\"opacity:.6\">(+${fmt(inc+orph)})</span></span></div>`;}).join('')||`<div class=\"note\">Sem ${LV.destLow} destino nesta etapa.</div>`;\n      h+=`<div class=\"note\">Claro = base antes da etapa · cheio = CBs entrantes${(s.orphanTotal?' · faixa final = CBs que seguiram suas líderes':'')}.</div>`;\n\n      // leaders\n      const L=s.lideres||[];\n      if(L.length){\n        const showOrph=L.some(x=>+x.orphan>0);\n        h+=`<h3>Líderes</h3><div class=\"scroll\"><table><thead><tr><th>Líder</th><th>CBs</th><th>${LV.dest} final</th>${showOrph?'<th>Órfãs</th>':''}<th>Desloc.</th></tr></thead><tbody>`\n          +L.map(x=>`<tr><td>${esc(x.lider||'—')}</td><td>${fmt(x.total)}</td>\n            <td>${x.home?`<span class=\"dot\" style=\"background:${esc(color[x.home]||'#8e8e93')}\"></span>${esc(x.home)}`:'–'}</td>\n            ${showOrph?`<td>${x.orphan?fmt(x.orphan):'·'}</td>`:''}\n            <td style=\"color:${+x.desloc?'var(--closing)':'var(--mut)'};font-weight:${+x.desloc?700:400}\">${fmt(x.desloc||0)}</td></tr>`).join('')\n          +`</tbody></table></div>`;\n      }\n      h+=`</div></div>`;\n\n      // antes -> depois\n      const M=s.metricas||[];\n      if(M.length){\n        h+=`<h3>Impacto antes → depois por ${LV.destLow} ${LV.fem?'receptora':'receptor'}</h3><div class=\"scroll\"><table><thead><tr><th>${LV.dest}</th>${M.map(m=>`<th>${esc(m.label)}</th>`).join('')}</tr></thead><tbody>`;\n        (s.dest||[]).forEach(sec=>{\n          h+=`<tr><td><span class=\"dot\" style=\"background:${esc(color[sec]||'#8e8e93')}\"></span>${esc(sec)}</td>`;\n          h+=M.map(m=>{const row=(m.rows||[]).find(x=>String(x.s)===String(sec))||{};\n            const a=+row.antes||0,b=+row.depois||0,d=a?((b-a)/a*100):0;const f=m.money?money:fmt;\n            return `<td>${f(b)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;}).join('')+`</tr>`;\n        });\n        h+=`<tr style=\"opacity:.75\"><td>${esc(s.sector)} (sai)</td>`+M.map(m=>`<td>${(m.money?money:fmt)(m.sai||0)}</td>`).join('')+`</tr>`;\n        h+=`</tbody></table></div>`;\n      }\n\n      // caveats\n      const fl=[];\n      if(s.excluidos&&s.excluidos.length) fl.push(`<b>${fmt(s.excluidos.length)} município(s)</b> com poucas CBs ficaram fora da realocação geográfica (quebra de vínculo): ${esc(s.excluidos.slice(0,6).map(x=>x.muni+' ('+x.cbs+')').join(', '))}${s.excluidos.length>6?' +'+(s.excluidos.length-6):''}. As CBs seguiram a líder do seu grupo.`);\n      if(s.orphanStranded) fl.push(`<b>${fmt(s.orphanStranded)} CB(s)</b> sem setor a seguir — a líder não ficou com nenhum município realocado.`);\n      if(s.noShapeCbs) fl.push(`<b>${fmt(s.noShapeCbs)} CB(s)</b> em municípios sem shape na UF, fora do mapa e de qualquer setor destino.`);\n      if(s.optTier===1) fl.push(`O span mínimo não era atingível nesta etapa e foi relaxado pelo modelo.`);\n      if(s.optTier===2) fl.push(`Os limites de span não eram atingíveis nesta etapa e foram relaxados pelo modelo.`);\n      if(s.optTier===-1) fl.push(`Etapa resolvida por heurística (espaço de busca grande) — os limites de span não são garantidos.`);\n      if(s.destRuleFallback) fl.push(`Nenhum município dominante nesta etapa: a regra de destino foi relaxada para \"qualquer município\".`);\n      if(s.realocFallback) fl.push(`Todos os municípios ficariam abaixo do limite: a regra de realocação foi relaxada para \"realocar todos\".`);\n      if(fl.length) h+=`<div class=\"flag\">⚠ ${fl.join('<br>')}</div>`;\n      h+=`</div>`;\n    });\n\n    // cumulative\n    // Este resumo é calculado no rastro de município (world.muniOverride) — hoje só existe pro\n    // fechamento de setor; GV/mercado têm o rastro equivalente (secOverride/gvOverride) mas essa\n    // agregação ainda não foi refeita nesse grão, então avisa em vez de mostrar tabela vazia.\n    const rc=sm.receivers||[];\n    if(!rc.length && sessLevel!=='setor'){\n      h+=`<div class=\"card\"><h2>Impacto acumulado da cadeia</h2>\n        <p class=\"note\" style=\"margin-top:0\">Ainda não disponível pra sessões de ${sessLevel==='gv'?'GV':'mercado'} — veja o relatório de cada etapa acima.</p></div>`;\n    }\n    if(rc.length){\n      h+=`<div class=\"card\"><h2>Impacto acumulado da cadeia</h2>\n        <p class=\"sub\">Efeito de <b>todas</b> as ${fmt(stages.length)} etapa(s) somadas, contra o snapshot congelado sem nenhum fechamento aplicado.</p>\n        <div class=\"scroll\"><table><thead><tr><th>Setor</th><th>Situação</th><th>CBs antes</th><th>CBs depois</th><th>Variação</th></tr></thead><tbody>`\n        +rc.map(x=>{const a=+x.antes||0,b=+x.depois||0,d=a?((b-a)/a*100):0;\n          return `<tr><td>${esc(x.s)}</td><td>${esc(x.situacao||'recebeu território')}</td><td>${fmt(a)}</td><td>${fmt(b)}</td>\n            <td class=\"delta ${d>=0?'up':'dn'}\" style=\"font-size:11.5px\">${d>0?'+':''}${d.toFixed(0)}%</td></tr>`;}).join('')\n        +`</tbody></table></div></div>`;\n    }\n\n    h+=`<div class=\"note\" style=\"text-align:center;margin-top:22px\">Visualização somente leitura de uma simulação congelada. Os números refletem o snapshot <b>${esc(r.snapshotRaw||'—')}</b> e as regras vigentes no momento do compartilhamento — não acompanham o dado ao vivo.</div>`;\n    return h+`</div></div>`;\n  },\n  mounted(d2, root){\n    d2.on('#goSim','click',()=>{\n      try{ (d2.router('timelineSimRouter')||d2.router()).goTo('/'); }catch(e){ try{ d2.router().goTo('/'); }catch(e2){} }\n    });\n    d2.on('.sc','click',e=>{\n      const el=e.target.closest('.sc'); if(!el) return;\n      const t=d2.$('#'+el.dataset.goto); if(t) t.scrollIntoView({behavior:'smooth',block:'start'});\n    });\n    // ---- map zoom/pan (viewBox só — texto e polígonos vivem no mesmo sistema de coordenadas, então\n    // escalam juntos automaticamente ao encolher/esticar o viewBox, sem lógica extra pra fonte).\n    // Delegado em `root` (nunca substituído por re-render) em vez de nos <svg> por id, porque cada\n    // atualização de estado reconstrói o HTML das etapas do zero.\n    const mapViews={};\n    const getMapView=(i,W,H)=>mapViews[i]||(mapViews[i]={x:0,y:0,w:W,h:H});\n    const applyMapView=(svg,i)=>{ const v=mapViews[i]; if(!v) return;\n      svg.setAttribute('viewBox', v.x.toFixed(2)+' '+v.y.toFixed(2)+' '+v.w.toFixed(2)+' '+v.h.toFixed(2));\n      // Rótulo tem tamanho em unidades do viewBox — sem isso ele cresceria junto com o zoom até virar\n      // um borrão. Encolher na mesma proporção que o viewBox encolhe cancela o zoom: o texto fica do\n      // mesmo tamanho na tela em qualquer nível. style.fontSize (não o atributo) porque uma regra de\n      // classe como .mlabel{font-size:...} outrankaria um atributo comum.\n      const W=+svg.dataset.w||700;\n      const fsScale=v.w/W;\n      svg.querySelectorAll('[data-fs]').forEach(t=>{\n        const base=+t.getAttribute('data-fs')||8;\n        t.style.fontSize=(base*fsScale).toFixed(2)+'px';\n      });\n      // Halo do texto (contorno branco que garante legibilidade sobre qualquer cor de fundo) tem\n      // largura de traço fixa em unidades do viewBox — sem reescalar junto, no zoom alto ele vira\n      // uma mancha cobrindo a letra inteira em vez de um contorno fino.\n      svg.querySelectorAll('[data-sw]').forEach(t=>{\n        const base=+t.getAttribute('data-sw')||1;\n        t.style.strokeWidth=(base*fsScale).toFixed(3)+'px';\n      });\n    };\n    const zoomStageMap=(i,factor)=>{\n      const svg=root.querySelector('#map-'+i); if(!svg) return;\n      const W=+svg.dataset.w||700, H=+svg.dataset.h||500;\n      const v=getMapView(i,W,H);\n      const cx=v.x+v.w/2, cy=v.y+v.h/2;\n      let w=v.w/factor, h=v.h/factor;\n      w=Math.min(W, Math.max(W/8, w)); h=Math.min(H, Math.max(H/8, h));\n      let x=cx-w/2, y=cy-h/2;\n      x=Math.min(W-w, Math.max(0,x)); y=Math.min(H-h, Math.max(0,y));\n      mapViews[i]={x,y,w,h};\n      applyMapView(svg,i);\n    };\n    d2.on('.zoom-pill button','click',e=>{\n      const b=e.target.closest('button'); if(!b) return;\n      zoomStageMap(b.dataset.i, b.dataset.zoom==='in'?1.4:1/1.4);\n    });\n    let mapDrag=null;\n    root.addEventListener('pointerdown',e=>{\n      const svg=e.target.closest('svg.map'); if(!svg) return;\n      const i=svg.id.replace('map-',''); const W=+svg.dataset.w||700, H=+svg.dataset.h||500;\n      const v=getMapView(i,W,H);\n      mapDrag={svg,i,W,H,x0:e.clientX,y0:e.clientY,vx0:v.x,vy0:v.y,rect:svg.getBoundingClientRect()};\n      svg.classList.add('dragging');\n    });\n    root.addEventListener('pointermove',e=>{\n      if(!mapDrag||!mapDrag.rect.width||!mapDrag.rect.height) return;\n      const dxPix=e.clientX-mapDrag.x0, dyPix=e.clientY-mapDrag.y0;\n      const v=getMapView(mapDrag.i,mapDrag.W,mapDrag.H);\n      const sx=v.w/mapDrag.rect.width, sy=v.h/mapDrag.rect.height;\n      let x=mapDrag.vx0-dxPix*sx, y=mapDrag.vy0-dyPix*sy;\n      x=Math.min(mapDrag.W-v.w, Math.max(0,x)); y=Math.min(mapDrag.H-v.h, Math.max(0,y));\n      mapViews[mapDrag.i]={...v,x,y};\n      applyMapView(mapDrag.svg,mapDrag.i);\n    });\n    const endMapDrag=()=>{ if(mapDrag){ mapDrag.svg.classList.remove('dragging'); mapDrag=null; } };\n    root.addEventListener('pointerup',endMapDrag);\n    root.addEventListener('pointerleave',endMapDrag);\n    (async()=>{\n      try{\n        // RACE, not a missing dataset. This DataSet is gated by timelineSimAuthFinal, so its\n        // Process is only define()d once the auth session resolves — a Process waits for its\n        // dependencies' setup before its own fires. In the field the auth lookup lands ~200ms\n        // and this DataSet's first fetch ~700-900ms, while mounted() runs immediately. d2.data()\n        // returns null for a module that is not defined YET, so bailing on the first miss threw\n        // \"dataset não disponível\" on every share link even though nothing was wrong.\n        // Poll briefly instead. Two lookups because either may win: the declared `shared` param,\n        // or the global-process path via the reference the instance binds — keep that binding.\n        let dead=false; d2.onUnmount(()=>{ dead=true; });\n        let ds=null;\n        for(let i=0;i<40 && !ds && !dead;i++){\n          ds=d2.data('shared')||d2.data('timeline-shared-sessionsDataSet');\n          if(!ds) await new Promise(r=>setTimeout(r,100));\n        }\n        if(dead) return;\n        if(!ds){ d2.setState({error:'Dataset de sessões compartilhadas não disponível.',ready:true}); return; }\n        // Name the router explicitly. Bare d2.router() looks for the conventional\n        // 'Router'/'MainRouter'; this one is 'timelineSimRouter', so the bare call can hand back a\n        // handle whose .params is empty — which is how a valid /sessions/<id> link ended up\n        // reporting \"o link não traz o identificador\". The goSim handler below already passes the\n        // ref for the same reason.\n        const rt=d2.router('timelineSimRouter')||d2.router();\n        const p=(rt&&rt.params)||{};\n        // ?id= lets the artboard be opened directly, outside the route.\n        let q=null;\n        try{ q=new URLSearchParams(window.location.search).get('id'); }catch(e){}\n        // Last resort, and the only one that cannot fail: the id is sitting in the path. Route\n        // params need the Router handle to resolve AND to have matched by the time mounted() runs\n        // (.params is a reactive getter, read once here); the pathname needs neither. Matches the\n        // current /sessions/:session_id route and the legacy /sessao/:id one.\n        let seg=null;\n        try{ const m=window.location.pathname.match(/\\/sess(?:ions|ao)\\/([^/?#]+)/); seg=m&&m[1]; }catch(e){}\n        const id=p.session_id||p.id||p.sessao||p.session||seg||q;\n        if(!id){ d2.setState({error:'O link não traz o identificador da sessão.',ready:true}); return; }\n        // .query() is a direct read and returns the rows — no refresh/loadAll ordering trap here\n        const rows=await ds.query({_id:id});\n        const rec=Array.isArray(rows)?rows[0]:(rows&&rows.data&&rows.data[0]);\n        d2.setState({rec:rec||null,ready:true,error:''});\n      }catch(err){\n        d2.setState({error:(err&&err.message)||'Falha ao carregar a sessão compartilhada.',ready:true});\n      }\n    })();\n  }\n}"}},"shared":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"shared"}}}}}}}}}},{"_id":"6a75620d9f1ece2343b4d4da","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"extends":"data2-core-primitives@Block","component":"cb-senso-de-rede","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// ---------- Senso de rede — afinidades entre consultoras de beleza ----------\n// Cada nó é uma CB; cada laço é uma AFINIDADE DERIVADA do snapshot mais recente, nunca um\n// cadastro de relacionamento — não existe tabela de \"quem conhece quem\". Por isso o peso de\n// cada dimensão é o INVERSO DA SUA FREQUÊNCIA na amostra: dividir a mesma líder diz muito mais\n// sobre duas CBs do que ambas estarem ativas no ciclo, e é o peso que decide a cor, a espessura\n// e quais laços sobrevivem ao corte por nó. Sem isso o grafo vira uma malha uniforme onde tudo\n// se liga a tudo e nada significa nada.\nconst T = 'product.perfil_cb.crm_perfil_cb_diaria_historico';\nconst RUSH_FALLBACK = 'AwW_11Nkg-dKksP7ic8V8';\nconst SNAP = `(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)`;\n// Paleta = cores de sistema do macOS, as mesmas do simulador de timeline, pra que os dois módulos\n// leiam como um produto só e não como duas ferramentas diferentes coladas.\nconst BG = '#f5f5f7';\n\nconst DIMS = [\n  { k:'origem',     label:'Origem',     color:'#ff9500', hint:'mesmo município' },\n  { k:'territorio', label:'Território', color:'#34c759', hint:'mesmo setor' },\n  { k:'rede',       label:'Rede',       color:'#007aff', hint:'mesma líder' },\n  { k:'trajetoria', label:'Trajetória', color:'#af52de', hint:'mesmo nível do plano' },\n  { k:'ritmo',      label:'Ritmo',      color:'#ff2d55', hint:'pedidos por ciclo parecidos' },\n  { k:'alcance',    label:'Alcance',    color:'#ffcc00', hint:'pontos parecidos' },\n  { k:'porte',      label:'Porte',      color:'#30b0c7', hint:'receita parecida' },\n  { k:'marca',      label:'Marca',      color:'#a2845e', hint:'mix Natura/Avon parecido' },\n  { k:'constancia', label:'Constância', color:'#8e8e93', hint:'ativas no ciclo' },\n];\nconst DIM_BY = {}; DIMS.forEach(d => DIM_BY[d.k] = d);\nconst TINTS = ['#e6f0ff','#e6f7ea','#fff2e0','#f6ecfd','#ffe9ee','#e6f5f9','#f2f2f7'];\n\nconst esc = s => (s == null ? '' : String(s)).replace(/[&<>\"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]));\nconst digits = s => String(s == null ? '' : s).replace(/[^0-9]/g, '');\nconst letters = s => String(s == null ? '' : s).replace(/[^A-Za-zÀ-ÿ]/g, '');\nconst hash = s => { let h = 0; const t = String(s || ''); for (let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) | 0; return Math.abs(h); };\nconst titleCase = s => String(s || '').toLowerCase().replace(/(^|[\\s'-])([a-zà-ÿ])/g, (m, a, b) => a + b.toUpperCase());\n\nfunction rowsOf(resp) {\n  const c = (resp.columns || []).map(x => x.name);\n  return (resp.rows || []).map(a => Object.fromEntries(c.map((k, i) => [k, a[i]])));\n}\nasync function q(sql) {\n  const ref = d2.params.consultaRush || RUSH_FALLBACK;\n  let dbx;\n  try {\n    const res = await d2.rush(ref).invoke({ sql });\n    dbx = res.data.result.data;              // envelope do listener: res.data, depois o payload do Databricks\n  } catch (e) {\n    throw new Error('consulta indisponível (rush ' + ref + '): ' + (e && e.message ? e.message : e));\n  }\n  if (dbx.state && dbx.state !== 'SUCCEEDED') {\n    throw new Error('Databricks ' + dbx.state + ': ' + ((dbx.error && dbx.error.message) || 'erro desconhecido'));\n  }\n  return dbx;\n}\n\nexport default {\n  css: `\n  :host{display:block;height:100%;--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--closing:#ff3b30}\n  *{box-sizing:border-box}\n  .root{display:flex;flex-direction:column;height:100%;background:var(--bg);color:var(--ink);\n    font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;-webkit-font-smoothing:antialiased}\n  header{padding:10px 16px;background:rgba(246,246,248,.85);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);\n    border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-wrap:wrap;flex:0 0 auto}\n  header h1{font-size:14.5px;margin:0;font-weight:600;letter-spacing:-.1px;color:var(--ink)}\n  .spacer{flex:1}\n  .pill{font-size:10.5px;padding:2.5px 9px;border-radius:99px;border:1px solid var(--line);color:var(--mut);background:var(--panel2);white-space:nowrap}\n  .pill b{color:var(--ink);font-weight:600}\n  .status{font-size:11px;color:var(--mut)}\n  .status.err{color:var(--closing)}\n  .btn{font:inherit;font-size:12px;padding:4.5px 11px;border-radius:7px;border:1px solid var(--line);background:var(--panel);color:var(--ink);cursor:pointer}\n  .btn:hover{border-color:#d5d5da;background:#fbfbfd}\n  .btn:active{background:#f0f0f4}\n  .btn:disabled{opacity:.5;cursor:progress}\n  .howto{padding:8px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:16px;flex-wrap:wrap;\n    font-size:11.5px;color:var(--mut);flex:0 0 auto}\n  .howto b{color:var(--ink)} .howto span{max-width:360px}\n  .body{flex:1;display:flex;min-height:0}\n  .stage{flex:1;position:relative;min-width:0}\n  canvas{width:100%;height:100%;display:block}\n  .side{width:334px;flex:0 0 334px;border-left:1px solid var(--line);padding:12px;display:flex;flex-direction:column;gap:12px;overflow:auto}\n  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 13px}\n  .card h2{margin:0 0 9px;font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);font-weight:600}\n  .card h3{margin:0 0 2px;font-size:15px;font-weight:600;letter-spacing:-.2px}\n  .card .sub{font-size:11.5px;color:var(--mut);margin-bottom:10px}\n  .facts{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px}\n  .fact{background:var(--panel2);border-radius:8px;padding:6px 8px}\n  .fact b{display:block;font-size:13px;font-weight:600}\n  .fact span{font-size:9.5px;color:var(--mut);letter-spacing:.05em;text-transform:uppercase}\n  .conn{border-top:1px solid var(--line);padding-top:8px}\n  .conn h4{margin:0 0 4px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);font-weight:600}\n  .crow{display:flex;align-items:center;gap:8px;padding:5px 6px;margin:0 -6px;cursor:pointer;border-radius:7px}\n  .crow:hover{background:var(--panel2)}\n  .cdot{width:22px;height:22px;border-radius:50%;flex:0 0 22px;display:flex;align-items:center;justify-content:center;\n    font-size:9px;font-weight:600;color:#3a3a3c;border:1px solid rgba(60,60,67,.12)}\n  .cname{flex:1;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n  .tags{display:flex;gap:3px;flex-wrap:wrap;justify-content:flex-end;max-width:110px}\n  .tag{width:7px;height:7px;border-radius:50%}\n  .hintline{font-size:11.5px;color:var(--mut);line-height:1.5}\n  .hintline b{color:var(--ink)}\n  .legend{display:flex;flex-wrap:wrap;gap:6px}\n  .chip{display:inline-flex;align-items:center;gap:6px;background:var(--panel2);border:1px solid var(--line);border-radius:99px;\n    padding:4px 10px 4px 7px;font:inherit;font-size:11.5px;color:var(--ink);cursor:pointer;user-select:none;\n    transition:opacity .12s,border-color .12s,color .12s}\n  .chip i{width:8px;height:8px;border-radius:50%;flex:0 0 8px}\n  .chip b{font-weight:500;color:var(--mut);font-size:10px}\n  .chip:hover{border-color:var(--acc);color:var(--acc)}\n  .chip:focus-visible{outline:2px solid rgba(0,122,255,.35);outline-offset:1px}\n  .chip.off{opacity:.4}\n  .tip{position:absolute;pointer-events:none;background:rgba(28,28,30,.94);color:#fff;border-radius:8px;padding:6px 9px;\n    font-size:11.5px;line-height:1.35;opacity:0;transition:opacity .1s;max-width:230px;z-index:5;box-shadow:0 8px 24px rgba(0,0,0,.18)}\n  .tip.on{opacity:1}\n  .tip em{font-style:normal;color:#aeaeb2;display:block;font-size:10.5px;margin-top:2px}\n  `,\n\n  // Shell estático de propósito: há um <canvas> aqui, e qualquer leitura reativa dentro de\n  // render() re-executa o render inteiro e devolve o canvas ao default 300x150 (checklist §15).\n  // Tudo que muda — contagem, cards, chips — é escrito por DOM dentro de mounted().\n  render() {\n    const chips = DIMS.map(d =>\n      `<button class=\"chip\" data-k=\"${d.k}\" title=\"${esc(d.hint)}\"><i style=\"background:${d.color}\"></i>${d.label} <b data-n=\"${d.k}\"></b></button>`\n    ).join('');\n    return `<div class=\"root\">\n      <header>\n        <h1>Senso de rede — consultoras</h1>\n        <span class=\"pill\"><b id=\"count\">—</b> pessoas</span>\n        <span class=\"status\" id=\"status\">consultando…</span>\n        <div class=\"spacer\"></div>\n        <button class=\"btn\" id=\"reload\">Recarregar</button>\n      </header>\n      <div class=\"howto\">\n        <span><b>1.</b> Cada nó é uma CB — o tamanho segue os pontos do ciclo, o anel marca quem está ativa.</span>\n        <span><b>2.</b> Cada laço é uma afinidade calculada; as contas no meio contam quantas dimensões as duas dividem.</span>\n        <span><b>3.</b> Clique num nó pra ver quem ele alcança — arraste pra reorganizar.</span>\n        <span><b>4.</b> Ligue e desligue dimensões nas marcações à direita.</span>\n      </div>\n      <div class=\"body\">\n        <div class=\"stage\" data-d2-static>\n          <canvas id=\"net\"></canvas>\n          <div class=\"tip\" id=\"tip\"></div>\n        </div>\n        <aside class=\"side\">\n          <section class=\"card\">\n            <h2>Afinidades</h2>\n            <div class=\"legend\" id=\"legend\">${chips}</div>\n          </section>\n          <section class=\"card\" id=\"detail\"></section>\n        </aside>\n      </div>\n    </div>`;\n  },\n\n  mounted(d2, root) {\n    const $ = s => root.querySelector(s);\n    const cv = $('#net'), ctx = cv.getContext('2d'), tip = $('#tip');\n    const S = {\n      nodes: [], edges: [], adj: new Map(), W: {}, on: {},\n      hover: null, sel: null, drag: null, alpha: 0, w: 0, h: 0\n    };\n    DIMS.forEach(d => S.on[d.k] = true);\n\n    const say = (msg, err) => { const el = $('#status'); el.textContent = msg || ''; el.classList.toggle('err', !!err); };\n\n    // ---------- canvas ----------\n    function resize() {\n      const r = cv.getBoundingClientRect();\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      S.w = r.width; S.h = r.height;\n      cv.width = Math.max(1, Math.round(r.width * dpr));\n      cv.height = Math.max(1, Math.round(r.height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);   // o resize zera a matriz — reaplicar sempre\n    }\n    const ro = new ResizeObserver(resize); ro.observe(cv);\n    d2.onUnmount(() => ro.disconnect());\n    resize();\n\n    // ---------- amostra ----------\n    async function nameColumn() {\n      // O nome da CB não está no contrato conhecido da tabela, então em vez de chutar um\n      // DS_NOME que pode não existir (e derrubar a query inteira), descobrimos a coluna.\n      try {\n        const d = await q(`DESCRIBE ${T}`);\n        const cols = rowsOf(d).map(r => String(r.col_name || r.name || '')).filter(Boolean);\n        const cand = cols.filter(c => /NOME|NAME/i.test(c) &&\n          !/LEADER|LIDER|MUNIC|ESTAD|SETOR|GRUPO|CANAL|MARCA|PAIS|REGIA|GV|NVL/i.test(c));\n        cand.sort((a, b) => (/CONSULT|CB|PESSOA/i.test(b) ? 1 : 0) - (/CONSULT|CB|PESSOA/i.test(a) ? 1 : 0));\n        return cand[0] || null;\n      } catch (e) { return null; }\n    }\n    function sqlFor(nomeCol, where, order, lim) {\n      const nome = nomeCol ? `MAX(${nomeCol}) nome,` : '';\n      return `SELECT CD_CONSULTORA cb, ${nome}\n        MAX(DS_MUNICIPIO) municipio, MAX(DS_ESTADO) uf, MAX(CD_SETOR) setor,\n        MAX(CD_GRUPO) grupo, MAX(DS_LEADER_NAME) lider, MAX(DS_NVL_PLAN_CRESC) nivel,\n        SUM(QT_PEDIDOS_CICLO) pedidos, SUM(QT_PONTOS_CICLO) pontos,\n        SUM(VL_PEDIDOS_NATURA_CICLO) rec_nat, SUM(VL_PEDIDOS_AVON_CICLO) rec_avon,\n        MAX(CASE WHEN ST_FEZ_PED_CICLO_ATUAL THEN 1 ELSE 0 END) ativa\n       FROM ${T} WHERE ${where} GROUP BY CD_CONSULTORA ${order} LIMIT ${lim}`;\n    }\n\n    // Faixas relativas à AMOSTRA, não absolutas: \"ritmo parecido\" só quer dizer alguma coisa\n    // comparado com as outras CBs desta consulta.\n    function bucketer(vals) {\n      const v = vals.filter(x => isFinite(x)).sort((a, b) => a - b);\n      if (!v.length) return () => 0;\n      const at = p => v[Math.min(v.length - 1, Math.floor(p * v.length))];\n      const q1 = at(.25), q2 = at(.5), q3 = at(.75);\n      return x => (x <= q1 ? 0 : x <= q2 ? 1 : x <= q3 ? 2 : 3);\n    }\n\n    // Amostra sintética — existe para revisar layout/física sem sessão do simulador (o listener do\n    // Databricks é gated). Nunca é o default e se anuncia na tela: um grafo bonito com gente que não\n    // existe é pior do que um erro honesto.\n    function demoRows(n) {\n      const NOMES = ['Ana Ribeiro','Marta Souza','Cláudia Lima','Rita Alves','Vera Nunes','Sônia Braga','Ivone Castro','Lúcia Prado','Neide Rocha','Tânia Melo','Dora Pires','Elis Faria','Bruna Antunes','Carla Moraes','Selma Dutra','Joana Bastos','Regina Paiva','Marli Cardoso','Fátima Goulart','Simone Teles'];\n      const MUN = ['Brasília','Formosa','Buritis','Unaí','Paracatu','Arinos','Riachinho','Bonfinópolis','Chapada Gaúcha','Urucuia'];\n      const NIV = ['Bronze','Cristal','Prata','Ouro','Diamante'];\n      const out = [];\n      for (let i = 0; i < n; i++) {\n        const h = hash('cb' + i);\n        const mun = MUN[h % MUN.length];\n        const setor = 3200 + ((h >> 3) % 6) * 7;\n        const grupo = 100 + ((h >> 5) % 14);\n        const escala = .25 + ((h >> 7) % 100) / 100 * 1.8;\n        out.push({\n          cb: 900000 + i,\n          nome: NOMES[h % NOMES.length] + ' ' + String.fromCharCode(65 + (h >> 11) % 26) + '.',\n          municipio: mun, uf: mun === 'Chapada Gaúcha' || mun === 'Urucuia' ? 'MG' : 'GO',\n          setor: setor, grupo: grupo, lider: 'Líder ' + grupo,\n          nivel: NIV[(h >> 13) % NIV.length],\n          pedidos: Math.round(4 + escala * 22), pontos: Math.round(120 + escala * 900),\n          rec_nat: Math.round(escala * 3200), rec_avon: Math.round(escala * 3200 * (((h >> 17) % 100) / 100)),\n          ativa: (h >> 19) % 10 > 2 ? 1 : 0\n        });\n      }\n      return out;\n    }\n\n    async function load() {\n      say('consultando o snapshot mais recente…');\n      const lim = Math.max(12, Math.min(400, +d2.param('limite', 82) || 82));\n      const setor = digits(d2.param('setor', ''));\n      const uf = letters(d2.param('uf', '')).toUpperCase();\n      const aleat = String(d2.param('amostra', 'top') || 'top').toLowerCase().indexOf('aleat') === 0;\n      let where = `CD_PAIS=1 AND CD_GRUPO>0 AND DT_HISTORICO=${SNAP}`;\n      if (setor) where += ` AND CD_SETOR=${setor}`;\n      if (uf) where += ` AND UPPER(DS_ESTADO)='${uf}'`;\n      const order = aleat ? 'ORDER BY RAND()' : 'ORDER BY pontos DESC';\n\n      // `amostra: \"demo\"` em vez de um param novo — a declaração já existe e o modo fica visível\n      // no mesmo lugar onde se escolhe top/aleatória, não escondido num booleano à parte.\n      const demo = String(d2.param('amostra', 'top') || '').toLowerCase().indexOf('demo') === 0;\n      let raw;\n      if (demo) {\n        raw = demoRows(lim);\n      } else {\n        const nc = await nameColumn();\n        const attempts = nc ? [nc, null] : [null];\n        let data = null, lastErr = null;\n        for (const col of attempts) {\n          try { data = await q(sqlFor(col, where, order, lim)); break; }\n          catch (e) { lastErr = e; }\n        }\n        if (!data) throw lastErr || new Error('sem resposta da consulta');\n        raw = rowsOf(data);\n      }\n      if (!raw.length) throw new Error('nenhuma CB encontrada com esses filtros' + (setor ? ' (setor ' + setor + ')' : '') + (uf ? ' (UF ' + uf + ')' : ''));\n\n      const bPed = bucketer(raw.map(r => +r.pedidos || 0));\n      const bPon = bucketer(raw.map(r => +r.pontos || 0));\n      const bRec = bucketer(raw.map(r => (+r.rec_nat || 0) + (+r.rec_avon || 0)));\n\n      S.nodes = raw.map((r, i) => {\n        const nat = +r.rec_nat || 0, avon = +r.rec_avon || 0, rec = nat + avon;\n        const nome = titleCase(r.nome || '');\n        const cb = String(r.cb == null ? '' : r.cb);\n        const ini = nome ? d2.fmt.initials(nome, 2) : cb.slice(-2);\n        return {\n          i, cb, nome, ini,\n          municipio: titleCase(r.municipio || ''), uf: String(r.uf || ''),\n          setor: String(r.setor == null ? '' : r.setor), grupo: String(r.grupo == null ? '' : r.grupo),\n          lider: titleCase(r.lider || ''), nivel: String(r.nivel || ''),\n          pedidos: +r.pedidos || 0, pontos: +r.pontos || 0, receita: rec,\n          ativa: +r.ativa === 1 ? 1 : 0,\n          bRitmo: bPed(+r.pedidos || 0), bAlcance: bPon(+r.pontos || 0), bPorte: bRec(rec),\n          bMarca: rec > 0 ? Math.min(3, Math.floor((nat / rec) * 4)) : null,\n          tint: TINTS[hash(String(r.setor) + '|' + String(r.grupo)) % TINTS.length],\n          r: 0, x: 0, y: 0, vx: 0, vy: 0\n        };\n      });\n\n      const maxP = Math.max(...S.nodes.map(n => n.pontos), 1);\n      S.nodes.forEach(n => {\n        n.r = 7 + 17 * Math.sqrt(Math.max(0, n.pontos) / maxP);\n        const a = (n.i / S.nodes.length) * Math.PI * 2, rad = 140 + (hash(n.cb) % 220);\n        n.x = S.w / 2 + Math.cos(a) * rad; n.y = S.h / 2 + Math.sin(a) * rad;\n      });\n\n      DIMS.forEach(d => S.W[d.k] = weightOf(d.k));\n      rebuild();\n      $('#count').textContent = d2.fmt.number(S.nodes.length);\n      say((demo ? '⚠ AMOSTRA SINTÉTICA (dadosDemo) — ' : '') + `${S.nodes.length} CBs · ${S.edges.length} laços · ${S.nodes.filter(n => n.ativa).length} ativas no ciclo`, demo);\n      renderDetail(null);\n    }\n\n    // ---------- afinidades ----------\n    function keyOf(k, n) {\n      if (k === 'origem') return n.municipio ? n.uf + '/' + n.municipio : null;\n      if (k === 'territorio') return n.setor || null;\n      if (k === 'rede') return n.grupo || null;\n      if (k === 'trajetoria') return n.nivel || null;\n      if (k === 'ritmo') return 'r' + n.bRitmo;\n      if (k === 'alcance') return 'a' + n.bAlcance;\n      if (k === 'porte') return 'p' + n.bPorte;\n      if (k === 'marca') return n.bMarca == null ? null : 'm' + n.bMarca;\n      if (k === 'constancia') return n.ativa ? 'ativa' : null;\n      return null;\n    }\n    // Peso = raridade. p = chance de dois nós sorteados caírem no mesmo grupo desta dimensão;\n    // log2(1/p) transforma isso em bits de informação, então \"mesma líder\" pesa muito mais que\n    // \"ambas ativas\" sem que ninguém precise arbitrar uma tabela de pesos à mão.\n    function weightOf(k) {\n      const g = {}, n = S.nodes.length;\n      S.nodes.forEach(x => { const key = keyOf(k, x); if (key != null) g[key] = (g[key] || 0) + 1; });\n      let p = 0; Object.values(g).forEach(c => { p += (c / n) * (c / n); });\n      return p > 0 ? Math.max(.35, Math.min(6, Math.log2(1 / p))) : 0;\n    }\n    function sharedDims(a, b) {\n      const out = [];\n      for (const d of DIMS) {\n        if (!S.on[d.k]) continue;\n        const ka = keyOf(d.k, a); if (ka == null) continue;\n        if (ka === keyOf(d.k, b)) out.push(d.k);\n      }\n      return out;\n    }\n    function rebuild() {\n      const ns = S.nodes, K = Math.max(1, Math.min(8, +d2.param('lacos', 4) || 4));\n      const cand = ns.map(() => []);\n      for (let i = 0; i < ns.length; i++) {\n        for (let j = i + 1; j < ns.length; j++) {\n          const dims = sharedDims(ns[i], ns[j]);\n          if (!dims.length) continue;\n          let sc = 0; for (const k of dims) sc += S.W[k] || 0;\n          if (sc <= 0) continue;\n          cand[i].push({ o: j, sc, dims }); cand[j].push({ o: i, sc, dims });\n        }\n      }\n      // Corte por nó, não global: um corte global deixaria as CBs de município grande com\n      // dezenas de laços e as demais órfãs — cada uma guarda seus K vínculos mais fortes.\n      const seen = new Set(), out = [];\n      cand.forEach((list, i) => {\n        list.sort((a, b) => b.sc - a.sc).slice(0, K).forEach(e => {\n          const a = Math.min(i, e.o), b = Math.max(i, e.o), key = a + ':' + b;\n          if (seen.has(key)) return; seen.add(key);\n          const top = e.dims.slice().sort((x, y) => (S.W[y] || 0) - (S.W[x] || 0))[0];\n          out.push({ a, b, sc: e.sc, dims: e.dims, top });\n        });\n      });\n      S.edges = out;\n      S.adj = new Map();\n      out.forEach((e, idx) => {\n        if (!S.adj.has(e.a)) S.adj.set(e.a, []);\n        if (!S.adj.has(e.b)) S.adj.set(e.b, []);\n        S.adj.get(e.a).push(idx); S.adj.get(e.b).push(idx);\n      });\n      const maxSc = Math.max(...out.map(e => e.sc), 1);\n      out.forEach(e => e.n = e.sc / maxSc);\n      const counts = {}; DIMS.forEach(d => counts[d.k] = 0);\n      out.forEach(e => e.dims.forEach(k => counts[k]++));\n      DIMS.forEach(d => { const el = root.querySelector(`[data-n=\"${d.k}\"]`); if (el) el.textContent = counts[d.k] ? counts[d.k] : ''; });\n      S.alpha = 1;\n    }\n\n    // ---------- física ----------\n    function tick() {\n      const ns = S.nodes, cx = S.w / 2, cy = S.h / 2;\n      if (S.alpha < .02 || !ns.length) return;\n      const k = S.alpha;\n      for (let i = 0; i < ns.length; i++) {\n        const a = ns[i];\n        for (let j = i + 1; j < ns.length; j++) {\n          const b = ns[j];\n          let dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy);\n          if (d < .01) {   // coincidentes: desempata com um deslocamento determinístico\n            dx = ((hash(a.cb + b.cb) % 21) - 10) / 10;\n            dy = ((hash(b.cb + a.cb) % 21) - 10) / 10;\n            d = Math.hypot(dx, dy) || 1;\n          }\n          const ux = dx / d, uy = dy / d;\n          const rep = Math.min(4, 9000 / (d * d)) * k;\n          a.vx -= ux * rep; a.vy -= uy * rep;\n          b.vx += ux * rep; b.vy += uy * rep;\n          // Colisão dura, resolvida na POSIÇÃO e não na velocidade: só repulsão deixa os círculos\n          // se sobreporem enquanto o sistema esfria, e as iniciais viram sopa.\n          const min = a.r + b.r + 6;\n          if (d < min) {\n            const push = (min - d) * .5;\n            a.x -= ux * push; a.y -= uy * push;\n            b.x += ux * push; b.y += uy * push;\n          }\n        }\n      }\n      for (const e of S.edges) {\n        const a = ns[e.a], b = ns[e.b];\n        const dx = b.x - a.x, dy = b.y - a.y, d = Math.max(.01, Math.hypot(dx, dy));\n        const target = 170 - 95 * e.n;          // laço forte = mais curto\n        const f = (d - target) * .022 * (.4 + .6 * e.n) * k;\n        const ux = dx / d, uy = dy / d;\n        a.vx += ux * f; a.vy += uy * f;\n        b.vx -= ux * f; b.vy -= uy * f;\n      }\n      for (const n of ns) {\n        if (S.drag === n) { n.vx = 0; n.vy = 0; continue; }\n        n.vx += (cx - n.x) * .002 * k; n.vy += (cy - n.y) * .002 * k;\n        n.vx *= .84; n.vy *= .84;\n        n.x += Math.max(-9, Math.min(9, n.vx));\n        n.y += Math.max(-9, Math.min(9, n.vy));\n      }\n      S.alpha *= .994;\n    }\n\n    // Enquadramento roda TODO frame, fora do laço de física: se ficasse dentro, congelaria junto\n    // com o alpha e o grafo terminaria transbordando o palco (ou torto) exatamente onde parou.\n    // Também é o que reenquadra depois de um resize, sem precisar reaquecer a simulação.\n    function fit() {\n      const ns = S.nodes;\n      if (!ns.length || S.drag) return;\n      let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;\n      for (const n of ns) {\n        x0 = Math.min(x0, n.x - n.r); y0 = Math.min(y0, n.y - n.r);\n        x1 = Math.max(x1, n.x + n.r); y1 = Math.max(y1, n.y + n.r);\n      }\n      const cx = S.w / 2, cy = S.h / 2;\n      const ox = cx - (x0 + x1) / 2, oy = cy - (y0 + y1) / 2;\n      if (Math.abs(ox) > .4 || Math.abs(oy) > .4) for (const n of ns) { n.x += ox * .12; n.y += oy * .12; }\n      const sc = Math.min(1, (S.w - 56) / Math.max(1, x1 - x0), (S.h - 56) / Math.max(1, y1 - y0));\n      if (sc < .999) for (const n of ns) {\n        n.x = cx + (n.x - cx) * (1 - (1 - sc) * .12);\n        n.y = cy + (n.y - cy) * (1 - (1 - sc) * .12);\n      }\n    }\n\n    // ---------- desenho ----------\n    function draw() {\n      ctx.clearRect(0, 0, S.w, S.h);\n      ctx.fillStyle = BG; ctx.fillRect(0, 0, S.w, S.h);\n      if (!S.nodes.length) return;\n      const focus = S.sel != null ? S.sel : S.hover;\n      const near = new Set();\n      if (focus != null) { near.add(focus); (S.adj.get(focus) || []).forEach(i => { near.add(S.edges[i].a); near.add(S.edges[i].b); }); }\n\n      S.edges.forEach((e, idx) => {\n        const a = S.nodes[e.a], b = S.nodes[e.b];\n        const lit = focus == null || e.a === focus || e.b === focus;\n        const col = (DIM_BY[e.top] || DIMS[0]).color;\n        ctx.globalAlpha = focus == null ? .16 + .34 * e.n : (lit ? .5 + .4 * e.n : .05);\n        ctx.strokeStyle = col;\n        ctx.lineWidth = .5 + 1.7 * e.n;\n        ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();\n        // uma conta por dimensão compartilhada, no meio do laço — é a leitura rápida de\n        // \"quantas coisas estas duas têm em comum\" sem precisar do hover\n        const dims = e.dims;\n        for (let i = 0; i < dims.length; i++) {\n          const t = (i + 1) / (dims.length + 1);\n          ctx.globalAlpha = focus == null ? .55 : (lit ? .95 : .08);\n          ctx.fillStyle = (DIM_BY[dims[i]] || DIMS[0]).color;\n          ctx.beginPath(); ctx.arc(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, 1.9 + 1.3 * e.n, 0, 6.284); ctx.fill();\n        }\n      });\n\n      S.nodes.forEach(n => {\n        const dim = focus != null && !near.has(n.i);\n        ctx.globalAlpha = dim ? .18 : 1;\n        ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, 6.284);\n        ctx.fillStyle = n.tint; ctx.fill();\n        ctx.lineWidth = n.i === focus ? 2.5 : 1;\n        ctx.strokeStyle = n.i === focus ? '#007aff' : (n.ativa ? 'rgba(60,60,67,.38)' : 'rgba(60,60,67,.14)');\n        ctx.stroke();\n        if (n.r >= 12) {   // nós pequenos ficam como pontos, sem inicial ilegível dentro\n          ctx.globalAlpha = dim ? .2 : .85;\n          ctx.fillStyle = '#3a3a3c';\n          ctx.font = `600 ${Math.round(n.r * .78)}px -apple-system,\"Segoe UI\",Roboto,sans-serif`;\n          ctx.textAlign = 'center'; ctx.textBaseline = 'middle';\n          ctx.fillText(n.ini, n.x, n.y + .5);\n        }\n      });\n      ctx.globalAlpha = 1;\n    }\n\n    d2.raf(() => { tick(); fit(); draw(); });\n\n    // ---------- interação ----------\n    function hit(px, py) {\n      let best = null, bd = Infinity;\n      for (const n of S.nodes) {\n        const d = Math.hypot(n.x - px, n.y - py);\n        if (d < n.r + 5 && d < bd) { bd = d; best = n; }\n      }\n      return best;\n    }\n    const rel = ev => { const r = cv.getBoundingClientRect(); return [ev.clientX - r.left, ev.clientY - r.top]; };\n\n    d2.on(cv, 'pointermove', ev => {\n      const [x, y] = rel(ev);\n      if (S.drag) { S.drag.x = x; S.drag.y = y; S.alpha = Math.max(S.alpha, .35); return; }\n      const n = hit(x, y);\n      S.hover = n ? n.i : null;\n      cv.style.cursor = n ? 'pointer' : 'default';\n      if (n) {\n        tip.classList.add('on');\n        tip.innerHTML = `<b>${esc(n.nome || 'CB ' + n.cb)}</b><em>${esc(n.municipio)}${n.uf ? '/' + esc(n.uf) : ''} · setor ${esc(n.setor)} · ${d2.fmt.number(n.pontos)} pontos</em>`;\n        const tw = tip.offsetWidth || 200;\n        tip.style.left = Math.max(6, Math.min(S.w - tw - 6, x + 14)) + 'px';\n        tip.style.top = Math.max(6, y - 42) + 'px';\n      } else tip.classList.remove('on');\n    });\n    d2.on(cv, 'pointerdown', ev => {\n      const [x, y] = rel(ev); const n = hit(x, y);\n      if (n) { S.drag = n; cv.setPointerCapture(ev.pointerId); }\n    });\n    d2.on(cv, 'pointerup', ev => {\n      const [x, y] = rel(ev); const n = hit(x, y);\n      S.drag = null;\n      if (n) { S.sel = (S.sel === n.i ? null : n.i); renderDetail(S.sel); }\n      else { S.sel = null; renderDetail(null); }\n    });\n    d2.on(cv, 'pointerleave', () => { S.hover = null; S.drag = null; tip.classList.remove('on'); });\n\n    d2.on('.chip', 'click', (e, el) => {\n      const k = el.dataset.k;\n      S.on[k] = !S.on[k];\n      el.classList.toggle('off', !S.on[k]);\n      if (S.nodes.length) { rebuild(); renderDetail(S.sel); }\n    });\n    d2.on('.crow', 'click', (e, el) => { S.sel = +el.dataset.i; renderDetail(S.sel); });\n    d2.on('#reload', 'click', (e, el) => {\n      el.disabled = true;\n      S.sel = null; S.hover = null;\n      load().catch(err => say(err && err.message ? err.message : String(err), true))\n        .then(() => { el.disabled = false; });\n    });\n\n    // ---------- painel ----------\n    function renderDetail(i) {\n      const el = $('#detail');\n      if (i == null || !S.nodes[i]) {\n        const on = DIMS.filter(d => S.on[d.k]);\n        el.innerHTML = `<div class=\"hintline\">Cada laço é uma afinidade calculada, não um cadastro: duas CBs se ligam quando compartilham origem, território, líder, trajetória ou um padrão de trabalho parecido.<br><br>O peso de cada dimensão é o inverso da sua frequência — por isso <b>${esc((on.slice().sort((a, b) => (S.W[b.k] || 0) - (S.W[a.k] || 0))[0] || {}).label || '—')}</b> puxa mais que as demais nesta amostra.<br><br>Clique num nó para ver quem ele alcança. Arraste para reorganizar.</div>`;\n        return;\n      }\n      const n = S.nodes[i];\n      const mine = (S.adj.get(i) || []).map(x => S.edges[x]).sort((a, b) => b.sc - a.sc).slice(0, 8);\n      const fact = (v, l) => `<div class=\"fact\"><b>${v}</b><span>${l}</span></div>`;        el.innerHTML = `<div>\n        <h3>${esc(n.nome || 'CB ' + n.cb)}</h3>\n        <div class=\"sub\">${esc(n.municipio)}${n.uf ? ' · ' + esc(n.uf) : ''} · setor ${esc(n.setor)}${n.lider ? ' · líder ' + esc(n.lider) : ''}</div>\n        <div class=\"facts\">\n          ${fact(d2.fmt.number(n.pontos), 'pontos')}\n          ${fact(d2.fmt.number(n.pedidos), 'pedidos')}\n          ${fact(d2.fmt.currency(n.receita, 'BRL'), 'receita')}\n          ${fact(esc(n.nivel || '—'), 'nível')}\n        </div>\n        <div class=\"conn\"><h4>${mine.length} afinidades mais fortes</h4>\n          ${mine.map(e => {\n            const o = S.nodes[e.a === i ? e.b : e.a];\n            return `<div class=\"crow\" data-i=\"${o.i}\" title=\"${esc(e.dims.map(k => (DIM_BY[k] || {}).hint).join(' · '))}\">\n              <span class=\"cdot\" style=\"background:${o.tint}\">${esc(o.ini)}</span>\n              <span class=\"cname\">${esc(o.nome || 'CB ' + o.cb)}</span>\n              <span class=\"tags\">${e.dims.map(k => `<i class=\"tag\" style=\"background:${(DIM_BY[k] || {}).color}\"></i>`).join('')}</span>\n            </div>`;\n          }).join('')}\n        </div>\n      </div>`;\n    }\n\n    load().catch(err => {\n      say(err && err.message ? err.message : String(err), true);\n      $('#detail').innerHTML = `<div class=\"hintline\">A consulta roda no listener do Databricks, que exige sessão do simulador. Se a mensagem acima fala em 401/permissão, é preciso estar logado no app.</div>`;\n    });\n  }\n};"}},"limite":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"limite"}}}},"setor":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"setor"}}}},"uf":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"uf"}}}},"amostra":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"amostra"}}}},"lacos":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"lacos"}}}},"consultaRush":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"consultaRush"}}}}}}}}}},{"_id":"6a7a38bbc9940e37623ae676","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"bsaEubO9EjIykK7MU-EnV","package":"natura-geomarketing","extends":"data2-core-primitives@Block","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const T='product.perfil_cb.crm_perfil_cb_diaria_historico';\nconst ELO='product.elo_modelo_combinado.commercial_consultant_index';\nconst PALETTE=['#5856d6','#ff9500','#34c759','#af52de','#ff2d55','#a2845e'];\nconst CLOSE_COLOR='#8e8e93';\nconst UF_CODE={RO:'11',AC:'12',AM:'13',RR:'14',PA:'15',AP:'16',TO:'17',MA:'21',PI:'22',CE:'23',RN:'24',PB:'25',PE:'26',AL:'27',SE:'28',BA:'29',MG:'31',ES:'32',RJ:'33',SP:'35',PR:'41',SC:'42',RS:'43',MS:'50',MT:'51',GO:'52',DF:'53'};\nconst UF_BORDERS={AC:['AM','RO'],AL:['BA','PE','SE'],AM:['AC','RO','RR','PA','MT'],AP:['PA'],\n BA:['AL','SE','PE','PI','TO','GO','MG','ES'],CE:['PI','PB','PE','RN'],DF:['GO','MG'],\n ES:['BA','MG','RJ'],GO:['DF','MT','MS','MG','BA','TO'],MA:['PA','TO','PI'],\n MG:['BA','GO','DF','MS','SP','RJ','ES'],MS:['MT','GO','MG','SP','PR'],MT:['RO','AM','PA','TO','GO','MS'],\n PA:['AP','AM','MT','TO','MA','RR'],PB:['RN','CE','PE'],PE:['PB','CE','PI','BA','AL'],\n PI:['MA','TO','BA','PE','CE'],PR:['SP','MS','SC'],RJ:['ES','MG','SP'],RN:['CE','PB'],\n RO:['AC','AM','MT'],RR:['AM','PA'],RS:['SC'],SC:['PR','RS'],SE:['BA','AL'],\n SP:['MG','RJ','PR','MS'],TO:['PA','MA','PI','BA','GO','MT']};\nconst NIVEIS=['Bronze','Cristal','Prata','Ouro','Diamante','Diamante+'];\nconst METRICS_DEF=[['cbs','CBs'],['ativas','Ativas'],['pedidos','Pedidos'],['pontos','Pontos'],['receita','Receita','$'],['rec_nat','Rec. Natura','$'],['rec_avon','Rec. Avon','$']];\nconst OPT_K=1.5;\nconst SESSIONS_DS='timeline-shared-sessionsDataSet';   // same table as SHARED_DS — see notes at listSessions()\n// Span defaults are the real operating band for a setor receptor, not placeholders: 2200 ≤ span ≤ 3500\n// with 2700 as the target. null meant \"sem limite\", so every new session started with the optimiser\n// balancing against nothing. Existing sessions are NOT touched — their persisted settings carry explicit\n// values that spread OVER these in openOptions/applyOptions, which is deliberate: their etapas were\n// computed under the old band and changing it silently would leave the stored result and the rule that\n// produced it out of sync.\nconst DEFAULT_SETTINGS={destRule:'any',destMinCbs:9,targetSpan:2700,maxSpan:3500,minSpan:2200};\nconst DOM_MEDIA_BASE='https://natura-geomarketing.data2.media/file/';\nconst DOM_CSV_FALLBACK=DOM_MEDIA_BASE+'yw_TQdp0QXEk6JsrliAqP'; \nconst numOrNull=v=>{const n=Math.round(+v);return (isFinite(n)&&n>0)?n:null;};\nconst spanShort=st=>{const p=[];if(st&&st.targetSpan)p.push('alvo '+st.targetSpan);if(st&&st.maxSpan)p.push('máx '+st.maxSpan);if(st&&st.minSpan)p.push('mín '+st.minSpan);return p.length?p.join(' · '):'livre';};\nconst ruleShort=st=>((st&&st.destRule)==='dominant')?('dominantes ≥'+((st&&st.destMinCbs)||DEFAULT_SETTINGS.destMinCbs)+' CBs'):'qualquer municipio';\n\nexport default {\n  css: `\n  :host{display:block;height:100%;--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--acc2:#0060df;--closing:#ff3b30;--ok:#34c759}\n  *{box-sizing:border-box}\n  .simroot{position:relative;height:100%;overflow:auto;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}\n  header{padding:10px 16px;background:rgba(246,246,248,.85);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-wrap:wrap}\n  header h1{font-size:14.5px;margin:0;font-weight:600;letter-spacing:-.1px;color:var(--ink)}\n  .mac-spacer{flex:1}\n  .howto{padding:8px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--mut);align-items:center}\n  .howto b{color:var(--ink)} .howto span{max-width:340px}\n  .pill{font-size:10.5px;padding:2.5px 9px;border-radius:99px;border:1px solid var(--line);color:var(--mut);background:var(--panel2)}\n  .pill.live{color:var(--ink);cursor:pointer;user-select:none;transition:border-color .1s,color .1s,background .1s}\n  .pill.live:hover{border-color:var(--acc);color:var(--acc);background:#eaf3ff}\n  .pill.live:focus-visible{outline:2px solid rgba(0,122,255,.35);outline-offset:1px}\n  .pill.live.busy{border-color:var(--acc);color:var(--acc);cursor:progress}\n  .status{font-size:11px;color:var(--mut)}.status.err{color:var(--closing)}\n  select,input,textarea,button{font:inherit}\n  select,input{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:7px;padding:5.5px 9px}\n  select:focus,input:focus{outline:2px solid rgba(0,122,255,.35);outline-offset:0;border-color:var(--acc)}\n  button.act{background:var(--acc);color:#fff;border:1px solid transparent;border-radius:7px;padding:6px 13px;font-weight:600;cursor:pointer;box-shadow:0 1px 1px rgba(0,0,0,.08)}\n  button.act:hover{background:var(--acc2)}\n  button.ghost{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5.5px 12px;cursor:pointer}\n  button.ghost:hover{background:var(--panel2)}\n  button.ghost.on{border-color:var(--acc);color:var(--acc);background:#eaf3ff}\n  button.ghost:disabled,button.act:disabled{opacity:.4;cursor:not-allowed}\n  .sessbar{padding:9px 20px;background:var(--panel2);border-bottom:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap;align-items:center}\n  .rail{padding:9px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:8px;align-items:center;overflow-x:auto}\n  #movementChips{display:flex;gap:8px;flex:0 0 auto}\n  #addMovementGroup{display:flex;gap:8px;align-items:center;flex:0 0 auto;margin-left:auto;padding-left:12px;border-left:1px solid var(--line)}\n  #addMovementGroup input{min-width:150px}\n  .tchip{display:flex;align-items:center;gap:7px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;cursor:pointer;font-size:12px;flex:0 0 auto}\n  .tchip.on{border-color:var(--acc);background:#eaf3ff}\n  .tchip.stale{border-style:dashed;opacity:.85}\n  .tchip .tstale{color:#ff9500;font-weight:700}\n  .tchip .tnum{background:var(--ink);color:#fff;border-radius:99px;width:16px;height:16px;font-size:9.5px;display:flex;align-items:center;justify-content:center;font-weight:700}\n  .tchip .tsec{font-weight:600}\n  .tchip .tstat{color:var(--mut)}\n  .tchip .trm{color:var(--mut);font-weight:700;padding:0 3px}\n  .tchip .trm:hover{color:var(--closing)}\n  .rail .empty{color:var(--mut);font-size:12px}\n  .wrap{display:grid;grid-template-columns:minmax(360px,1fr) 430px;gap:14px;padding:14px 16px;align-items:start}\n  @media(max-width:1150px){.wrap{grid-template-columns:1fr}}\n  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;margin-bottom:14px;box-shadow:0 1px 2px rgba(0,0,0,.04)}\n  .card h2{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);margin:0 0 9px;font-weight:600}\n  /* user-select, not pointer-events. .lbl already sets pointer-events:none, which stops the text from\n     RECEIVING clicks but does not exclude it from the browser's selection range — so cycling a\n     município's destination with repeated clicks kept dragging a double-click selection across the\n     labels. Nothing drawn in an svg here is text anyone needs to copy. */\n  svg{width:100%;height:auto;display:block;user-select:none;-webkit-user-select:none}\n  /* White hairlines inside the closing sector: the municipios read as one territory cut into parts\n     rather than as separate outlined units, and the saturated fills carry the grouping instead of the\n     ink. Neighbour sectors keep their own coloured strokes (.sect) — that contrast is what separates\n     what is being reallocated from what is only context. Selection no longer draws an outline; the\n     white border was the click affordance AND the default is now white, so it had nothing left to say. */\n  .muni{stroke:#fff;stroke-width:1.1;cursor:pointer;transition:filter .1s}.muni:hover{filter:brightness(1.15)}\n  .sect{stroke-width:1;fill-opacity:.32;stroke-opacity:.6}\n  .sect.plain{fill:#e8e8ec;stroke:#d4d4d9;fill-opacity:1;stroke-opacity:1}\n  .sect.closed-ctx{fill:#e2e2e7;stroke:#aeaeb2;stroke-dasharray:3 2;fill-opacity:1;stroke-opacity:1}\n  .lbl{pointer-events:none}\n  /* No plate behind the text: a halo painted UNDER the glyphs (paint-order) keeps a name legible on any\n     fill without stamping an opaque box over the territory it names. Ink and halo are chosen per label\n     from the luminance of the fill underneath, so they are deliberately NOT declared here — a\n     stylesheet rule outranks a presentation attribute and would silently override that choice. */\n  .lbl text{text-anchor:middle;paint-order:stroke fill;stroke-width:2.2px;stroke-linejoin:round;stroke-linecap:round}\n  .lbl.big text{stroke-width:2.9px}\n  .lbl line.lead{stroke-opacity:.5;stroke-width:.8}\n  .legend{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;font-size:12px}\n  .legend b{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:5px;vertical-align:-1px}\n  .kpis{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-bottom:12px}\n  .kpi{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:9px 11px}\n  .kpi .n{font-size:19px;font-weight:700}.kpi .l{font-size:10.5px;color:var(--mut);margin-top:2px}\n  .kpi.warn{border-color:#ffd1cc}.kpi.warn .n{color:var(--closing)}.kpi.ok{border-color:#bdeecb}.kpi.ok .n{color:var(--ok)}\n  .bar-row{display:flex;align-items:center;gap:8px;margin:6px 0}\n  .bar-row .tag{width:42px;font-weight:600;font-size:12px}\n  .bar-track{flex:1;height:18px;background:var(--panel2);border-radius:5px;overflow:hidden;display:flex;position:relative}\n  .bar-mark{position:absolute;top:0;bottom:0;width:2px;opacity:.9;z-index:2}\n  .bar-base{height:100%;opacity:.4}.bar-inc{height:100%}\n  .bar-val{width:132px;text-align:right;font-size:11px;color:var(--mut);font-variant-numeric:tabular-nums}\n  table{border-collapse:collapse;width:100%;font-size:11.5px}\n  th,td{padding:5px 6px;text-align:right;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}\n  th:first-child,td:first-child{text-align:left}\n  thead th{color:var(--mut);font-weight:600;font-size:10.5px;position:sticky;top:0;background:var(--panel)}\n  .home{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:4px}\n  .muni-item{display:flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:5px 8px;margin-bottom:6px}\n  .muni-item .nm{flex:1;font-weight:600;font-size:12px}.muni-item .ct{color:var(--mut);font-size:11px}\n  .seg{display:flex;border:1px solid var(--line);border-radius:7px;overflow:hidden}\n  .seg button{border:0;background:transparent;color:var(--mut);padding:4px 8px;font-size:11px;font-weight:600;cursor:pointer}\n  .seg button.on{color:#fff}\n  .toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}\n  .toggle{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;font-size:12px}\n  .toggle button{background:transparent;border:0;color:var(--mut);padding:6px 10px;cursor:pointer;font-weight:600}\n  .toggle button.on{background:var(--panel2);color:var(--ink)}\n  .note{color:var(--mut);font-size:10.5px;margin-top:7px;line-height:1.5}\n  .cell{font-variant-numeric:tabular-nums}\n  .scroll{max-height:240px;overflow:auto}\n  /* Tables meant to be READ in full rather than sampled. Only the vertical cap is dropped — overflow\n     stays auto so the wide grupo grid can still scroll sideways inside its card, which is the one axis\n     that genuinely cannot fit. */\n  .scroll.grow{max-height:none}\n  .tip{position:fixed;pointer-events:none;background:#1c1c1e;color:#fff;border-radius:8px;padding:7px 9px;font-size:11px;opacity:0;transition:opacity .1s;z-index:9;max-width:230px}\n  .spark{display:flex;align-items:flex-end;gap:3px;height:44px}\n  .spark .b{flex:1;background:var(--acc);border-radius:2px 2px 0 0;min-height:2px;opacity:.85}\n  .delta{font-size:10px}.delta.up{color:var(--ok)}.delta.dn{color:var(--closing)}\n  .tabs{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}\n  .tabs button{background:transparent;border:1px solid var(--line);border-radius:8px;color:var(--mut);padding:6px 12px;cursor:pointer;font-weight:600}\n  .tabs button.on{background:var(--panel2);color:var(--ink);border-color:var(--acc)}\n  .hidden{display:none}\n  .sqlbox{width:100%;height:60px;font-family:ui-monospace,Menlo,monospace;font-size:11px;resize:vertical}\n  .load{position:absolute;inset:0;background:rgba(245,245,247,.85);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:20;font-size:13px;color:var(--acc);font-weight:600;text-align:center;padding:20px}\n  .load.hidden{display:none}\n  .badge{font-size:10px;padding:1px 6px;border-radius:5px;background:var(--panel2);border:1px solid var(--line);color:var(--mut)}\n  .sheet-bg{position:fixed;inset:0;background:rgba(28,28,30,.34);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;z-index:40;padding:24px}\n  .sheet-bg.hidden{display:none}\n  .sheet{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:0 24px 60px rgba(0,0,0,.22);width:min(580px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden}\n  .sheet-hd{display:flex;align-items:center;gap:9px;padding:12px 16px;border-bottom:1px solid var(--line)}\n  .sheet-hd h3{margin:0;font-size:14px;font-weight:600;letter-spacing:-.1px}\n  .sheet-x{background:transparent;border:0;color:var(--mut);font-size:13px;cursor:pointer;padding:3px 7px;border-radius:6px}\n  .sheet-x:hover{background:var(--panel2);color:var(--ink)}\n  .sheet-bd{padding:14px 16px;overflow:auto}\n  .sheet-ft{display:flex;align-items:center;gap:8px;padding:11px 16px;border-top:1px solid var(--line);background:var(--panel2)}\n  .opt-lbl{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600}\n  .opt-sub{font-size:11.5px;color:var(--mut);margin:4px 0 10px;line-height:1.5}\n  .opt-row{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-bottom:8px;cursor:pointer;background:var(--panel)}\n  .opt-row:hover{background:var(--panel2)}\n  .opt-row.on{border-color:var(--acc);background:#eaf3ff}\n  .opt-row input[type=radio]{margin:2px 0 0;accent-color:var(--acc);flex:0 0 auto}\n  .opt-t{font-size:12.5px;font-weight:600;display:flex;align-items:center;gap:6px;flex-wrap:wrap}\n  .opt-d{font-size:11.5px;color:var(--mut);margin-top:3px;line-height:1.55}\n  .opt-inline{font-size:11.5px;color:var(--mut);margin-top:9px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}\n  .opt-inline.off{opacity:.4;pointer-events:none}\n  .opt-note{border:1px solid var(--line);background:var(--panel2);border-radius:10px;padding:9px 11px;font-size:11.5px;color:var(--mut);line-height:1.6;margin-top:12px}\n  .opt-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:2px}\n  .opt-fld{display:flex;flex-direction:column;gap:5px;border:1px solid var(--line);border-radius:9px;padding:8px 10px;background:var(--panel2)}\n  .opt-fld span{font-size:11px;color:var(--mut);font-weight:600;line-height:1.35}\n  .opt-fld i{font-style:normal;font-weight:400;opacity:.72}\n  .opt-fld input{width:100%;background:var(--panel)}\n  .opt-err{margin-top:9px;font-size:11.5px;color:var(--closing);font-weight:600}\n  .opt-hr{height:1px;background:var(--line);margin:17px 0 0}\n  .shareurl{margin-top:9px;padding:9px 10px;border-radius:8px;background:var(--panel);border:1px solid var(--acc);\n    font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--ink);word-break:break-all;user-select:all}\n`,\n  render(d2){\n    return `<div class=\"simroot\"><header id=\"topHeader\" class=\"hidden\">\n  <h1>Simulador de Timeline de Fechamentos</h1>\n  <span class=\"pill live\" id=\"liveState\" role=\"button\" tabindex=\"0\" title=\"Clique pra buscar o snapshot mais recente e recalcular a timeline inteira\">● iniciando</span>\n  <span class=\"pill ro\">read-only · nunca escreve</span>\n  <span class=\"pill\" id=\"basePill\"></span>\n  <span class=\"pill\" id=\"rulePill\"></span>\n  <span class=\"pill\" id=\"spanPill\"></span>\n  <span class=\"status\" id=\"ds\"></span>\n  <div class=\"mac-spacer\"></div>\n  <select id=\"sessionPick\" style=\"min-width:200px\"></select>\n  <button class=\"ghost\" id=\"openOptions\" title=\"Configurações desta sessão\">⚙ Opções</button>\n  <button class=\"act\" id=\"saveSession\">Salvar sessão</button>\n  <button class=\"ghost\" id=\"dupSession\" title=\"Cria uma cópia independente desta sessão — a original fica intacta e a cópia passa a ser a sessão ativa\">⧉ Duplicar</button>\n  <button class=\"ghost\" id=\"openShare\" title=\"Publicar uma cópia congelada desta sessão em modo leitura\">⇪ Compartilhar</button>\n  <span class=\"status\" id=\"sessStatus\"></span>\n</header>\n\n<div class=\"rail hidden\" id=\"timelineRail\">\n  <div id=\"movementChips\"></div>\n  <div id=\"addMovementGroup\">\n    <input id=\"sectorPick\" list=\"sectorPickList\" placeholder=\"código do setor\" style=\"min-width:150px\">\n    <datalist id=\"sectorPickList\"></datalist>\n    <button class=\"ghost\" id=\"rand\" title=\"Setor aleatório\">🎲</button>\n    <button class=\"act\" id=\"go\">Fechar setor ▶ (nova etapa)</button>\n  </div>\n</div>\n\n<div class=\"card\" id=\"confirmCard\" style=\"margin:14px 20px\">\n  <h2 style=\"margin:0 0 9px\">Confirmar snapshot antes de simular</h2>\n  <div class=\"note\" id=\"confirmBody\">congelando snapshot…</div>\n  <button class=\"act\" id=\"confirmSnap\" style=\"margin-top:12px\">Confirmar snapshot ▶ ir pra simulação</button>\n  <div class=\"note\" style=\"margin-top:16px\">ou continue uma sessão salva:</div>\n  <select id=\"confirmSessionPick\" style=\"min-width:260px;margin-top:4px\"></select>\n  <div class=\"status\" id=\"confirmStatus\" style=\"margin-top:8px\"></div>\n</div>\n\n<div class=\"howto hidden\" id=\"howtoBox\">\n  <b>Como usar &nbsp;→&nbsp;</b>\n  <span><b>1.</b> Escolha um setor pra <b>fechar</b> (ou 🎲) — isso cria a etapa 1 da timeline.</span>\n  <span><b>2.</b> Feche outro setor pra criar a etapa 2 — ela já considera pra onde a etapa 1 mandou cada município.</span>\n  <span><b>3.</b> Clique numa etapa (chip acima) pra revisitar/editar — editar uma etapa recalcula automaticamente todas as etapas seguintes.</span>\n  <span><b>4.</b> Veja o efeito acumulado da cadeia inteira na aba <b>Impacto acumulado</b>.</span>\n</div>\n<div class=\"wrap hidden\" id=\"mainWrap\">\n  <div>\n    <div class=\"card\">\n      <div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:9px\">\n        <h2 id=\"mapTitle\" style=\"margin:0\">Mapa — clique num município p/ trocar o setor destino</h2>\n        <div class=\"toggle\" id=\"viewToggle\" title=\"ASIS = estrutura do setor antes desta etapa (já considerando etapas anteriores) · TOBE = após a redistribuição desta etapa\">\n          <button data-v=\"asis\">◧ ASIS (antes desta etapa)</button>\n          <button data-v=\"tobe\" class=\"on\">◨ TOBE (simulação)</button>\n        </div>\n      </div>\n      <svg id=\"map\" viewBox=\"0 0 700 500\" preserveAspectRatio=\"xMidYMid meet\"></svg>\n      <div class=\"legend\" id=\"legend\"></div>\n      <div class=\"note\">Coloridos = municípios do setor fechado nesta etapa (cor = destino). Ao redor, cada área é um <b>setor vizinho inteiro</b> com o código no meio — não municípios soltos: cor = setor destino, cinza = contexto, cinza tracejado = setor já fechado numa etapa anterior. Shapes reais IBGE/tbrugz.</div>\n      <div class=\"note\" id=\"mapIgnoredNote\"></div>\n    </div>\n\n    <div class=\"card\">\n      <h2>Líderes — destino e CBs deslocadas (nesta etapa)</h2>\n      <div class=\"note\" style=\"margin:-4px 0 9px\"><b>Home</b> = setor que ficou com a maioria das CBs da líder. <b style=\"color:var(--closing)\">Deslocadas</b> = CBs dela que caíram noutro setor.</div>\n      <div class=\"scroll grow\"><table id=\"leaders\"></table></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"tabs\">\n        <button data-tab=\"matrix\" class=\"on\">Matriz ASIS</button>\n        <button data-tab=\"tobe\">Matriz TOBE</button>\n        <button data-tab=\"report\">Relatório de impacto (etapa)</button>\n        <button data-tab=\"cumulative\">Impacto acumulado (timeline)</button>\n        <button data-tab=\"sql\">Console SQL</button>\n      </div>\n      <div id=\"tab-matrix\"><div class=\"scroll grow\"><table id=\"matrix\"></table></div></div>\n      <div id=\"tab-tobe\" class=\"hidden\"><div class=\"scroll grow\"><table id=\"matrixTobe\"></table></div>\n        <div class=\"note\" id=\"tobeNote\"></div></div>\n      <div id=\"tab-report\" class=\"hidden\"></div>\n      <div id=\"tab-cumulative\" class=\"hidden\"></div>\n      <div id=\"tab-sql\" class=\"hidden\">\n        <textarea class=\"sqlbox\" id=\"sqlIn\">SELECT DS_MUNICIPIO, COUNT(DISTINCT CD_CONSULTORA) qtd FROM product.perfil_cb.crm_perfil_cb_diaria_historico WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=3504 GROUP BY DS_MUNICIPIO ORDER BY qtd DESC</textarea>\n        <div class=\"toolbar\" style=\"margin:8px 0\"><button class=\"act\" id=\"runSql\">▶ Rodar</button><span class=\"status\" id=\"sqlStatus\"></span></div>\n        <div class=\"note\" style=\"margin:-4px 0 8px\">Roda contra o snapshot congelado desta sessão (não \"ao vivo\") — mesma coerência da timeline.</div>\n        <div class=\"scroll\"><table id=\"sqlOut\"></table></div>\n      </div>\n    </div>\n  </div>\n\n  <div>\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"margin-bottom:11px\">\n        <button class=\"ghost\" id=\"optim\" title=\"testa todas alocações contíguas e escolhe a que equilibra a carga entre setores + minimiza a quebra (evita empilhar tudo num setor)\">★ Modelo ÓTIMO (equilíbrio + quebra)</button>\n        <button class=\"ghost\" id=\"autoTobe\" title=\"preenche cada município pelo setor vizinho mais próximo (geografia)\">TOBE por adjacência</button>\n        <span class=\"badge\" id=\"optBadge\"></span>\n      </div>\n      <div class=\"note\" style=\"margin:-4px 0 10px\"><b style=\"color:var(--ink)\">★ ÓTIMO</b> = equilibra a carga entre os setores <b>e</b> minimiza a quebra (contíguo; não empilha tudo num setor). <b style=\"color:var(--ink)\">TOBE</b> = sugestão por vizinhança. Editar esta etapa recalcula automaticamente as etapas seguintes da timeline.</div>\n      <div class=\"note\" id=\"ruleNote\" style=\"margin:-6px 0 11px;padding:7px 9px;background:var(--panel2);border:1px solid var(--line);border-radius:8px\"></div>\n      <div class=\"kpis\" id=\"kpis\"></div>\n      <div id=\"contig\"></div>\n      <div id=\"spanRule\"></div>\n      <h2>Span por setor (CBs) — base + entrantes</h2>\n      <div id=\"spans\"></div>\n      <div style=\"display:flex;justify-content:space-between;align-items:center;margin:14px 0 7px\">\n        <h2 style=\"margin:0\">CBs deslocadas</h2>\n        <div class=\"toggle\" id=\"policy\">\n          <button data-p=\"fora\" class=\"on\">= fora do setor</button>\n          <button data-p=\"quebra\">= quebra de relação</button>\n        </div>\n      </div>\n      <div class=\"note\" id=\"policyNote\"></div>\n    </div>\n\n    <div class=\"card\">\n      <h2>Municípios → setor destino</h2>\n      <div class=\"note\" style=\"margin:-4px 0 9px\">Clique no <b>código do setor</b> pra mandar o município <b>inteiro</b> pra lá (regra de negócio). A cor acompanha o mapa.</div>\n      <div id=\"muniList\"></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"justify-content:space-between\">\n        <h2 style=\"margin:0\">Plano de migração (etapa selecionada)</h2>\n        <div><button class=\"ghost\" id=\"copyPlan\">Copiar</button> <button class=\"ghost\" id=\"csvPlan\">CSV</button></div>\n      </div>\n      <div id=\"plan\" class=\"note\"></div>\n    </div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"shareBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"shareTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"shareTitle\">Compartilhar sessão</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"shareX\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\" id=\"shareBody\"></div>\n    <div class=\"sheet-ft\">\n      <span class=\"status\" id=\"shareStatus\"></span>\n      <div class=\"mac-spacer\"></div>\n      <button class=\"ghost hidden\" id=\"shareCopy\">Copiar link</button>\n      <button class=\"ghost hidden\" id=\"shareOpen\">Abrir</button>\n      <button class=\"ghost\" id=\"shareCancel\">Cancelar</button>\n      <button class=\"act\" id=\"shareGo\">Compartilhar</button>\n    </div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"optionsBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"optTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"optTitle\">Opções da sessão</h3><span class=\"badge\" id=\"optSessName\"></span><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"optClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-lbl\">Regra rígida — seleção dos setores destino</div>\n      <div class=\"opt-sub\">Define quais setores vizinhos podem entrar na lista de destinos de cada etapa. Vale pra toda a timeline desta sessão.</div>\n      <label class=\"opt-row\" data-r=\"any\">\n        <input type=\"radio\" name=\"destRule\" value=\"any\">\n        <div>\n          <div class=\"opt-t\">Adjacente a qualquer município <span class=\"badge\">padrão</span></div>\n          <div class=\"opt-d\">Um setor entra na lista de destinos se tiver município vizinho a <b>qualquer</b> município do setor fechado — inclusive os menores. É o comportamento de hoje.</div>\n        </div>\n      </label>\n      <label class=\"opt-row\" data-r=\"dominant\">\n        <input type=\"radio\" name=\"destRule\" value=\"dominant\">\n        <div>\n          <div class=\"opt-t\">Adjacente a municípios dominantes</div>\n          <div class=\"opt-d\">Só os municípios do setor fechado com <b>CBs suficientes</b> valem como ponte. Os pequenos continuam sendo redistribuídos normalmente — só não trazem setor novo pra lista de destinos.</div>\n          <div class=\"opt-inline\" id=\"optMinCbsWrap\">Ignorar municípios com menos de <input type=\"number\" id=\"optMinCbs\" min=\"1\" max=\"9999\" step=\"1\" style=\"width:74px\"> CBs</div>\n        </div>\n      </label>\n      <div class=\"opt-hr\"></div>\n      <div class=\"opt-lbl\" style=\"margin-top:15px\">Pré-requisitos do setor destino — tamanho em CBs</div>\n      <div class=\"opt-sub\">Vale pro tamanho de cada setor receptor <b>depois</b> da redistribuição (base + entrantes). Em branco = desligado.</div>\n      <div class=\"opt-grid\">\n        <label class=\"opt-fld\"><span>Span alvo <i>(tamanho ideal)</i></span><input type=\"number\" id=\"optTargetSpan\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"livre\"></label>\n        <label class=\"opt-fld\"><span>Span máximo <i>(nenhum setor acima)</i></span><input type=\"number\" id=\"optMaxSpan\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n        <label class=\"opt-fld\"><span>Span mínimo <i>(nenhum setor abaixo)</i></span><input type=\"number\" id=\"optMinSpan\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n      </div>\n      <div class=\"opt-err hidden\" id=\"optSpanErr\"></div>\n      <div class=\"opt-note\" id=\"optPreview\"></div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"optStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"optCancel\">Cancelar</button><button class=\"act\" id=\"optApply\">Aplicar</button></div>\n  </div>\n</div>\n\n<div class=\"tip\" id=\"tip\"></div>\n<div class=\"load hidden\" id=\"loader\">carregando…</div>\n<span id=\"ready\" style=\"display:none\"></span></div>`;\n  },\n  mounted(d2, root){\n\nconst $=s=>root.querySelector(s);\nconst fmt=n=>n==null?'–':Math.round(+n).toLocaleString('pt-BR');\nconst money=n=>{n=+n||0;return n>=1e6?'R$'+(n/1e6).toFixed(1)+'M':n>=1e3?'R$'+(n/1e3).toFixed(0)+'k':'R$'+Math.round(n)};\nconst norm=s=>(s||'').normalize('NFKD').replace(/[̀-ͯ]/g,'').toUpperCase().replace(/[^A-Z0-9]+/g,' ').trim();\nconst esc=s=>(s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\nconst blankM=()=>({cbs:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0,ativas:0});\n// ---------- município identity ----------\n// Brazil has many municipalities sharing a name (Bom Jesus alone exists in PI, RN, RS, SC, PB, GO).\n// A stage loads the dominant UF PLUS every bordering UF, so those collisions land in the SAME\n// feature set routinely: keyed by name alone the last shape loaded won, the map could draw a\n// município from the wrong state, buildAdj merged two unrelated municipalities into one node, and\n// GROUP BY DS_MUNICIPIO summed two real municipalities into one row. The key is NAME/UF everywhere\n// — SQL rows, GeoJSON features, the dominantes base, assignments, persisted sessions. norm() maps\n// every non-alphanumeric to a space, so '/' can never occur inside a name and the split is exact.\n// A missing UF degrades to a bare NAME key rather than inventing one.\nconst mkey=(name,uf)=>{const n=norm(name),u=String(uf==null?'':uf).trim().toUpperCase();return u?n+'/'+u:n;};\nconst mName=k=>{const s=String(k==null?'':k),i=s.lastIndexOf('/');return i<0?s:s.slice(0,i);};\nconst mUf=k=>{const s=String(k==null?'':k),i=s.lastIndexOf('/');return i<0?'':s.slice(i+1);};\nconst mDisp=k=>{const u=mUf(k);return u?mName(k)+' ('+u+')':mName(k);};\n// The base CSV is the ONLY source of a município's UF now, so its column is accepted in whatever\n// shape the base is maintained in: sigla, IBGE numeric code, or full state name. Anything that does\n// not resolve to a real sigla is treated as UNKNOWN rather than passed through — a bogus UF would key\n// a município to a shape that does not exist and drop it off the map without a word.\nconst UF_OF_CODE={}; Object.keys(UF_CODE).forEach(k=>{ UF_OF_CODE[UF_CODE[k]]=k; });\nconst UF_OF_NAME={ACRE:'AC',ALAGOAS:'AL',AMAPA:'AP',AMAZONAS:'AM',BAHIA:'BA',CEARA:'CE','DISTRITO FEDERAL':'DF','ESPIRITO SANTO':'ES',GOIAS:'GO',MARANHAO:'MA','MATO GROSSO':'MT','MATO GROSSO DO SUL':'MS','MINAS GERAIS':'MG',PARA:'PA',PARAIBA:'PB',PARANA:'PR',PERNAMBUCO:'PE',PIAUI:'PI','RIO DE JANEIRO':'RJ','RIO GRANDE DO NORTE':'RN','RIO GRANDE DO SUL':'RS',RONDONIA:'RO',RORAIMA:'RR','SANTA CATARINA':'SC','SAO PAULO':'SP',SERGIPE:'SE',TOCANTINS:'TO'};\nconst ufNorm=v=>{const s=norm(v); if(!s) return '';\n  if(UF_CODE[s]) return s;\n  if(UF_OF_CODE[s]) return UF_OF_CODE[s];\n  return UF_OF_NAME[s]||'';};\nconst loader=(on,t)=>{const l=$('#loader');l.classList.toggle('hidden',!on);if(t)l.textContent=t;};\nconst setError=(msg)=>{ const t=$('#ds'); if(t){t.textContent=msg;t.classList.add('err');} const c=$('#confirmStatus'); if(c){c.textContent=msg;c.classList.add('err');} };\n\n// ---------- base de municípios dominantes (CSV) ----------\n// WHICH municipalities belong to a sector is a business fact, not something to infer from CB volume:\n// it comes from a maintained CSV (one row per setor x município) linked through the `dominantesCsvUrl`\n// param. Anything the closing sector has CBs in but that the base does NOT assign to it is spillover:\n// not realocated, not drawn on the map, and its CBs follow their leader. Loaded once per URL and\n// cached for the whole session; a failure is thrown, never swallowed — a silent fallback here would\n// quietly change every number on the screen.\nconst DOM={url:null,promise:null,bySetor:null,ufBySetorMuni:null,rows:0,setores:0,hasUf:false,ufCol:null,badUf:0,clashes:0,headers:null,error:null};\nfunction parseCsv(text){\n  const out=[]; let row=[],cur='',q=false;\n  const push=()=>{ row.push(cur); cur=''; };\n  const endRow=()=>{ push(); if(row.length>1||row[0]!=='') out.push(row); row=[]; };\n  for(let i=0;i<text.length;i++){ const ch=text[i];\n    if(q){ if(ch==='\"'){ if(text[i+1]==='\"'){ cur+='\"'; i++; } else q=false; } else cur+=ch; }\n    else if(ch==='\"') q=true;\n    else if(ch===',') push();\n    else if(ch==='\\n') endRow();\n    else if(ch!=='\\r') cur+=ch;\n  }\n  if(cur!==''||row.length) endRow();\n  return out;\n}\n// `dominantesCsvUrl` is declared as a FileComponentUrlReference, so the instance points at the Drive\n// FILE, not at a URL literal — the Drive is policy-gated behind the app's auth strategy and a baked\n// string would be both stale and unauthorised. What the resolver hands back varies by where it is\n// resolved (URL string / file record / bare reference), so accept every shape instead of silently\n// reading undefined and falling through to the fallback.\n// The Drive is gated by the app's AuthenticationStrategy, so this read has to travel as the LOGGED-IN\n// USER — an anonymous CDN GET is exactly what the policy is there to refuse. `<system>.data2.media` is\n// only a cache in front of the SSP: on a cache miss it 302s to `<system>.data2apis.com`, which is where\n// the policy is actually evaluated.\n// COOKIES CANNOT CARRY THAT SESSION, which is why the previous `credentials:'include'` attempt could\n// never have worked: the app runs on <app>.<system>.data2.app and the SSP on <system>.data2apis.com —\n// cross-site — and the SSP answers `Access-Control-Allow-Origin: *` with no `Allow-Credentials`, so the\n// browser rejects a credentialed CORS read before it ever leaves the page. What the SSP actually reads\n// is `Authorization: Bearer <token>`: the runtime keeps that token in localStorage under\n// `__data2-session-<systemDomain>` (`__data2-session-data2` inside the builder preview) and replays it\n// on every dataset and rush call — same token, same header, same session, so the Drive policy sees the\n// logged-in user. Preflight passes because the SSP echoes whatever header the request asks for.\nfunction d2SessionToken(sysDomain){\n  const read=k=>{ try{ return (k&&window.localStorage.getItem(k))||''; }catch(e){ return ''; } };\n  return read(sysDomain?('__data2-session-'+sysDomain):'')||read('__data2-session-data2')||'';\n}\nfunction domCsvSystemDomain(url){\n  const m=/^https?:\\/\\/([^.\\/]+)\\.data2\\.(?:media|app)\\//i.exec(url)||/^https?:\\/\\/([^.\\/]+)\\.data2apis\\.com\\//i.exec(url);\n  return m?m[1]:'';\n}\nfunction domCsvCandidates(url){\n  const out=[];\n  const isMedia=/^https?:\\/\\/[^.\\/]+\\.data2\\.media\\//i.test(url);\n  const api=isMedia?url.replace(/^(https?:\\/\\/[^.\\/]+)\\.data2\\.media\\//i,'$1.data2apis.com/'):null;\n  const tok=d2SessionToken(domCsvSystemDomain(url));\n  const auth=tok?{authorization:'Bearer '+tok}:null;\n  // Authenticated origin read first — the only candidate a GATED Drive can actually answer.\n  if(api&&auth) out.push({url:api,headers:auth,label:'api+sessão'});\n  if(!api&&auth) out.push({url:url,headers:auth,label:'origem+sessão'});\n  // Anonymous last: only ever succeeds on an UNGATED Drive — or on a stale CDN cache hit, which is NOT\n  // proof the file is public (the origin keeps answering 401 once the edge entry expires).\n  if(api) out.push({url:api,headers:null,label:'api anônimo'});\n  out.push({url:url,headers:null,label:'origem anônima'});\n  return out;\n}\nfunction domCsvUrl(){\n  const pick=v=>{\n    if(!v) return '';\n    if(typeof v==='string') return v;\n    const q=v.params||{};\n    const ref=q.reference||v.reference;\n    return q.fileUrl||q.url||v.fileUrl||v.url||(ref?DOM_MEDIA_BASE+ref:'');\n  };\n  return String(pick(d2.param('dominantesCsvUrl'))||DOM_CSV_FALLBACK||'').trim();\n}\nasync function ensureDominantes(){\n  const url=domCsvUrl();\n  if(!url) throw new Error('base de municípios dominantes não configurada — ligue o arquivo CSV no parâmetro dominantesCsvUrl da instância do componente');\n  if(DOM.url===url && DOM.bySetor) return DOM.bySetor;\n  if(DOM.url===url && DOM.promise) return DOM.promise;\n  DOM.url=url; DOM.error=null;\n  DOM.promise=(async()=>{\n    // Self-diagnosing on purpose: every candidate reports its own outcome and the message names the\n    // session it was carrying. \"Unauthorized\" alone cannot distinguish \"not logged into THIS strategy\"\n    // from \"cookie not sent cross-site\" from \"param never bound, so it tried the fallback URL\" — and\n    // guessing between those from the outside costs a round trip each time.\n    let r=null; const tried=[];\n    for(const c of domCsvCandidates(url)){\n      let res=null;\n      try{ res=await fetch(c.url,{cache:'no-store',credentials:'omit',headers:c.headers||undefined}); }\n      catch(e){ tried.push(c.label+' → rede/CORS'); continue; }\n      if(res.ok){ r=res; break; }\n      tried.push(c.label+' → '+res.status);\n    }\n    if(!r){\n      let sess='sessão desconhecida';\n      try{\n        const a=d2.auth(AUTH_REF);\n        sess=(a&&a.isAuthed)?('logado como '+((a.user&&(a.user.email||a.user.nome))||'?')):('NÃO logado na estratégia '+AUTH_REF);\n      }catch(e){ sess='estratégia '+AUTH_REF+' não resolvida'; }\n      const tk=d2SessionToken(domCsvSystemDomain(url));\n      throw new Error('base de municípios dominantes: não foi possível ler o arquivo — '+sess+' · token de sessão '+(tk?'presente':'AUSENTE no localStorage')+'. Tentativas: '+tried.join(' · '));\n    }\n    const table=parseCsv(await r.text());\n    if(table.length<2) throw new Error('base de municípios dominantes vazia');\n    const head=table[0].map(h=>norm(h));\n    const iS=head.indexOf('CD SETOR'), iM=head.indexOf('MUNICIPIO');\n    if(iS<0||iM<0) throw new Error('base de municípios dominantes sem as colunas CD_SETOR / MUNICIPIO');\n    // CD_GV is the gerência de vendas the setor answers to, and it is the authority for the hard rule\n    // that a closing sector only ever hands territory to sectors of its OWN gerência. Its absence is\n    // fatal rather than a warning: with no GV there is no way to PROVE two sectors share one, and\n    // falling through unchecked is exactly the leak this column exists to close.\n    const iG=['CD GV','CDGV','GV','CD GERENCIA','CD GERENCIA VENDAS','CD GERENCIA DE VENDAS','GERENCIA DE VENDAS','GERENCIA'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    if(iG==null) throw new Error('base de municípios dominantes sem a coluna CD_GV — a regra de gerência de vendas não pode ser aplicada. Colunas lidas: '+head.slice(0,40).join(', '));\n    // The UF column is what the whole keying now rests on — SQL no longer carries DS_ESTADO — so its\n    // absence is a first-class finding, not a footnote. The header actually matched is reported on the\n    // base pill, and when nothing matches the pill lists every column read, because guessing a header\n    // name from the outside costs a round trip each time.\n    const iU=['UF','SIGLA UF','SG UF','CD UF','ESTADO','DS ESTADO','SIGLA','ESTADO SIGLA','UF MUNICIPIO'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    const by={},ufBy={},gvBy={}; let n=0,bad=0; const clash={},gvClash={};\n    for(let i=1;i<table.length;i++){\n      const line=table[i]; if(!line) continue;\n      const s=String(line[iS]==null?'':line[iS]).trim(), m=norm(line[iM]);\n      if(!s||!m) continue;\n      // One setor spans many rows (one per município) and every one of them repeats its CD_GV. Two\n      // DIFFERENT values for the same setor means the base itself disagrees about which gerência owns\n      // it — counted and reported, never keyed to whichever row happened to be read last.\n      const gvv=String(line[iG]==null?'':line[iG]).trim();\n      if(gvv){ if(gvBy[s]!==undefined && gvBy[s]!==gvv) gvClash[s]=true; else gvBy[s]=gvv; }\n      const raw=(iU==null)?'':line[iU];\n      const u=(iU==null)?'':ufNorm(raw);\n      if(iU!=null && String(raw==null?'':raw).trim() && !u) bad++;\n      // The same name twice in ONE setor under two UFs is the single case the reverted SQL cannot\n      // survive: mSQL groups by DS_MUNICIPIO alone, so both municipalities arrive summed into one row\n      // and no lookup can split them again. Counted and reported — never quietly keyed to whichever\n      // UF happened to be read last.\n      const kk=s+'|'+m;\n      // An UNRESOLVED uf must never outrank a resolved one. Reading a blank/bogus UF row first wrote ''\n      // here, and the next row carrying the real sigla was then rejected as a clash — so the key stayed\n      // falsy for good, ufFor() fell through, and the município degraded to a bare NAME key that matches\n      // no shape and no dominance entry. With DISTRITO/ZONA in the base a município spans several rows,\n      // so which row lands first is pure luck. A clash is only two RESOLVED siglas that disagree.\n      if(!u){ if(ufBy[kk]===undefined) ufBy[kk]=''; }\n      else if(!ufBy[kk]) ufBy[kk]=u;\n      else if(ufBy[kk]!==u) clash[kk]=true;\n      (by[s]=by[s]||new Set()).add(u?m+'/'+u:m); n++;\n    }\n    if(!n) throw new Error('base de municípios dominantes sem linhas válidas');\n    DOM.bySetor=by; DOM.ufBySetorMuni=ufBy; DOM.rows=n; DOM.setores=Object.keys(by).length;\n    DOM.gvBySetor=gvBy; DOM.gvClashes=Object.keys(gvClash).length; DOM.gvCol=String(table[0][iG]||'').trim();\n    DOM.gvSetores=Object.keys(gvBy).length;\n    DOM.hasUf=(iU!=null); DOM.ufCol=(iU==null?null:String(table[0][iU]||'').trim());\n    DOM.badUf=bad; DOM.clashes=Object.keys(clash).length; DOM.headers=head.slice(0,40); DOM.error=null;\n    return by;\n  })();\n  DOM.promise.catch(e=>{ DOM.promise=null; DOM.bySetor=null; DOM.error=e.message; renderAll(); });\n  return DOM.promise;\n}\n\nlet SESS={ snapshotRaw:null, snapshotSyncedAt:null, snapLiteral:null, eloMaxCycle:null, timeline:[], selected:0,\n           sessionId:null, sessionLabel:null, sessionsList:[], sectorsList:[], sectorBaseCache:{}, stage:'loading', dirtyFrom:null,\n           snapBusy:false, settings:{...DEFAULT_SETTINGS} };\n\nasync function q(sql){\n  let dbx,res;\n  try{\n    res=await d2.rush('AwW_11Nkg-dKksP7ic8V8').invoke({sql});\n  }catch(e){\n    throw new Error('Databricks indisponível (rush AwW_11Nkg-dKksP7ic8V8): '+e.message);\n  }\n  // The listener answers 200 with {sql} and NO `result` when its upstream probe times out: the\n  // handler builds `result: $result.data`, so an empty upstream simply drops the key rather than\n  // failing. Reading straight through to res.data.result.data reported \"Cannot read properties of\n  // undefined (reading 'data')\" — which reads like a bug in THIS component and sent a debugging\n  // session after the wrong thing. Name the layer that actually failed instead.\n  const payload=res&&res.data;\n  if(!payload) throw new Error('rush AwW_11Nkg-dKksP7ic8V8 não devolveu corpo — listener fora do ar?');\n  if(!payload.result) throw new Error('a sonda Databricks não respondeu: o listener AwW_11Nkg-dKksP7ic8V8 devolveu 200 sem `result`, o que normalmente é timeout do upstream (natura-x-app). O SQL não chegou a rodar.');\n  dbx=payload.result.data;\n  if(!dbx) throw new Error('sonda Databricks respondeu sem dados (result.data vazio).');\n  if(dbx.state && dbx.state!=='SUCCEEDED') throw new Error('Databricks '+dbx.state+': '+(dbx.error?.message||JSON.stringify(dbx.error)||'erro desconhecido'));\n  return dbx;\n}\nfunction rows(resp){const c=(resp.columns||[]).map(x=>x.name);\n  return (resp.rows||[]).map(a=>Object.fromEntries(c.map((k,i)=>[k,a[i]])));}\n// Stamps the UF onto every feature at load: the fetch is per-UF, so this is the ONLY point where a\n// shape's state is known for certain. Downstream the features are flattened into one array and the\n// origin would be unrecoverable. Persisted with the feature in saved sessions.\nasync function geoUF(uf){const code=UF_CODE[uf]||uf;const r=await fetch('https://raw.githubusercontent.com/tbrugz/geodata-br/master/geojson/geojs-'+code+'-mun.json');if(!r.ok)throw new Error('geo '+uf);\n  const g=await r.json(); const U=String(uf).toUpperCase();\n  (g.features||[]).forEach(f=>{ f.__uf=U; });\n  return g;}\n\nfunction buildAdj(features){\n  const pts=f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates],s=new Set();\n    ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>s.add(Math.round(p[0]*1000)/1000+','+Math.round(p[1]*1000)/1000))));return s;};\n  const feat={},idx={};\n  features.forEach(f=>{const nm=mkey(f.properties.name||f.properties.NOME||f.properties.nome,f.__uf);feat[nm]={f,pts:pts(f)};});\n  for(const nm in feat) feat[nm].pts.forEach(p=>(idx[p]=idx[p]||[]).push(nm));\n  const nbrs=nm=>{const c={};feat[nm].pts.forEach(p=>idx[p].forEach(o=>{if(o!==nm)c[o]=(c[o]||0)+1;}));\n    return Object.entries(c).filter(([,n])=>n>=2).map(([o,n])=>({o,w:n}));};\n  return {feat,nbrs};\n}\nfunction centroidLL(f){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  ps.forEach(poly=>poly[0].forEach(p=>{x+=p[0];y+=p[1];n++;}));return[x/n,y/n];}\n\n// ---------- snapshot pinning ----------\nasync function resolveSnapshotPin(){\n  const [rawR,liveCountR,eloR]=await Promise.all([\n    q(`SELECT MAX(DT_HISTORICO) snap FROM ${T} WHERE CD_PAIS=1`).then(rows),\n    q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)`).then(rows),\n    q(`SELECT MAX(operational_cycle) mx FROM ${ELO} WHERE country_code=1`).then(rows).catch(()=>[]),\n  ]);\n  const raw=rawR[0]&&rawR[0].snap;\n  const liveCount=+((liveCountR[0]||{}).n||0);\n  const eloMax=+((eloR[0]||{}).mx||0);\n  const LIVE_SUBQ=`(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)`;\n  const dbTs=(raw||'').replace('T',' ').replace(/Z$/,'');\n  const candidates=[`TIMESTAMP'${dbTs}'`,`'${dbTs}'`,`DATE'${dbTs}'`];\n  const tests=await Promise.all(candidates.map(c=>\n    q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=${c}`).then(rows).then(r=>+((r[0]||{}).n||0)).catch(()=>-1)\n  ));\n  let chosen=LIVE_SUBQ;\n  for(let i=0;i<candidates.length;i++){ if(liveCount>0 && tests[i]===liveCount){ chosen=candidates[i]; break; } }\n  return {snapshotRaw:raw, snapLiteral:chosen, eloMaxCycle:eloMax, syncedAt:new Date().toISOString()};\n}\n// DISPLAY CONTRACT: the UI never prints the snapshot's own DT_HISTORICO (snapshotRaw). What the user\n// sees is `snapshotSyncedAt` — the date AND hour at which THIS instance last pulled/pinned the\n// snapshot it is querying. snapshotRaw is still resolved, still drives every SQL literal, and is\n// still persisted by saveSession/doShare; it is only kept off the screen.\nfunction fmtSyncedAt(iso){\n  if(!iso) return '—';\n  const d=new Date(iso);\n  if(isNaN(d.getTime())) return String(iso);\n  return d.toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});\n}\nasync function ensurePinned(){\n  if(SESS.snapLiteral) return;\n  const p=await resolveSnapshotPin();\n  SESS.snapshotRaw=p.snapshotRaw; SESS.snapLiteral=p.snapLiteral; SESS.eloMaxCycle=p.eloMaxCycle;\n  SESS.snapshotSyncedAt=p.syncedAt;\n}\n\n// ---------- world-state fold ----------\nfunction accMetrics(base,add){\n  const out=base?{...base}:blankM();\n  Object.keys(blankM()).forEach(k=>{ out[k]=(out[k]||0)+((add&&add[k])||0); });\n  return out;\n}\nfunction foldWorldState(timeline,uptoIdx){\n  const closedSectors=new Set(),muniOverride={},muniGeo={},muniRow={},muniLevel={},sectorInflow={},sectorInflowLevel={},sectorOrphanRows={};\n  for(let k=0;k<uptoIdx;k++){\n    const mv=timeline[k]; if(!mv||!mv.sector||!mv.munis) continue;\n    closedSectors.add(String(mv.sector));\n    Object.keys(mv.munis).forEach(m=>{\n      if(m==='__OUTROS__') return;\n      const dest=mv.assign[m]; if(!dest) return;\n      muniOverride[m]=String(dest);\n      if(mv.geoFeat&&mv.geoFeat[m]) muniGeo[m]=mv.geoFeat[m];\n      const rws=(mv.matrix||[]).filter(r=>mkey(r.municipio,r.uf)===m);\n      muniRow[m]=(muniRow[m]||[]).concat(rws);\n      if(mv.levelByMuni&&mv.levelByMuni[m]) muniLevel[m]={...(muniLevel[m]||{}),...mv.levelByMuni[m]};\n      sectorInflow[dest]=accMetrics(sectorInflow[dest],mv.munis[m]);\n      const lv=(mv.levelByMuni&&mv.levelByMuni[m])||{};\n      sectorInflowLevel[dest]=sectorInflowLevel[dest]||{};\n      Object.entries(lv).forEach(([nv,c])=>{ sectorInflowLevel[dest][nv]=(sectorInflowLevel[dest][nv]||0)+c; });\n    });\n    // Orphan CBs follow their leader's home sector, so later etapas must inherit them the same way\n    // the allocated municipalities are inherited — otherwise a stage's span would not match the\n    // base carried into the next one.\n    if(mv.orphanMetricsByGroup && Object.keys(mv.orphanMetricsByGroup).length){\n      const d=computeDerived(mv);\n      d.leaders.forEach(L=>{\n        if(!L.home) return;\n        const om=mv.orphanMetricsByGroup[L.grupo];\n        if(om) sectorInflow[L.home]=accMetrics(sectorInflow[L.home],om);\n        // sectorInflow only reaches a sector while it is a DESTINATION (it lands in baseMetrics).\n        // Keep the rows as well, so the sector still carries these CBs on the day it is the one\n        // being closed — that read goes through mv.matrix, which baseMetrics never touches.\n        const orws=mv.orphanRowsByGroup&&mv.orphanRowsByGroup[L.grupo];\n        if(orws&&orws.length) sectorOrphanRows[L.home]=(sectorOrphanRows[L.home]||[]).concat(orws);\n        const ol=mv.orphanLevelByGroup&&mv.orphanLevelByGroup[L.grupo];\n        if(ol){ sectorInflowLevel[L.home]=sectorInflowLevel[L.home]||{};\n          Object.entries(ol).forEach(([nv,c])=>{ sectorInflowLevel[L.home][nv]=(sectorInflowLevel[L.home][nv]||0)+c; }); }\n      });\n    }\n  }\n  return {closedSectors,muniOverride,muniGeo,muniRow,muniLevel,sectorInflow,sectorInflowLevel,sectorOrphanRows};\n}\n\n// ---------- per-movement compute ----------\nfunction aggregateMovement(mv){\n  const munis={},leaders={};\n  const exSet=new Set((mv.excludedMunis||[]).map(x=>x.muni));\n  const acc=(o,r)=>{o.cbs+=+r.cbs;o.receita+=+r.receita||0;o.rec_nat+=+r.rec_nat||0;o.rec_avon+=+r.rec_avon||0;o.pontos+=+r.pontos||0;o.pedidos+=+r.pedidos||0;o.ativas+=+r.ativas||0;};\n  // Orphans = CBs in municipalities outside the sector's dominance base. They are NOT allocated\n  // geographically; they follow their own leader's home sector. Kept per leader group so every\n  // consumer (span, cascade, impact report) can route them the same way.\n  mv.orphanByGroup={}; mv.orphanMetricsByGroup={}; mv.orphanRowsByGroup={};\n  mv.matrix.forEach(r=>{\n    const m=mkey(r.municipio,r.uf);\n    r._out=!mv.geoFeat[m];   // assign, never just set: absorbed rows are reused across etapas\n    const mm=r._out?'__OUTROS__':m;\n    munis[mm]=munis[mm]||{...blankM(),name:mm}; acc(munis[mm],r);\n    const g=String(r.grupo); leaders[g]=leaders[g]||{grupo:g,lider:r.lider,total:0,byMuni:{}};\n    leaders[g].total+=+r.cbs; leaders[g].byMuni[mm]=(leaders[g].byMuni[mm]||0)+ +r.cbs;\n    if(r._out && exSet.has(m)){\n      mv.orphanByGroup[g]=(mv.orphanByGroup[g]||0)+ +r.cbs;\n      // The ROW too, not just the totals: when the leader's home sector is itself closed in a later\n      // etapa, that etapa rebuilds its matrix from SQL + absorbed municípios, and these CBs are in\n      // neither (their município never moved). Without the row they silently vanish from the sector\n      // they were just credited to — see sectorOrphanRows in foldWorldState.\n      (mv.orphanRowsByGroup[g]=mv.orphanRowsByGroup[g]||[]).push(r);\n      mv.orphanMetricsByGroup[g]=accMetrics(mv.orphanMetricsByGroup[g],\n        {cbs:+r.cbs,receita:+r.receita||0,rec_nat:+r.rec_nat||0,rec_avon:+r.rec_avon||0,pontos:+r.pontos||0,pedidos:+r.pedidos||0,ativas:+r.ativas||0});\n    }\n  });\n  // Level counts only exist per municipality, never per leader, so an excluded municipality's level\n  // mix is credited to the group holding most of its CBs (sub-threshold by definition, so usually one).\n  mv.orphanLevelByGroup={};\n  (mv.excludedMunis||[]).forEach(({muni})=>{\n    const lv=(mv.levelByMuni&&mv.levelByMuni[muni])||{}; if(!Object.keys(lv).length) return;\n    let bg=null,bc=-1;\n    mv.matrix.forEach(r=>{ if(mkey(r.municipio,r.uf)!==muni) return; const cc=+r.cbs; if(cc>bc){bc=cc;bg=String(r.grupo);} });\n    if(!bg) return;\n    mv.orphanLevelByGroup[bg]=mv.orphanLevelByGroup[bg]||{};\n    Object.entries(lv).forEach(([nv,cc])=>{ mv.orphanLevelByGroup[bg][nv]=(mv.orphanLevelByGroup[bg][nv]||0)+cc; });\n  });\n  mv.munis=munis; mv.leaders=Object.values(leaders).sort((a,b)=>b.total-a.total);\n}\nfunction autoAssignFor(mv){\n  const a={};\n  Object.keys(mv.munis).forEach(m=>{ if(m==='__OUTROS__')return;\n    const v=mv.allowed[m]||{}; const votes=Object.keys(v);\n    if(votes.length){ let best=votes[0],bw=-1; for(const s of votes) if(v[s]>bw){bw=v[s];best=s;} a[m]=best; }\n    else if(mv.dest.length){ const c=mv.muniCentroid[m]; let best=mv.dest[0],bd=Infinity;\n      for(const s of mv.dest){ const sc=mv.secCentroid[s]; if(!sc||!c)continue; const d=(c[0]-sc[0])**2+(c[1]-sc[1])**2; if(d<bd){bd=d;best=s;} }\n      a[m]=best; }\n  });\n  return a;\n}\nfunction deslocFor(mv,assign){\n  let d=0;\n  mv.leaders.forEach(L=>{const byS={};let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=assign[m];if(s){byS[s]=(byS[s]||0)+c;tot+=c;}}\n    const home=mv.dest.reduce((a,s)=>(byS[s]||0)>(byS[a]||0)?s:a,mv.dest[0]);\n    d+=tot-(byS[home]||0);});\n  return d;\n}\nfunction spanMapFor(mv,a){const inc={};mv.dest.forEach(s=>inc[s]=0);\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=a[m];if(s&&inc[s]!=null)inc[s]+=o.cbs;});\n  // Orphan CBs follow their leader's home sector, and that home depends on THIS candidate\n  // assignment — so they have to be counted here, or the optimizer would score and constrain a\n  // span that differs from the one the UI shows. Skipped entirely when there are no orphans.\n  if(mv.orphanByGroup&&Object.keys(mv.orphanByGroup).length){\n    mv.leaders.forEach(L=>{\n      const orphan=mv.orphanByGroup[L.grupo]||0; if(!orphan) return;\n      const byS={}; let tot=0;\n      for(const [m,c] of Object.entries(L.byMuni)){ if(m==='__OUTROS__') continue; const s=a[m]; if(s&&inc[s]!=null){ byS[s]=(byS[s]||0)+c; tot+=c; } }\n      if(!tot) return;   // no home to follow -> stranded, counted nowhere\n      const home=mv.dest.reduce((x,s)=>(byS[s]||0)>(byS[x]||0)?s:x,mv.dest[0]);\n      if(inc[home]!=null) inc[home]+=orphan;\n    });\n  }\n  const sp={};mv.dest.forEach(s=>sp[s]=(+(mv.baseMetrics[s]?.cbs||0))+inc[s]);return sp;}\nfunction maxSpanOfFor(mv,a){return Math.max(...Object.values(spanMapFor(mv,a)),0);}\nfunction spanLimits(){const st={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  return {target:numOrNull(st.targetSpan),max:numOrNull(st.maxSpan),min:numOrNull(st.minSpan)};}\nfunction spanLimitsOn(lim){return !!(lim.target||lim.max||lim.min);}\nfunction spanViolationsOf(sp,lim){const over=[],under=[];\n  Object.entries(sp).forEach(([s,v])=>{ if(lim.max&&v>lim.max)over.push(s); if(lim.min&&v<lim.min)under.push(s); });\n  return {over,under};}\nfunction spanViolationsFor(mv,a){return spanViolationsOf(spanMapFor(mv,a),spanLimits());}\n// With a target span the objective becomes RMS distance from the ideal size (punishes too big AND\n// too small). With no target we keep the legacy objective: shave the biggest sector.\nfunction spanCostFor(mv,a){const lim=spanLimits(),vals=Object.values(spanMapFor(mv,a));\n  if(!vals.length) return 0;\n  if(lim.target) return Math.sqrt(vals.reduce((s,v)=>s+(v-lim.target)**2,0)/vals.length);\n  return Math.max(...vals);}\n// ---------- ÓTIMO: objetivo multi-critério ----------\n// Three normalised objectives, each 0..1, so the weights below are directly comparable. The old\n// score added a span cost measured in CBs to a desloc measured in CBs, so on a large sector the\n// balance term was numerically ~100x the desloc term and silently decided everything on its own.\n//   balance — RMS spread of the resulting spans (against the target span when one is set,\n//             otherwise against their own mean), over that reference. 0 = every destination\n//             sector ends up the same size.\n//   dist    — how far each município travels, CB-weighted: half from the share of its outer\n//             border that faces the sector it was given to, half from centroid distance. 0 =\n//             every município went to a sector it physically touches. Adjacency used to be a\n//             pure yes/no constraint (contiguity); this is what makes it a preference too.\n//   orphan  — stranded orphan CBs (leader left without a home sector, so they land nowhere at\n//             all) plus CBs split away from their leader's home, over the CBs at stake.\n//             Stranded ones count OPT_STRAND_K times: a stranded CB leaves the model entirely,\n//             a deslocated one merely changes hands.\nconst OPT_W={balance:1, dist:0.85, orphan:1};\nconst OPT_STRAND_K=3;\nconst OPT_SIMS=24;             // simultaneous simulations per run, scaled down on big stages\nconst OPT_PASSES=14;           // local-search sweeps inside one simulation\nconst OPT_EVAL_BUDGET=120000;  // total candidate evaluations across the whole portfolio\nconst OPT_EXHAUSTIVE_MAX=60000;\nfunction optDistPrep(mv){\n  let mx=0;\n  Object.keys(mv.munis).forEach(m=>{ if(m==='__OUTROS__') return; const c=mv.muniCentroid[m]; if(!c) return;\n    mv.dest.forEach(s=>{ const sc=mv.secCentroid[s]; if(!sc) return;\n      const d=Math.hypot(c[0]-sc[0],c[1]-sc[1]); if(d>mx) mx=d; }); });\n  return mx||1;\n}\nfunction optDistCost(mv,m,s,dmax){\n  const v=mv.allowed[m]||{}; let tot=0; for(const k in v) tot+=v[k];\n  const share=tot?((v[s]||0)/tot):0;\n  const c=mv.muniCentroid[m], sc=mv.secCentroid[s];\n  // Missing centroid on either side is neutral (0.5), never 0 — an unmeasurable move must not\n  // score as the cheapest one on the board.\n  const geo=(c&&sc)?Math.min(1,Math.hypot(c[0]-sc[0],c[1]-sc[1])/dmax):0.5;\n  return 0.5*(1-share)+0.5*geo;\n}\nfunction optEval(mv,a,dmax){\n  const lim=spanLimits();\n  const sp=spanMapFor(mv,a), vals=Object.values(sp);\n  let balance=0;\n  if(vals.length){\n    const ref=lim.target||(vals.reduce((x,y)=>x+y,0)/vals.length);\n    if(ref) balance=Math.min(1,Math.sqrt(vals.reduce((s,v)=>s+(v-ref)**2,0)/vals.length)/ref);\n  }\n  let cbs=0,dist=0;\n  for(const m in a){ const o=mv.munis[m]; if(!o) continue; const w=+o.cbs||0; cbs+=w; dist+=w*optDistCost(mv,m,a[m],dmax); }\n  dist=cbs?(dist/cbs):0;\n  let stranded=0,desloc=0,tot=0;\n  mv.leaders.forEach(L=>{\n    const byS={}; let t=0;\n    for(const [m,c] of Object.entries(L.byMuni)){ if(m==='__OUTROS__') continue; const s=a[m]; if(s){ byS[s]=(byS[s]||0)+c; t+=c; } }\n    const orphan=(mv.orphanByGroup&&mv.orphanByGroup[L.grupo])||0;\n    tot+=t+orphan;\n    if(!t){ stranded+=orphan; return; }   // no allocated CBs -> no home -> these orphans land nowhere\n    const home=mv.dest.reduce((x,s)=>(byS[s]||0)>(byS[x]||0)?s:x,mv.dest[0]);\n    desloc+=t-(byS[home]||0);\n  });\n  const orphanTerm=tot?Math.min(1,(desloc+OPT_STRAND_K*stranded)/tot):0;\n  return { cost:OPT_W.balance*balance+OPT_W.dist*dist+OPT_W.orphan*orphanTerm,\n           balance, dist, desloc, stranded, sp, viol:spanViolationsOf(sp,lim) };\n}\nfunction optScoreFor(mv,a){ return optEval(mv,a,mv.__optDmax||optDistPrep(mv)).cost; }\n// Seeded PRNG on purpose. computeMovement runs the optimizer for EVERY stage and recomputeFrom\n// re-runs every stage downstream of an edit, so a Math.random() portfolio would hand back a\n// different allocation on each pass and a saved timeline would never reproduce.\nfunction optRng(seed){ let a=seed>>>0;\n  return ()=>{ a=(a+0x6D2B79F5)>>>0; let t=Math.imul(a^(a>>>15),1|a);\n    t=(t+Math.imul(t^(t>>>7),61|t))^t; return ((t^(t>>>14))>>>0)/4294967296; }; }\nfunction optSeed(mv,ms){ let h=2166136261>>>0;\n  const s=String(mv.sector)+'|'+ms.join(',')+'|'+mv.dest.join(',');\n  for(let i=0;i<s.length;i++) h=Math.imul(h^s.charCodeAt(i),16777619);\n  return h>>>0; }\nfunction contigBadFor(mv,assign){\n  const bySec={}; for(const m in assign){(bySec[assign[m]]=bySec[assign[m]]||[]).push(m);}\n  const bad=[];\n  for(const s in bySec){const grp=bySec[s],inGrp=new Set(grp);\n    const seeds=grp.filter(m=>(mv.allowed[m]||{})[s]);\n    if(!seeds.length){bad.push(s);continue;}\n    const seen=new Set(seeds),st=[...seeds];\n    while(st.length){const m=st.pop();(mv.closeAdj[m]||[]).forEach(o=>{if(inGrp.has(o)&&!seen.has(o)){seen.add(o);st.push(o);}});}\n    if(seen.size!==grp.length)bad.push(s);}\n  return bad;\n}\nfunction greedyFor(mv,ms,opts){const lim=spanLimits();const a={};\n  ms.forEach((m,i)=>{let best=opts[i][0],bd=Infinity;\n    for(const s of opts[i]){a[m]=s;\n      // over-max only grows as we keep assigning, so it is safe to penalise mid-build;\n      // under-min shrinks, so penalising it here would just chase noise.\n      const over=lim.max?spanViolationsOf(spanMapFor(mv,a),{max:lim.max,min:null}).over.length:0;\n      const d=optScoreFor(mv,a)+(contigBadFor(mv,a).length?1e9:0)+over*1e6;\n      if(d<bd){bd=d;best=s;}}\n    a[m]=best;});\n  return a;}\nfunction optimizeFor(mv){\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__');\n  if(!ms.length||!mv.dest.length){ mv.optBadge=mv.dest.length?'':'sem setor destino identificado'; return {}; }\n  const opts=ms.map(m=>{const al=(mv.reachable[m]&&mv.reachable[m].length)?mv.reachable[m]:mv.dest.slice();return al.length?al:mv.dest.slice();});\n  const combos=opts.reduce((a,o)=>a*o.length,1);\n  const lim=spanLimits();\n  const dmax=optDistPrep(mv); mv.__optDmax=dmax;\n\n  // Feasibility ladder — same meaning as before, because renderSpanRule and the saved sessions\n  // both read mv.optTier: 0 = contíguo + max + min span, 1 = contíguo + max span, 2 = contíguo\n  // only. Every candidate any simulation produces is offered to all three tiers; we ship the\n  // strictest tier that got a solution, so an unreachable min/max relaxes loudly instead of\n  // stranding the stage with nothing.\n  const tier=[null,null,null],tSc=[Infinity,Infinity,Infinity],tEv=[null,null,null];\n  let feasible=0,evals=0;\n  const offer=(a,ev)=>{\n    if(contigBadFor(mv,a).length) return false;\n    feasible++;\n    const put=t=>{ if(ev.cost<tSc[t]){ tSc[t]=ev.cost; tier[t]={...a}; tEv[t]=ev; } };\n    put(2);\n    if(!ev.viol.over.length){ put(1); if(!ev.viol.under.length) put(0); }\n    return true;\n  };\n  const evalOf=a=>{ evals++; return optEval(mv,a,dmax); };\n\n  // Small enough to settle the question outright: enumerate every contiguous allocation and the\n  // winner is provably the best one under this objective. No search needed, so none is run.\n  const exhaustive=combos<=OPT_EXHAUSTIVE_MAX;\n  let sims=0;\n  if(exhaustive){\n    const cur={};\n    const rec=i=>{ if(i===ms.length){ offer(cur,evalOf(cur)); return; }\n      for(const s of opts[i]){ cur[ms[i]]=s; rec(i+1); } };\n    rec(0);\n  } else {\n    // Too big to enumerate, so run a portfolio of independent simulations instead. Each one is a\n    // seed assignment, then hill-climbing on single-município reassignments, then one perturbation\n    // and another climb to escape whatever local optimum it settled into. Different seeds land in\n    // different basins; the ladder above keeps the best across all of them.\n    const improve=a=>{\n      let ev=evalOf(a); offer(a,ev);\n      for(let pass=0;pass<OPT_PASSES;pass++){\n        let moved=false;\n        for(let i=0;i<ms.length;i++){\n          const m=ms[i],cur=a[m]; let best=cur,bev=ev;\n          for(const s of opts[i]){\n            if(s===cur) continue;\n            a[m]=s;\n            const e=evalOf(a);\n            // contiguity is a hard constraint, and the cheaper test runs first on purpose\n            if(e.cost<bev.cost-1e-12 && !contigBadFor(mv,a).length){ best=s; bev=e; }\n          }\n          a[m]=best;\n          if(best!==cur){ ev=bev; moved=true; offer(a,ev); }\n        }\n        if(!moved) break;\n      }\n    };\n    const rng=optRng(optSeed(mv,ms));\n    const perSim=Math.max(1,ms.length*Math.max(1,mv.dest.length)*OPT_PASSES);\n    const budget=Math.max(4,Math.min(OPT_SIMS,Math.floor(OPT_EVAL_BUDGET/perSim)||4));\n    const seeds=[];\n    if(mv.assign&&ms.every(m=>mv.assign[m])) seeds.push({...mv.assign});   // never regress on what is already on screen\n    seeds.push(autoAssignFor(mv));                                        // adjacency-first seed\n    seeds.push(greedyFor(mv,ms,opts));                                    // balance-first seed\n    while(seeds.length<budget){\n      // randomised construction biased toward nearby sectors — the bias keeps each seed sane, the\n      // randomness is what sends the runs into different basins\n      const a={};\n      ms.forEach((m,i)=>{ let bs=opts[i][0],bw=-Infinity;\n        for(const s of opts[i]){ const w=(1-optDistCost(mv,m,s,dmax))+rng()*0.9; if(w>bw){bw=w;bs=s;} }\n        a[m]=bs; });\n      seeds.push(a);\n    }\n    seeds.forEach(seed=>{\n      const a={...seed};\n      // a restored or legacy assign can still name a sector this stage no longer offers\n      ms.forEach((m,i)=>{ if(!opts[i].includes(a[m])) a[m]=opts[i][0]; });\n      improve(a);\n      const kick=Math.max(1,Math.round(ms.length*0.25));\n      for(let k=0;k<kick;k++){ const i=Math.floor(rng()*ms.length); a[ms[i]]=opts[i][Math.floor(rng()*opts[i].length)]; }\n      improve(a);\n    });\n    sims=seeds.length;\n  }\n\n  const t=tier.findIndex(x=>x);\n  if(t<0){ mv.optTier=-1; mv.optBadge='sem solução contígua — heurística'; return greedyFor(mv,ms,opts); }\n  const best=tier[t], ev=tEv[t]; mv.optTier=t;\n  const relax=[];\n  if(t>0 && lim.min) relax.push('span mínimo '+fmt(lim.min));\n  if(t>1 && lim.max) relax.push('span máximo '+fmt(lim.max));\n  mv.optStats={balance:ev.balance,dist:ev.dist,desloc:ev.desloc,stranded:ev.stranded,sims,evals,feasible,combos,exhaustive};\n  mv.optBadge=`ótimo · ${exhaustive?`espaço completo (${fmt(combos)})`:`${fmt(sims)} simulações`}`\n    +` · equilíbrio ±${Math.round(ev.balance*100)}% · adjacência ${Math.round((1-ev.dist)*100)}%`\n    +` · quebra ${fmt(ev.desloc)} CBs · órfãs sem destino ${fmt(ev.stranded)}`\n    +` · pico ${fmt(maxSpanOfFor(mv,best))} CBs`\n    +(lim.target?` · alvo ${fmt(lim.target)}`:'')\n    +(relax.length?` · ⚠ inatingível: ${relax.join(' + ')}`:(spanLimitsOn(lim)?' · limites ok':''));\n  return best;\n}\nfunction computeDerived(mv){\n  const inc={},gained={},orph={};mv.dest.forEach(s=>{inc[s]=0;gained[s]=0;orph[s]=0;});\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(s&&inc[s]!=null)inc[s]+=o.cbs;});\n  let desloc=0,orphanTotal=0,orphanStranded=0;\n  const L2=mv.leaders.map(L=>{const byS={};mv.dest.forEach(s=>byS[s]=0);let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=mv.assign[m];if(s&&byS[s]!=null){byS[s]+=c;tot+=c;}}\n    // no allocated CBs => no home. Picking dest[0] by default would silently dump this leader's\n    // orphan CBs into an arbitrary sector, so we leave it null and report them as stranded.\n    const home=(tot>0&&mv.dest.length)?mv.dest.reduce((a,s)=>byS[s]>byS[a]?s:a,mv.dest[0]):null;\n    const dl=tot-(home?byS[home]:0);desloc+=dl;if(home)gained[home]++;\n    const orphan=(mv.orphanByGroup&&mv.orphanByGroup[L.grupo])||0;\n    orphanTotal+=orphan;\n    if(orphan){ if(home&&orph[home]!=null) orph[home]+=orphan; else orphanStranded+=orphan; }\n    return {...L,byS,home,desloc:dl,assignedTotal:tot,orphan,orphanHome:(orphan&&home)?home:null};});\n  const span={};mv.dest.forEach(s=>span[s]=(+(mv.baseMetrics[s]?.cbs||0))+inc[s]+orph[s]);\n  return {inc,gained,desloc,leaders:L2,span,orph,orphanTotal,orphanStranded};\n}\n\nasync function computeMovement(sec,world){\n  sec=String(sec);\n  const mv={sector:sec,policy:'fora',view:'tobe',selMuni:null,optBadge:''};\n  const SNAP=SESS.snapLiteral;\n  const mSQL=`SELECT DS_MUNICIPIO municipio, CD_GRUPO grupo, MAX(DS_LEADER_NAME) lider,\n     COUNT(DISTINCT CD_CONSULTORA) cbs, ROUND(SUM(VL_PEDIDOS_CICLO),0) receita,\n     ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat, ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon,\n     ROUND(SUM(QT_PONTOS_CICLO),0) pontos, ROUND(SUM(QT_PEDIDOS_CICLO),0) pedidos,\n     SUM(CASE WHEN ST_FEZ_PED_CICLO_ATUAL THEN 1 ELSE 0 END) ativas\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${sec} AND DT_HISTORICO=${SNAP}\n    GROUP BY DS_MUNICIPIO, CD_GRUPO`;\n  const ufSQL=`SELECT DS_ESTADO uf, COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${sec} AND DT_HISTORICO=${SNAP} GROUP BY DS_ESTADO ORDER BY n DESC`;\n  const trendSQL=`SELECT operational_cycle ciclo, COUNT(DISTINCT person_code) cbs,\n     COUNT(DISTINCT CASE WHEN registered_bc_ind=1 THEN person_code END) ativas,\n     ROUND(SUM(revenue_nat_ss_amt),0) receita, ROUND(SUM(points_nat_ss_amt),0) pontos\n    FROM ${ELO} WHERE country_code=1 AND sector_code=${sec} AND operational_cycle<=${SESS.eloMaxCycle||999999} GROUP BY operational_cycle ORDER BY operational_cycle DESC LIMIT 8`;\n  const lvlSQL=`SELECT DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs,\n     ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat, ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${sec} AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY DS_NVL_PLAN_CRESC ORDER BY cbs DESC`;\n  const lvlMuniSQL=`SELECT DS_MUNICIPIO municipio, DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${sec} AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY DS_MUNICIPIO, DS_NVL_PLAN_CRESC`;\n  const [mR,ufR,tR,lR,lmR]=await Promise.all([q(mSQL),q(ufSQL),q(trendSQL),q(lvlSQL),q(lvlMuniSQL)]);\n\n  const ownMatrix=rows(mR).filter(r=>+r.cbs>0);\n  mv.pureOwnMetrics=blankM();\n  ownMatrix.forEach(r=>{ mv.pureOwnMetrics.cbs+=+r.cbs; mv.pureOwnMetrics.receita+=+r.receita||0; mv.pureOwnMetrics.rec_nat+=+r.rec_nat||0; mv.pureOwnMetrics.rec_avon+=+r.rec_avon||0; mv.pureOwnMetrics.pontos+=+r.pontos||0; mv.pureOwnMetrics.pedidos+=+r.pedidos||0; mv.pureOwnMetrics.ativas+=+r.ativas||0; });\n\n  mv.levelByMuni={};\n  Object.entries(world.muniLevel||{}).forEach(([m,lv])=>{ if(world.muniOverride[m]===sec) mv.levelByMuni[m]={...lv}; });\n  // keyed further down, once the UF resolver exists — see the stamp block after buildAdj.\n\n  mv.trend=rows(tR).map(r=>({...r,cbs:+r.cbs,ativas:+r.ativas,receita:+r.receita,pontos:+r.pontos})).filter(r=>r.cbs>10).reverse();\n  mv.levels=rows(lR);\n\n  const absorbedRows=[];\n  Object.entries(world.muniOverride).forEach(([m,destSec])=>{ if(destSec===sec && world.muniRow[m]) absorbedRows.push(...world.muniRow[m]); });\n  // CBs an earlier etapa sent here by FOLLOWING THEIR LEADER (quebra de vínculo) carry no\n  // muniOverride — the município stayed where it was, only the CBs moved — so absorbedRows misses\n  // them. Leaving them out made this sector's ASIS total smaller than the span the previous etapa's\n  // TOBE promised for it, by exactly that etapa's \"CBs ignoradas\", and dropped those CBs from the\n  // redistribution as if they had never existed.\n  const inheritedOrphanRows=(world.sectorOrphanRows&&world.sectorOrphanRows[sec])||[];\n  mv.matrix=ownMatrix.concat(absorbedRows,inheritedOrphanRows);\n\n  const ufList=rows(ufR).map(r=>r.uf).filter(Boolean);\n  const domUF=ufList[0]||'PR';\n  mv.domUF=domUF;\n  const ufs=[domUF,...(UF_BORDERS[domUF]||[])];\n  const geos=await Promise.all(ufs.map(u=>geoUF(u).catch(()=>null)));\n  const allFeat=geos.filter(Boolean).flatMap(g=>g.features);\n  const adj=buildAdj(allFeat);\n\n  // ---- UF resolution ----\n  // SQL no longer selects DS_ESTADO. The base CSV is the authority on a município's UF, indexed by the\n  // (setor, município) pair it already maintains, and rows are stamped ONCE here so every consumer\n  // downstream keeps reading r.uf exactly as it did before.\n  // Fallback for a município the base does not carry — spillover, where the sector has CBs but no\n  // dominance: adopt the UF of the loaded shape when that name is unique across the UFs in this\n  // window. A name shared by two of them stays unresolved instead of guessed; it is spillover either\n  // way, and a guess would put a shape from the wrong state on the map.\n  const featUfByName={};\n  Object.keys(adj.feat).forEach(k=>{ const nm=mName(k),u=mUf(k);\n    if(!(nm in featUfByName)) featUfByName[nm]=u; else if(featUfByName[nm]!==u) featUfByName[nm]=null; });\n  const ufGuessed={};\n  const ufFor=(setorOf,muni)=>{const nm=norm(muni);\n    const fromCsv=DOM.ufBySetorMuni&&DOM.ufBySetorMuni[String(setorOf)+'|'+nm];\n    if(fromCsv) return fromCsv;\n    const uniq=featUfByName[nm];\n    if(uniq) return uniq;\n    // uniq===null means the name is shared by two of the LOADED UFs — Terra Nova exists in BA and in PE,\n    // and a BA sector loads PE as a border — so the unique-name fallback gave up. Giving up here too\n    // yields a bare NAME key, which matches NO shape: the município leaves the map without a word. The\n    // sector's own dominant UF is a real signal rather than a coin flip: when a shape of this name\n    // exists in domUF, that is the one this sector means. Recorded so the inference is never invisible.\n    if(uniq===null && adj.feat[mkey(nm,domUF)]){ ufGuessed[nm]=domUF; return domUF; }\n    return '';};\n  // Absorbed and orphan rows inherited from an earlier etapa were stamped when THAT etapa ran, under\n  // the setor they belonged to then; `if(!r.uf)` keeps that original resolution intact.\n  mv.matrix.forEach(r=>{ if(!r.uf) r.uf=ufFor(sec,r.municipio); });\n  rows(lmR).forEach(r=>{ const k=mkey(r.municipio,ufFor(sec,r.municipio)); (mv.levelByMuni[k]=mv.levelByMuni[k]||{})[r.nivel]=+r.cbs; });\n\n  const totBy={}; mv.matrix.forEach(r=>{const n=mkey(r.municipio,r.uf); totBy[n]=(totBy[n]||0)+ +r.cbs;});\n  const footprint=new Set(Object.entries(totBy).filter(([,c])=>c>0).map(([m])=>m));\n  const withShape=[...footprint].filter(n=>adj.feat[n]||world.muniGeo[n]);\n  mv.cbsByMuni={...totBy}; mv.shapeMunis=withShape.slice();\n  mv.noShapeMunis=[...footprint].filter(n=>!(adj.feat[n]||world.muniGeo[n])).map(n=>({muni:n,cbs:totBy[n]||0})).sort((a,b)=>b.cbs-a.cbs);\n  mv.noShapeCbs=mv.noShapeMunis.reduce((a,x)=>a+x.cbs,0);\n\n  // ---- RULE: which municipalities belong to this sector ----\n  // Comes from the dominantes CSV (setor x município), NOT from CB volume. A municipality where the\n  // closing sector happens to have a handful of CBs but which the base assigns to ANOTHER sector is\n  // spillover: it is not reallocated, it is not drawn, and its CBs follow their leader (quebra de\n  // vínculo). Municipalities an EARLIER etapa moved into this sector count as belonging to it too —\n  // otherwise the cascade would throw away the territory the sector just received.\n  const domBase=await ensureDominantes();\n  const domOwn=domBase[String(sec)];\n  const inherited=new Set(Object.keys(world.muniOverride).filter(m=>world.muniOverride[m]===sec));\n  if((!domOwn||!domOwn.size)&&!inherited.size) throw new Error('setor '+sec+' não existe na base de municípios dominantes (CSV) — atualize a base antes de fechar este setor');\n  const belongs=n=>(domOwn&&(domOwn.has(n)||(!DOM.hasUf&&domOwn.has(mName(n)))))||inherited.has(n);\n  mv.domCsvMunis=(domOwn&&domOwn.size)||0;\n  const dropped=withShape.filter(n=>!belongs(n));\n  if(withShape.length && dropped.length===withShape.length) throw new Error('setor '+sec+': nenhum dos municípios com CBs está na base de dominantes — nada pra realocar');\n  const droppedSet=new Set(dropped);\n  mv.excludedMunis=dropped.map(n=>({muni:n,cbs:totBy[n]||0})).sort((a,b)=>b.cbs-a.cbs);\n  mv.excludedCbs=dropped.reduce((a,n)=>a+(totBy[n]||0),0);\n\n  const closingNames=withShape.filter(n=>!droppedSet.has(n));\n  const closingSet=new Set(closingNames);\n  mv.geoFeat={}; closingNames.forEach(n=>{ mv.geoFeat[n]=(adj.feat[n]&&adj.feat[n].f)||world.muniGeo[n]; });\n\n  // ---- why is the map short of the base? ----\n  // Three gates drop a município between the CSV and the map: footprint (no CBs under this setor at\n  // the snapshot), withShape (no geometry in the loaded UFs) and belongs (key mismatch). Only the last\n  // two had a counter, so the FIRST one was invisible — the operator counted 5 shapes against a base\n  // that says 6 and had nothing on screen explaining the missing one. Named here, with its reason.\n  const drawnCsv=new Set(closingNames.map(n=>DOM.hasUf?n:mName(n)));\n  const footKeyOf=k=>DOM.hasUf?(footprint.has(k)?k:null):([...footprint].find(x=>mName(x)===k)||null);\n  mv.csvMunisTotal=(domOwn&&domOwn.size)||0;\n  mv.ufGuessed={...ufGuessed};\n  mv.missingMunis=[...(domOwn||[])].filter(k=>!drawnCsv.has(k)).map(k=>{\n    const fk=footKeyOf(k);\n    const hasShape=!!(adj.feat[fk||k]||world.muniGeo[fk||k]);\n    // The SAME name present in footprint under a DIFFERENT key means UF resolution disagreed with the\n    // base: the CBs are there, they just landed on a key that no shape and no dominance entry can\n    // match. That is a different failure from \"this município has no CBs\", and conflating the two sends\n    // the next investigation into the CB base when the answer is in the UF column.\n    const alias=[...footprint].filter(x=>x!==k&&mName(x)===mName(k));\n    let reason;\n    if(!fk&&alias.length) reason=`UF divergente — as CBs entraram na chave ${alias.join(' / ')} e a base diz ${k}`;\n    else if(!fk) reason=hasShape?'sem CBs neste setor no snapshot — desenhado como vizinho':'sem CBs neste setor no snapshot';\n    else if(!hasShape) reason='sem shape nas UFs carregadas';\n    else reason='chave não casou com a base de dominantes';\n    return {muni:k,cbs:(fk?totBy[fk]:alias.reduce((a,x)=>a+(totBy[x]||0),0))||0,reason:reason};\n  });\n\n  const extVotes={}; const nbrNames=new Set();\n  closingNames.forEach(n=>{\n    if(!adj.feat[n]) return;\n    // droppedSet is excluded too: a municipality outside this sector's dominance base must not\n    // nominate a destination sector, and it is not drawn on the map at all.\n    adj.nbrs(n).forEach(({o,w})=>{ if(!closingSet.has(o) && !droppedSet.has(o)){ nbrNames.add(o); (extVotes[n]=extVotes[n]||[]).push({o,w}); } });\n  });\n  // mv.nbrFeat is assembled far below, once mv.dest is final: the map draws only the closing sector's\n  // municípios and the destination sectors' territory, so the feature list cannot be built until the\n  // neighbour query has resolved each neighbour's sector AND the destination list has settled.\n  let neigh=[];\n  if(nbrNames.size){\n    // The IN-list carries bare names — that is what the column holds, and the query is back to not\n    // selecting DS_ESTADO. Every returned row names its own CD_SETOR, which is exactly the key the\n    // base CSV is indexed by, so each row's UF is resolved from the base right after the fetch.\n    const inList=[...new Set([...nbrNames].map(n=>mName(n)))].map(n=>`'${n.replace(/'/g,\"''\")}'`).join(',');\n    const nSQL=`SELECT DS_MUNICIPIO municipio, CD_SETOR setor, COUNT(DISTINCT CD_CONSULTORA) cbs\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR<>${sec} AND CD_SETOR>0 AND DT_HISTORICO=${SNAP} AND DS_MUNICIPIO IN (${inList}) GROUP BY DS_MUNICIPIO, CD_SETOR`;\n    neigh=rows(await q(nSQL));\n  }\n  const domSecOf={};\n  neigh.forEach(r=>{ if(!r.uf) r.uf=ufFor(r.setor,r.municipio); });\n  const byM={}; neigh.forEach(r=>{const m=mkey(r.municipio,r.uf); if(!nbrNames.has(m)) return; (byM[m]=byM[m]||[]).push(r);});\n  Object.entries(byM).forEach(([m,rs])=>{ rs.sort((a,b)=>+b.cbs-+a.cbs); domSecOf[m]=String(rs[0].setor); });\n  nbrNames.forEach(n=>{ if(world.muniOverride[n]) domSecOf[n]=world.muniOverride[n]; });\n  mv.nbrCurrentSector=domSecOf;\n\n  // ---- HARD RULE: which sectors are eligible as destinations ----\n  // 'any'      = a sector qualifies if it borders ANY municipality of the closing sector (legacy default)\n  // 'dominant' = only municipalities with >= destMinCbs CBs act as a bridge; smaller ones are still\n  //              redistributed, they just cannot pull a new sector into the destination list.\n  //\n  // ---- HARD RULE (não negociável): destino tem de ser da MESMA gerência de vendas ----\n  // Território não atravessa fronteira de GV num fechamento real: outra gerência é outra gestora,\n  // outra meta e outro P&L, então oferecer um setor de fora produz um plano que ninguém pode\n  // executar. CD_GV da base de dominantes é a autoridade, e o filtro roda ANTES de qualquer peso,\n  // regra de ponte ou corte de top-6 — nenhum caminho a jusante pode reintroduzir um setor de fora.\n  const gvBySetor=DOM.gvBySetor||{};\n  const gvOf=s=>gvBySetor[String(s)]||'';\n  const ownGv=gvOf(sec);\n  if(!ownGv) throw new Error('setor '+sec+' não tem CD_GV na base de municípios dominantes — sem a gerência de vendas do setor que fecha não há como garantir que os destinos sejam da mesma gerência');\n  // Um candidato que a base não carrega NÃO pode ser provado como da mesma gerência, então é recusado\n  // em vez de presumido: a regra só vale se nunca vazar.\n  const sameGv=s=>gvOf(s)===ownGv;\n  mv.gv=ownGv;\n  // Foreign-GV neighbours are not drawn AT ALL. Filtering them only out of the destination list still\n  // left them sitting on the map as context, which invites the exact question the rule exists to\n  // settle — \"why is 3471 right there if nothing can be sent to it?\". A neighbour whose sector is\n  // UNKNOWN is not being asserted to be foreign, so it stays as plain geography.\n  // Dropping them also tightens the frame: project() fits the bounding box of whatever survives, so the\n  // closing sector is drawn larger once foreign territory stops padding the extent.\n  mv.gvRejected=[];\n  const gvHidden=new Set();\n  nbrNames.forEach(n=>{ const s=domSecOf[n];\n    if(s && !sameGv(s)){ gvHidden.add(n); if(mv.gvRejected.indexOf(s)<0) mv.gvRejected.push(s); } });\n  gvHidden.forEach(n=>{ nbrNames.delete(n); delete domSecOf[n]; });\n  Object.keys(extVotes).forEach(n=>{ extVotes[n]=extVotes[n].filter(v=>!gvHidden.has(v.o)); });\n  const destRule=(SESS.settings&&SESS.settings.destRule)||DEFAULT_SETTINGS.destRule;\n  const destMinCbs=Math.max(1,Math.round(+((SESS.settings||{}).destMinCbs)||DEFAULT_SETTINGS.destMinCbs));\n  mv.destRule=destRule; mv.destMinCbs=destMinCbs; mv.destRuleFallback=false;\n  const isBridge=n=>destRule!=='dominant'||(totBy[n]||0)>=destMinCbs;\n  mv.bridgeMunis=closingNames.filter(isBridge);\n  mv.ignoredBridges=closingNames.filter(n=>!isBridge(n));\n\n  mv.allowed={}; mv.allowedRaw={};\n  const wAny={}, wDom={};\n  closingNames.forEach(n=>{\n    const v={}, bridge=isBridge(n);\n    (extVotes[n]||[]).forEach(({o,w})=>{\n      const s=domSecOf[o];\n      if(!s || s===sec || world.closedSectors.has(s)) return;\n      // Belt to the braces above: domSecOf has already had every foreign-GV neighbour removed, so this\n      // cannot fire any more. It stays because it costs one comparison and the failure it guards — a\n      // destination in the wrong gerência — is the whole reason this code exists.\n      if(!sameGv(s)) return;\n      v[s]=(v[s]||0)+w;\n      wAny[s]=(wAny[s]||0)+w;\n      if(bridge) wDom[s]=(wDom[s]||0)+w;\n    });\n    mv.allowed[n]=v; mv.allowedRaw[n]={...v};\n  });\n  let secWeight=wAny;\n  if(destRule==='dominant'){\n    if(Object.keys(wDom).length) secWeight=wDom;\n    else { secWeight=wAny; mv.destRuleFallback=true; }   // no dominant municipality at all -> relax, never strand the stage\n  }\n  mv.dest=Object.keys(secWeight).sort((a,b)=>secWeight[b]-secWeight[a]).slice(0,6);\n  if(!mv.dest.length){\n    // O fallback de emergência lê domSecOf direto, contornando os votos — então repete o filtro de GV\n    // aqui. Sem isso a regra vazaria exatamente no caso em que ninguém está olhando.\n    mv.dest=[...new Set(Object.values(domSecOf))].filter(s=>s!==sec&&!world.closedSectors.has(s)&&sameGv(s)).slice(0,6);\n    closingNames.forEach(n=>{ if(!Object.keys(mv.allowed[n]).length){ mv.dest.forEach(s=>mv.allowed[n][s]=1); } });\n  }\n  // Cinto e suspensório: qualquer rota que tenha preenchido mv.dest (pesos, fallback, um branch novo\n  // amanhã) passa por esta linha, e nada de fora da gerência do setor que fecha sobrevive a ela.\n  mv.dest=mv.dest.filter(sameGv);\n  // WHAT GETS DRAWN — decided here because it depends on the final destination list. Two things only:\n  // the closing sector's municípios and the territory of the sectors that can actually receive them.\n  // Every other neighbour was geographic filler that stretched the frame and settled no question.\n  // nbrCurrentSector deliberately keeps its full mapping: drawMap groups whatever survives in nbrFeat\n  // by it, so trimming the feature list is the entire change.\n  const drawSet=new Set(mv.dest);\n  mv.nbrFeat=[...nbrNames].filter(n=>drawSet.has(domSecOf[n])).map(n=>adj.feat[n]&&adj.feat[n].f).filter(Boolean);\n  // votes may reference a sector that did not survive the rule / top-6 cut — drop them so\n  // autoAssign + contiguity seeds can never point at a sector outside mv.dest\n  closingNames.forEach(n=>{ Object.keys(mv.allowed[n]||{}).forEach(s=>{ if(!mv.dest.includes(s)) delete mv.allowed[n][s]; }); });\n  mv.color={}; mv.dest.forEach((s,i)=>mv.color[s]=PALETTE[i%PALETTE.length]);\n\n  const accC={}; Object.entries(domSecOf).forEach(([nb,s])=>{ if(!mv.dest.includes(s))return; const f=(adj.feat[nb]&&adj.feat[nb].f)||world.muniGeo[nb]; if(!f)return; const c=centroidLL(f); accC[s]=accC[s]||[0,0,0]; accC[s][0]+=c[0]; accC[s][1]+=c[1]; accC[s][2]++; });\n  mv.secCentroid={}; mv.dest.forEach(s=>{ if(accC[s]) mv.secCentroid[s]=[accC[s][0]/accC[s][2],accC[s][1]/accC[s][2]]; });\n  mv.muniCentroid={}; closingNames.forEach(n=>{ if(mv.geoFeat[n]) mv.muniCentroid[n]=centroidLL(mv.geoFeat[n]); });\n  mv.closeAdj={}; closingNames.forEach(n=>{ mv.closeAdj[n]=adj.feat[n]?adj.nbrs(n).filter(({o})=>closingSet.has(o)).map(({o})=>o):[]; });\n  const comp={}; let ci=0; closingNames.forEach(n=>{ if(comp[n]!=null)return; const st=[n]; comp[n]=ci; while(st.length){ const x=st.pop(); (mv.closeAdj[x]||[]).forEach(o=>{ if(comp[o]==null){ comp[o]=ci; st.push(o); } }); } ci++; });\n  const compAllowed={}; closingNames.forEach(n=>{ const cid=comp[n]; (compAllowed[cid]=compAllowed[cid]||new Set()); Object.keys(mv.allowed[n]||{}).forEach(s=>compAllowed[cid].add(s)); });\n  mv.reachable={}; closingNames.forEach(n=>{ mv.reachable[n]=[...(compAllowed[comp[n]]||[])].filter(s=>mv.dest.includes(s)); });\n\n  if(mv.dest.length){\n    const rList=mv.dest.join(',');\n    const bSQL=`SELECT CD_SETOR setor, COUNT(DISTINCT CD_CONSULTORA) cbs,\n       SUM(CASE WHEN ST_FEZ_PED_CICLO_ATUAL THEN 1 ELSE 0 END) ativas,\n       ROUND(SUM(VL_PEDIDOS_CICLO),0) receita, ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat,\n       ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon, ROUND(SUM(QT_PONTOS_CICLO),0) pontos, ROUND(SUM(QT_PEDIDOS_CICLO),0) pedidos\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${rList}) AND DT_HISTORICO=${SNAP} GROUP BY CD_SETOR`;\n    const blSQL=`SELECT CD_SETOR setor, DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${rList}) AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY CD_SETOR, DS_NVL_PLAN_CRESC`;\n    const [bR,blR]=await Promise.all([q(bSQL),q(blSQL)]);\n    mv.baseMetrics={}; rows(bR).forEach(r=>{ mv.baseMetrics[String(r.setor)]=r; if(!SESS.sectorBaseCache[String(r.setor)]) SESS.sectorBaseCache[String(r.setor)]=r; });\n    mv.baseLevel={}; rows(blR).forEach(r=>{ const s=String(r.setor); (mv.baseLevel[s]=mv.baseLevel[s]||{})[r.nivel]=+r.cbs; });\n  } else { mv.baseMetrics={}; mv.baseLevel={}; }\n\n  mv.dest.forEach(s=>{\n    if(!mv.baseMetrics[s]) mv.baseMetrics[s]={setor:s,cbs:0,ativas:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0};\n    const infl=world.sectorInflow[s];\n    if(infl) Object.keys(blankM()).forEach(k=>{ mv.baseMetrics[s][k]=(+mv.baseMetrics[s][k]||0)+(infl[k]||0); });\n    const inflLv=world.sectorInflowLevel[s];\n    if(inflLv){ mv.baseLevel[s]=mv.baseLevel[s]||{}; Object.entries(inflLv).forEach(([nv,c])=>{ mv.baseLevel[s][nv]=(mv.baseLevel[s][nv]||0)+c; }); }\n  });\n\n  aggregateMovement(mv);\n  mv.assign=optimizeFor(mv);\n  return mv;\n}\n\n// ---------- cascade engine ----------\nasync function computeAt(i){\n  await ensurePinned();\n  const world=foldWorldState(SESS.timeline,i);\n  const sec=SESS.timeline[i].sector;\n  const mv=await computeMovement(sec,world);\n  SESS.timeline[i]=mv;\n}\nasync function recomputeFrom(i){\n  for(let k=i;k<SESS.timeline.length;k++){\n    loader(true,`recalculando etapa ${k+1} de ${SESS.timeline.length}…`);\n    try{ await computeAt(k); }\n    catch(e){\n      // A stage that failed to recompute still holds its PREVIOUS result, so the timeline would\n      // silently mix pre- and post-change etapas. Flag this stage and everything after it as stale\n      // (dashed chip + ⟳) so the state is visible and flushDirty retries from here.\n      SESS.dirtyFrom = SESS.dirtyFrom==null ? k : Math.min(SESS.dirtyFrom,k);\n      throw e;\n    }\n  }\n}\nasync function flushDirty(){\n  if(SESS.dirtyFrom==null) return;\n  const from=SESS.dirtyFrom; SESS.dirtyFrom=null;\n  loader(true,'recalculando etapas seguintes…');\n  try{ await recomputeFrom(from); }\n  catch(e){ $('#ds').textContent='erro ao recalcular cadeia: '+e.message; $('#ds').classList.add('err'); }\n  finally{ loader(false); }\n}\nasync function addMovement(sec){\n  sec=(sec==null?'':String(sec)).trim();\n  if(!sec) return;\n  const closedNow=new Set(SESS.timeline.map(mv=>mv.sector));\n  if(closedNow.has(sec)){ $('#ds').textContent='setor '+sec+' já fechado nesta timeline.'; $('#ds').classList.add('err'); return; }\n  if(!SESS.sectorsList.some(s=>s.setor===sec)){ $('#ds').textContent='setor \"'+sec+'\" não existe no snapshot congelado.'; $('#ds').classList.add('err'); return; }\n  await flushDirty();\n  loader(true,'fechando setor '+sec+' — puxando dado do snapshot congelado…');\n  $('#liveState').classList.remove('err');\n  try{\n    SESS.timeline.push({sector:String(sec)});\n    await computeAt(SESS.timeline.length-1);\n    SESS.selected=SESS.timeline.length-1;\n    $('#ds').classList.remove('err'); $('#ds').textContent='';\n  }catch(e){\n    SESS.timeline.pop();\n    $('#ds').textContent='erro ao fechar setor '+sec+': '+e.message; $('#ds').classList.add('err');\n  } finally { loader(false); renderAll(); }\n}\nfunction removeFrom(i){\n  SESS.timeline.length=i;\n  SESS.selected=Math.min(SESS.selected,Math.max(0,SESS.timeline.length-1));\n  if(SESS.dirtyFrom!=null && SESS.dirtyFrom>=SESS.timeline.length) SESS.dirtyFrom=null;\n  renderAll();\n}\nasync function editSelected(mutator){\n  const mv=SESS.timeline[SESS.selected]; if(!mv) return;\n  mutator(mv);\n  if(SESS.selected<SESS.timeline.length-1){\n    SESS.dirtyFrom = SESS.dirtyFrom==null ? SESS.selected+1 : Math.min(SESS.dirtyFrom, SESS.selected+1);\n  }\n  renderAll();\n}\n\n// ---------- sectors list / picker ----------\nasync function loadSectors(){\n  const sql=`SELECT CD_SETOR setor, MAX(DS_ESTADO) uf, COUNT(DISTINCT CD_CONSULTORA) cbs, COUNT(DISTINCT DS_MUNICIPIO) munis\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR>0 AND DT_HISTORICO=(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1) GROUP BY CD_SETOR ORDER BY CD_SETOR`;\n  SESS.sectorsList=rows(await q(sql)).map(r=>({setor:String(r.setor),uf:r.uf,cbs:+r.cbs,munis:+r.munis}));\n}\nfunction renderSectorPicker(){\n  const closed=new Set(SESS.timeline.map(mv=>mv.sector));\n  const avail=SESS.sectorsList.filter(s=>!closed.has(s.setor));\n  const dl=$('#sectorPickList');\n  if(dl) dl.innerHTML=avail.map(s=>`<option value=\"${s.setor}\">${s.setor} · ${s.uf||'?'} · ${fmt(s.cbs)} CBs · ${s.munis} munic.</option>`).join('');\n  const ready=SESS.stage==='main';\n  $('#rand').disabled=!ready||!avail.length; $('#go').disabled=!ready||!avail.length; $('#sectorPick').disabled=!ready;\n}\nfunction renderStage(){\n  const isMain=SESS.stage==='main';\n  const cc=$('#confirmCard'), hb=$('#howtoBox'), mw=$('#mainWrap');\n  const hdr=$('#topHeader'), rl=$('#timelineRail');\n  if(hdr) hdr.classList.toggle('hidden', !isMain);\n  if(rl) rl.classList.toggle('hidden', !isMain);\n  if(cc) cc.classList.toggle('hidden', isMain);\n  if(hb) hb.classList.toggle('hidden', !isMain);\n  if(mw) mw.classList.toggle('hidden', !isMain);\n  const cb=$('#confirmBody');\n  if(cb && !isMain) cb.innerHTML = SESS.snapshotRaw\n    ? `Snapshot congelado · atualizado em <b>${fmtSyncedAt(SESS.snapshotSyncedAt)}</b> · <b>${SESS.sectorsList.length}</b> setor(es) disponível(eis) pra fechar. Nenhuma consulta de detalhe roda até você confirmar e escolher o primeiro setor.`\n    : 'congelando snapshot…';\n}\n\n// ---------- session persistence ----------\n// ONE table (`timeline-shared-sessions`) holds BOTH kinds of row, so SESSIONS_DS and SHARED_DS\n// deliberately resolve to the SAME DataSet — the two names mark intent, not two stores:\n//   · working session  — carries `movements`; saveSession updates it in place\n//   · shared snapshot  — carries `sharedAt` + `stages`; doShare CREATES it and nothing updates it\n// listSessions MUST exclude the shared rows. They are write-once by contract, and if one showed\n// up in the picker, loading it and pressing Salvar would fire ds.update() straight at a frozen\n// snapshot. Filtering here is what keeps that unreachable — it is the guard, not a cosmetic.\n// Both lookups stay because a policy-gated DataSet's Process resolves late; same race the reader\n// component polls for. SESSIONS_DS lives at module scope so no call site can hit it in the TDZ.\nasync function listSessions(){\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS)); if(!ds) return [];\n  await ds.refresh();\n  const recs=ds.records()||[];\n  return [...recs].filter(r=>!r.sharedAt).sort((a,b)=>new Date(b.updatedAt||0)-new Date(a.updatedAt||0));\n}\nasync function refreshSessionPicker(){\n  const list=await listSessions().catch(()=>[]);\n  SESS.sessionsList=list;\n  const opts=list.map(s=>`<option value=\"${s._id}\">${esc(s.label||'sem nome')} · ${fmt((s.movements||[]).length)} etapa(s)</option>`).join('')+`<option value=\"\">+ Nova sessão</option>`;\n  const sel=$('#sessionPick'); if(sel){ sel.innerHTML=opts; sel.value=SESS.sessionId||''; }\n  const csel=$('#confirmSessionPick'); if(csel){ csel.innerHTML=opts; csel.value=SESS.sessionId||''; }\n}\nasync function saveSession(){\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS));\n  if(!ds){ $('#sessStatus').textContent='dataset de sessões indisponível'; return; }\n  if(!SESS.timeline.length){ $('#sessStatus').textContent='nada pra salvar ainda'; return; }\n  await flushDirty();\n  const label=SESS.sessionLabel||('Sessão '+new Date().toLocaleString('pt-BR'));\n  const nowIso=new Date().toISOString();\n  const payload={\n    label,\n    settings:{...DEFAULT_SETTINGS,...(SESS.settings||{})},\n    snapshotRaw:SESS.snapshotRaw, snapshotSyncedAt:SESS.snapshotSyncedAt, snapLiteral:SESS.snapLiteral, eloMaxCycle:SESS.eloMaxCycle,\n    movements:SESS.timeline.map(mv=>({\n      sector:mv.sector, assign:mv.assign, policy:mv.policy,\n      matrix:mv.matrix, trend:mv.trend, levels:mv.levels, levelByMuni:mv.levelByMuni,\n      pureOwnMetrics:mv.pureOwnMetrics, domUF:mv.domUF, dest:mv.dest, color:mv.color,\n      gv:mv.gv, gvRejected:mv.gvRejected,\n      missingMunis:mv.missingMunis, csvMunisTotal:mv.csvMunisTotal, ufGuessed:mv.ufGuessed,\n      baseMetrics:mv.baseMetrics, baseLevel:mv.baseLevel, geoFeat:mv.geoFeat, nbrFeat:mv.nbrFeat,\n      nbrCurrentSector:mv.nbrCurrentSector, allowed:mv.allowed, allowedRaw:mv.allowedRaw, reachable:mv.reachable,\n      destRule:mv.destRule, destMinCbs:mv.destMinCbs, destRuleFallback:mv.destRuleFallback, optTier:mv.optTier,\n      cbsByMuni:mv.cbsByMuni, shapeMunis:mv.shapeMunis, excludedMunis:mv.excludedMunis, domCsvMunis:mv.domCsvMunis,\n      excludedCbs:mv.excludedCbs, noShapeMunis:mv.noShapeMunis, noShapeCbs:mv.noShapeCbs,\n      secCentroid:mv.secCentroid, muniCentroid:mv.muniCentroid, closeAdj:mv.closeAdj, optBadge:mv.optBadge,\n    })),\n    updatedAt:nowIso,\n  };\n  $('#sessStatus').textContent='salvando…';\n  try{\n    if(SESS.sessionId){\n      await ds.update({_id:SESS.sessionId,...payload});\n    } else {\n      payload.createdAt=nowIso;\n      const created=await ds.create(payload);\n      SESS.sessionId=(created&&created._id)||(created&&created.data&&created.data._id)||(ds.selected&&ds.selected()&&ds.selected()._id)||null;\n    }\n    SESS.sessionLabel=label;\n    await refreshSessionPicker();\n    $('#sessStatus').textContent='salvo ✓';\n  }catch(e){ $('#sessStatus').textContent='erro ao salvar: '+e.message; }\n  setTimeout(()=>{ if($('#sessStatus')) $('#sessStatus').textContent=''; },2200);\n}\n// Duplicate = drop sessionId so saveSession takes its CREATE branch instead of its update branch.\n// The source record is never written to, the copy gets a fresh _id and its own createdAt, and the\n// copy becomes the active session (so the next Salvar edits the copy, not the original).\nasync function duplicateSession(){\n  if(!SESS.timeline.length){ $('#sessStatus').textContent='nada pra duplicar ainda'; return; }\n  const prevId=SESS.sessionId, prevLabel=SESS.sessionLabel;\n  SESS.sessionId=null;\n  SESS.sessionLabel=(SESS.sessionLabel||'Sessão')+' (cópia)';\n  await saveSession();\n  if(!SESS.sessionId){\n    // saveSession swallows its own errors into #sessStatus, so a still-null id after the await is\n    // the only failure signal we get. Put the original identity back — otherwise a failed copy\n    // silently leaves the session detached, and the next Salvar would fork a new record instead\n    // of updating the one the user thought they were editing.\n    SESS.sessionId=prevId; SESS.sessionLabel=prevLabel;\n    await refreshSessionPicker().catch(()=>{});\n    return;\n  }\n  if($('#sessStatus')) $('#sessStatus').textContent='duplicado ✓';\n}\nasync function loadSession(id){\n  if(!id) return newSession();\n  const list=(SESS.sessionsList&&SESS.sessionsList.length)?SESS.sessionsList:await listSessions().catch(()=>[]);\n  const rec=list.find(s=>String(s._id)===String(id));\n  if(!rec) return newSession();\n  loader(true,'carregando sessão…');\n  SESS.sessionId=rec._id; SESS.sessionLabel=rec.label;\n  SESS.snapshotRaw=rec.snapshotRaw; SESS.snapshotSyncedAt=rec.snapshotSyncedAt||null; SESS.snapLiteral=rec.snapLiteral; SESS.eloMaxCycle=rec.eloMaxCycle;\n  SESS.settings={...DEFAULT_SETTINGS,...(rec.settings||{})};\n  SESS.timeline=(rec.movements||[]).map(m=>({sector:String(m.sector)}));\n  SESS.selected=0; SESS.sectorBaseCache={}; SESS.stage='main'; SESS.dirtyFrom=null;\n  try{\n    for(let i=0;i<SESS.timeline.length;i++){\n      const saved=(rec.movements||[])[i]||{};\n      // Sessions saved before the UF became part of the key hold bare-name keys in matrix rows,\n      // assign, geoFeat and closeAdj alike. Restoring that cache would mix key shapes the moment the\n      // stage is recomputed — and it would preserve the very collision this fixes — so those stages\n      // are replayed against the pinned snapshot and the manual assignment is remapped by name.\n      const legacyKeys=!!(saved.matrix&&saved.matrix.length&&saved.matrix.every(r=>r&&r.uf==null));\n      // Sessões salvas antes da regra de gerência podem carregar um destino de outra GV no cache.\n      // Restaurar esse cache manteria o setor proibido na tela para sempre, então essas etapas são\n      // reprocessadas contra o snapshot fixado — igual ao tratamento das chaves legadas.\n      const crossGv=await savedCrossesGv(saved);\n      if(saved.matrix&&!legacyKeys&&!crossGv){\n        loader(true,`restaurando etapa ${i+1}/${SESS.timeline.length} — setor ${SESS.timeline[i].sector} (cache, sem consulta)…`);\n        const mv={sector:String(saved.sector), policy:saved.policy||'fora', view:'tobe', selMuni:null, optBadge:saved.optBadge||''};\n        mv.matrix=saved.matrix; mv.trend=saved.trend||[]; mv.levels=saved.levels||[]; mv.levelByMuni=saved.levelByMuni||{};\n        mv.pureOwnMetrics=saved.pureOwnMetrics||blankM(); mv.domUF=saved.domUF; mv.dest=saved.dest||[]; mv.color=saved.color||{};\n        mv.baseMetrics=saved.baseMetrics||{}; mv.baseLevel=saved.baseLevel||{}; mv.geoFeat=saved.geoFeat||{}; mv.nbrFeat=saved.nbrFeat||[];\n        mv.nbrCurrentSector=saved.nbrCurrentSector||{}; mv.allowed=saved.allowed||{}; mv.reachable=saved.reachable||{};\n        mv.allowedRaw=saved.allowedRaw||saved.allowed||{};\n        mv.destRule=saved.destRule||SESS.settings.destRule; mv.destMinCbs=saved.destMinCbs||SESS.settings.destMinCbs; mv.destRuleFallback=!!saved.destRuleFallback;\n        mv.optTier=(saved.optTier==null?0:saved.optTier);\n        mv.gv=saved.gv||''; mv.gvRejected=saved.gvRejected||[];\n        mv.missingMunis=saved.missingMunis||[]; mv.csvMunisTotal=saved.csvMunisTotal||0;\n        mv.ufGuessed=saved.ufGuessed||{};\n        mv.cbsByMuni=saved.cbsByMuni||{}; mv.shapeMunis=saved.shapeMunis||[];\n        mv.excludedMunis=saved.excludedMunis||[]; mv.excludedCbs=saved.excludedCbs||0; mv.domCsvMunis=saved.domCsvMunis||0;\n        mv.noShapeMunis=saved.noShapeMunis||[]; mv.noShapeCbs=saved.noShapeCbs||0;\n        mv.secCentroid=saved.secCentroid||{}; mv.muniCentroid=saved.muniCentroid||{}; mv.closeAdj=saved.closeAdj||{};\n        aggregateMovement(mv);\n        mv.assign=saved.assign||{};\n        mv.dest.forEach(s=>{ if(!SESS.sectorBaseCache[s]) SESS.sectorBaseCache[s]=mv.baseMetrics[s]; });\n        SESS.timeline[i]=mv;\n      } else {\n        loader(true,`replay ${i+1}/${SESS.timeline.length} — setor ${SESS.timeline[i].sector}…`);\n        await computeAt(i);\n        if(saved.assign) SESS.timeline[i].assign=legacyKeys?remapAssign(SESS.timeline[i],saved.assign):saved.assign;\n        if(saved.policy) SESS.timeline[i].policy=saved.policy;\n      }\n    }\n    await loadSectors();\n  }catch(e){ $('#ds').textContent='erro ao carregar sessão: '+e.message; $('#ds').classList.add('err'); }\n  finally{ loader(false); }\n  renderAll();\n}\n// Re-points a legacy bare-name assignment at the NAME/UF key it can only mean. When a name resolves\n// to more than one município in the recomputed stage the manual choice is genuinely ambiguous —\n// dropping it lets the optimizer allocate instead of guessing on the operator's behalf.\n// True when a cached stage names a destination outside the closing sector's gerência de vendas.\n// Judges the cache by TODAY's rule, reading the (memoised) dominantes base rather than trusting what\n// the session was saved with. Unknown GV on the closing sector answers false: the stage then loads\n// from cache instead of being replayed into the hard throw in computeMovement, which would leave the\n// operator staring at a broken session with no way back.\nasync function savedCrossesGv(saved){\n  if(!saved||!saved.sector||!(saved.dest||[]).length) return false;\n  let gvBy={};\n  try{ await ensureDominantes(); gvBy=DOM.gvBySetor||{}; }catch(e){ return false; }\n  const own=gvBy[String(saved.sector)]||'';\n  if(!own) return false;\n  if(saved.dest.some(s=>(gvBy[String(s)]||'')!==own)) return true;\n  // A stage saved before foreign-GV neighbours stopped being drawn can hold a perfectly clean dest and\n  // STILL carry their shapes in nbrFeat/nbrCurrentSector — it would repaint the old map straight from\n  // cache. An unknown gerência is not evidence of a foreign one, so only a known, differing GV replays.\n  return Object.values(saved.nbrCurrentSector||{}).some(s=>{ const g=gvBy[String(s)]||''; return g&&g!==own; });\n}\nfunction remapAssign(mv,savedAssign){\n  const keys=Object.keys(mv.munis||{}).filter(k=>k!=='__OUTROS__');\n  const out={};\n  Object.entries(savedAssign||{}).forEach(([k,v])=>{\n    if(keys.indexOf(k)>=0){ out[k]=v; return; }\n    const cand=keys.filter(x=>mName(x)===mName(k));\n    if(cand.length===1) out[cand[0]]=v;\n  });\n  return out;\n}\nfunction newSession(){\n  SESS.sessionId=null; SESS.sessionLabel=null; SESS.timeline=[]; SESS.selected=0; SESS.sectorBaseCache={};\n  SESS.settings={...DEFAULT_SETTINGS};\n  SESS.snapshotRaw=null; SESS.snapshotSyncedAt=null; SESS.snapLiteral=null; SESS.eloMaxCycle=null; SESS.stage='loading'; SESS.dirtyFrom=null;\n  (async()=>{\n    loader(true,'iniciando nova sessão…');\n    try{ await Promise.all([ensurePinned(), loadSectors()]); SESS.stage='confirm'; }\n    catch(e){ setError('erro init: '+e.message); }\n    finally{ loader(false); renderAll(); }\n  })();\n}\n\n// ---------- snapshot refresh ----------\n// Deliberately breaks the frozen-snapshot guarantee, on demand: re-resolves DT_HISTORICO and\n// replays the whole timeline against the new one. Skips the replay when the snapshot has not\n// actually moved, so a stray click on a long timeline costs one cheap probe instead of ~8 queries\n// per etapa.\nasync function refreshSnapshot(){\n  if(SESS.stage!=='main' || SESS.snapBusy) return;\n  SESS.snapBusy=true;\n  const prevRaw=SESS.snapshotRaw;\n  const pill=$('#liveState'); if(pill){ pill.classList.add('busy'); pill.textContent='◌ atualizando snapshot…'; }\n  loader(true,'buscando o snapshot mais recente…');\n  let ok=false;\n  try{\n    const p=await resolveSnapshotPin();\n    if(!p.snapshotRaw) throw new Error('não foi possível resolver DT_HISTORICO');\n    const same=String(p.snapshotRaw)===String(prevRaw);\n    SESS.snapshotRaw=p.snapshotRaw; SESS.snapshotSyncedAt=p.syncedAt; SESS.snapLiteral=p.snapLiteral; SESS.eloMaxCycle=p.eloMaxCycle;\n    await loadSectors();   // the sector list follows the live max, so it can have moved too\n    if(same){\n      $('#ds').textContent=`snapshot já era o mais recente · atualizado em ${fmtSyncedAt(p.syncedAt)} — nada recalculado`;\n    } else if(SESS.timeline.length){\n      SESS.dirtyFrom=null;\n      SESS.sectorBaseCache={};   // baselines come from the snapshot, so every one of them changed\n      loader(true,'snapshot novo — recalculando a timeline…');\n      await recomputeFrom(0);\n      $('#ds').textContent=`snapshot atualizado em ${fmtSyncedAt(p.syncedAt)} · ${fmt(SESS.timeline.length)} etapa(s) recalculada(s)`;\n    } else {\n      $('#ds').textContent=`snapshot atualizado em ${fmtSyncedAt(p.syncedAt)}`;\n    }\n    $('#ds').classList.remove('err');\n    ok=true;\n  }catch(e){\n    const at=(SESS.dirtyFrom==null?null:SESS.dirtyFrom+1);\n    $('#ds').textContent='erro ao atualizar o snapshot'+(at?` na etapa ${at} — etapa ${at} em diante ficou desatualizada (⟳)`:'')+': '+e.message;\n    $('#ds').classList.add('err');\n  }finally{\n    SESS.snapBusy=false;\n    if(pill) pill.classList.remove('busy');\n    loader(false); renderAll();\n    if(ok) setTimeout(()=>{ const d=$('#ds'); if(d&&!d.classList.contains('err')) d.textContent=''; },7000);\n  }\n}\n\n// ---------- session options ----------\nfunction previewDest(mv,rule,minCbs){\n  if(!mv||!mv.allowedRaw||!mv.munis) return null;\n  const w={}; let relaxed=false;\n  const cbsOf=m=>(mv.cbsByMuni&&mv.cbsByMuni[m]!=null)?mv.cbsByMuni[m]:((mv.munis[m]&&mv.munis[m].cbs)||0);\n  const add=m=>Object.entries(mv.allowedRaw[m]||{}).forEach(([s,v])=>{ w[s]=(w[s]||0)+v; });\n  // allowedRaw only carries municipalities already inside the sector's dominance base, so there is\n  // nothing left to filter out here beyond the destination rule itself.\n  Object.keys(mv.allowedRaw).forEach(m=>{ const cbs=cbsOf(m);\n    if(rule==='dominant'&&cbs<minCbs) return; add(m); });\n  if(rule==='dominant'&&!Object.keys(w).length){ Object.keys(mv.allowedRaw).forEach(add); relaxed=true; }\n  return {dest:Object.keys(w).sort((a,b)=>w[b]-w[a]).slice(0,6),relaxed};\n}\nfunction readOptForm(){\n  const r=root.querySelector('input[name=\"destRule\"]:checked');\n  let n=Math.round(+$('#optMinCbs').value);\n  if(!(n>0)) n=DEFAULT_SETTINGS.destMinCbs;\n  return {destRule:(r&&r.value)||DEFAULT_SETTINGS.destRule, destMinCbs:n,\n          targetSpan:numOrNull($('#optTargetSpan').value),\n          maxSpan:numOrNull($('#optMaxSpan').value),\n          minSpan:numOrNull($('#optMinSpan').value)};\n}\nfunction spanFormError(f){\n  if(f.minSpan&&f.maxSpan&&f.minSpan>f.maxSpan) return `Span mínimo (${fmt(f.minSpan)}) não pode ser maior que o máximo (${fmt(f.maxSpan)}).`;\n  if(f.targetSpan&&f.maxSpan&&f.targetSpan>f.maxSpan) return `Span alvo (${fmt(f.targetSpan)}) não pode passar do máximo (${fmt(f.maxSpan)}).`;\n  if(f.targetSpan&&f.minSpan&&f.targetSpan<f.minSpan) return `Span alvo (${fmt(f.targetSpan)}) não pode ficar abaixo do mínimo (${fmt(f.minSpan)}).`;\n  return '';\n}\nfunction renderOptPreview(){\n  const el=$('#optPreview'); if(!el) return;\n  const f=readOptForm();\n  root.querySelectorAll('.opt-row').forEach(r=>{\n    if(r.dataset.r!=null) r.classList.toggle('on',r.dataset.r===f.destRule);\n  });\n  const wrap=$('#optMinCbsWrap'); if(wrap) wrap.classList.toggle('off',f.destRule!=='dominant');\n  const err=spanFormError(f), ee=$('#optSpanErr');\n  if(ee){ ee.textContent=err; ee.classList.toggle('hidden',!err); }\n  const ap=$('#optApply'); if(ap) ap.disabled=!!err;\n  const mv=SESS.timeline[SESS.selected];\n  if(!mv||!mv.munis){ el.innerHTML='Nenhuma etapa calculada ainda — as regras passam a valer no próximo fechamento.'; return; }\n  const cbsOf=m=>(mv.cbsByMuni&&mv.cbsByMuni[m]!=null)?mv.cbsByMuni[m]:((mv.munis[m]&&mv.munis[m].cbs)||0);\n  const ms=Object.keys(mv.geoFeat||{});\n  const ig=f.destRule==='dominant'?ms.filter(m=>cbsOf(m)<f.destMinCbs):[];\n  const pv=previewDest(mv,f.destRule,f.destMinCbs);\n  let h=`<b style=\"color:var(--ink)\">Prévia — etapa ${SESS.selected+1}, setor ${mv.sector}</b><br>`;\n  const exN=(mv.excludedMunis||[]).length;\n  if(exN) h+=`Base de dominantes: <b>${fmt(ms.length)}</b> município(s) no mapa · <span style=\"color:var(--closing)\">${fmt(exN)} fora da base — ${fmt(mv.excludedCbs||0)} CBs seguem suas líderes</span><br>`;\n  h+=`${fmt(ms.length-ig.length)} de ${fmt(ms.length)} município(s) valem como ponte`;\n  if(ig.length) h+=` · <span style=\"color:var(--closing)\">ignorados:</span> ${ig.slice(0,8).join(', ')}${ig.length>8?' +'+(ig.length-8):''}`;\n  if(pv){\n    h+=`<br>Setores destino com esta regra: <b style=\"color:var(--ink)\">${pv.dest.join(', ')||'–'}</b>`;\n    const cur=(mv.dest||[]).join(', ');\n    if(pv.dest.join(',')!==(mv.dest||[]).join(',')) h+=` <span style=\"color:var(--closing)\">(hoje: ${cur||'–'})</span>`;\n    if(pv.relaxed) h+=`<br><b style=\"color:var(--closing)\">⚠ nenhum município dominante nesta etapa — a regra relaxa pra \"qualquer município\" pra não deixar a etapa sem destino.</b>`;\n  }\n  if(f.targetSpan||f.maxSpan||f.minSpan){\n    const sp=spanMapFor(mv,mv.assign||{}), v=spanViolationsOf(sp,{target:f.targetSpan,max:f.maxSpan,min:f.minSpan});\n    h+=`<br><br>Span dos destinos como está hoje: ${mv.dest.map(s=>s+' <b>'+fmt(sp[s])+'</b>').join(' · ')||'–'}`;\n    if(v.over.length||v.under.length) h+=`<br><span style=\"color:var(--closing)\">Fora dos limites: ${[...v.over.map(s=>s+' ▲ acima do máx'),...v.under.map(s=>s+' ▼ abaixo do mín')].join(', ')}</span> — o modelo ÓTIMO tenta corrigir no recálculo.`;\n    else h+=` <span style=\"color:var(--ok)\">— já dentro dos limites</span>`;\n  }\n  if(SESS.timeline.length) h+=`<br><span style=\"margin-top:4px;display:inline-block\">Aplicar <b>recalcula as ${fmt(SESS.timeline.length)} etapa(s)</b> contra o snapshot congelado — os destinos ajustados à mão serão refeitos pelo modelo ÓTIMO.</span>`;\n  el.innerHTML=h;\n}\nfunction openOptions(){\n  const st={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  root.querySelectorAll('input[name=\"destRule\"]').forEach(r=>{ r.checked=(r.value===st.destRule); });\n  $('#optMinCbs').value=st.destMinCbs;\n  $('#optTargetSpan').value=st.targetSpan||'';\n  $('#optMaxSpan').value=st.maxSpan||'';\n  $('#optMinSpan').value=st.minSpan||'';\n  $('#optApply').disabled=false;\n  $('#optSessName').textContent=SESS.sessionLabel||'sessão não salva';\n  $('#optStatus').textContent='';\n  $('#optionsBg').classList.remove('hidden');\n  renderOptPreview();\n  const c=$('#optCancel'); if(c) c.focus();\n}\nfunction closeOptions(){ const b=$('#optionsBg'); if(b) b.classList.add('hidden'); }\nasync function applyOptions(){\n  const f=readOptForm();\n  if(spanFormError(f)) return;\n  const prev={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  const changed = prev.destRule!==f.destRule\n    || (f.destRule==='dominant' && prev.destMinCbs!==f.destMinCbs)\n    || numOrNull(prev.targetSpan)!==f.targetSpan\n    || numOrNull(prev.maxSpan)!==f.maxSpan\n    || numOrNull(prev.minSpan)!==f.minSpan;\n  SESS.settings={...prev,...f};\n  closeOptions();\n  if(!changed || !SESS.timeline.length){ renderAll(); return; }\n  SESS.dirtyFrom=null;\n  SESS.sectorBaseCache={};   // the recompute re-derives every baseline; keeping the old ones would\n                             // leave \"Impacto acumulado\" comparing against pre-change numbers\n  loader(true,'aplicando nova regra de destino — recalculando timeline…');\n  try{\n    await recomputeFrom(0);\n    $('#ds').textContent=''; $('#ds').classList.remove('err');\n  }catch(e){\n    const at=(SESS.dirtyFrom==null?0:SESS.dirtyFrom)+1;\n    $('#ds').textContent=`erro ao aplicar a regra na etapa ${at}: ${e.message} — etapa ${at} em diante ficou desatualizada (⟳)`;\n    $('#ds').classList.add('err');\n  }finally{ loader(false); renderAll(); }\n}\n\nconst SHARED_DS='timeline-shared-sessionsDataSet';\nconst AUTH_REF='timelineSimAuthFinal';   // single source of truth for the auth strategy reference\nlet SHARE={busy:false,url:null,last:null};\n// Serialise a PRESENTATION model, not the raw session, so the reader stays purely presentational\n// and never re-derives spans/desloc/orphans — no second copy of this engine to keep in sync.\n// NOTE: METRICS_DEF[2] is the money marker; read it as !!md[2] (a literal dollar sign in this\n// position trips the upsert's parse check).\nfunction buildShareStages(){\n  return SESS.timeline.map((mv,i)=>{\n    const c=computeDerived(mv);\n    const incBy={};\n    mv.dest.forEach(s=>{ incBy[s]=Object.fromEntries(METRICS_DEF.map(md=>[md[0],0])); });\n    Object.entries(mv.munis).forEach(([m,o])=>{\n      if(m==='__OUTROS__') return;\n      const s=mv.assign[m]; if(!s||!incBy[s]) return;\n      METRICS_DEF.forEach(md=>{ incBy[s][md[0]]+=o[md[0]]||0; });\n    });\n    const saiTot={};\n    METRICS_DEF.forEach(md=>{\n      const k=md[0];\n      saiTot[k]=Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,p)=>a+((p[1][k])||0),0);\n    });\n    // orphans follow their leader's home: they enter that sector AND leave the closing one\n    c.leaders.forEach(L=>{\n      const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n      if(!om||!L.home) return;\n      METRICS_DEF.forEach(md=>{ const k=md[0];\n        if(incBy[L.home]) incBy[L.home][k]+=om[k]||0;\n        saiTot[k]+=om[k]||0; });\n    });\n    const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n    const sv=mv.dest.map(s=>c.span[s]);\n    const baseOf=(s,k)=>{ const b=mv.baseMetrics[s]||{}; return +(b[k]||0); };\n    return {\n      i:i, sector:mv.sector, policy:mv.policy, domUF:mv.domUF,\n      optTier:(mv.optTier==null?0:mv.optTier),\n      destRuleFallback:!!mv.destRuleFallback,\n      dest:mv.dest.slice(), color:Object.assign({},mv.color), assign:Object.assign({},mv.assign),\n      kpis:{ cbsRealocadas:closingCbsOf(mv), desloc:c.desloc,\n             spread:(sv.length?Math.max.apply(null,sv)-Math.min.apply(null,sv):0),\n             municipios:ms.length, lideres:mv.leaders.length },\n      destinos:mv.dest.map(s=>({ s:s, base:baseOf(s,'cbs'), inc:c.inc[s]||0, orph:c.orph[s]||0, span:c.span[s]||0, gained:c.gained[s]||0 })),\n      municipios:ms.map(m=>({ nome:mName(m), uf:mUf(m), cbs:mv.munis[m].cbs, destino:mv.assign[m]||null })),\n      excluidos:(mv.excludedMunis||[]).map(x=>({ muni:x.muni, cbs:x.cbs })),\n      orphanTotal:c.orphanTotal||0, orphanStranded:c.orphanStranded||0, noShapeCbs:mv.noShapeCbs||0,\n      lideres:c.leaders.map(L=>({ lider:(L.lider||'').split(' ').slice(0,2).join(' '), grupo:L.grupo,\n        total:L.total, home:L.home, desloc:L.desloc, orphan:L.orphan||0 })),\n      metricas:METRICS_DEF.map(md=>{ const k=md[0];\n        return { key:k, label:md[1], money:!!md[2],\n          rows:mv.dest.map(s=>({ s:s, antes:baseOf(s,k), depois:baseOf(s,k)+((incBy[s]&&incBy[s][k])||0) })),\n          sai:saiTot[k]||0 }; }),\n      geoFeat:mv.geoFeat, nbrFeat:mv.nbrFeat, excludedFeat:[]\n    };\n  });\n}\nfunction buildShareSummary(stages){\n  const world=foldWorldState(SESS.timeline,SESS.timeline.length);\n  const receivers=[];\n  SESS.timeline.forEach((mv,i)=>{\n    const own=mv.pureOwnMetrics||{};\n    receivers.push({ s:mv.sector, situacao:'fechado (etapa '+(i+1)+')', antes:+(own.cbs||0), depois:0 });\n  });\n  const seen={};\n  Object.values(world.muniOverride).forEach(s=>{\n    if(seen[s]) return; seen[s]=1;\n    const base=SESS.sectorBaseCache[s]||{}, infl=world.sectorInflow[s]||{};\n    receivers.push({ s:s, situacao:'recebeu território', antes:+(base.cbs||0), depois:(+(base.cbs||0))+(infl.cbs||0) });\n  });\n  return { stages:stages.length, sectors:SESS.timeline.map(mv=>mv.sector),\n    totalCbs:stages.reduce((a,s)=>a+(s.kpis.cbsRealocadas||0),0),\n    totalDesloc:stages.reduce((a,s)=>a+(s.kpis.desloc||0),0), receivers:receivers };\n}\nfunction renderShareBody(){\n  const el=$('#shareBody'); if(!el) return;\n  const go=$('#shareGo'), cp=$('#shareCopy'), op=$('#shareOpen'), cc=$('#shareCancel');\n  if(SHARE.url){\n    el.innerHTML=`<div class=\"opt-note\" style=\"margin-top:0\"><b style=\"color:var(--ink)\">Sessão compartilhada ✓</b><br>\n      Link de leitura desta simulação congelada. Quem abrir precisa ter login no simulador.\n      <div class=\"shareurl\">${esc(SHARE.url)}</div></div>`;\n    if(go) go.classList.add('hidden');\n    if(cp) cp.classList.remove('hidden');\n    if(op) op.classList.remove('hidden');\n    if(cc) cc.textContent='Fechar';\n    return;\n  }\n  if(go) go.classList.remove('hidden');\n  if(cp) cp.classList.add('hidden');\n  if(op) op.classList.add('hidden');\n  if(cc) cc.textContent='Cancelar';\n  const st={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  const desloc=SESS.timeline.reduce((a,mv)=>a+(mv.munis?computeDerived(mv).desloc:0),0);\n  const destTxt=st.destRule==='dominant'?('dominantes ≥'+fmt(st.destMinCbs)+' CBs'):'qualquer município';\n  el.innerHTML=`<div class=\"opt-lbl\">Confirmar compartilhamento</div>\n    <div class=\"opt-sub\">Publica uma <b>cópia congelada</b> desta sessão, exatamente no estado atual.</div>\n    <div class=\"opt-note\" style=\"margin-top:0\">\n      · <b>${fmt(SESS.timeline.length)}</b> etapa(s) — ${esc(SESS.timeline.map(mv=>mv.sector).join(' → '))}<br>\n      · snapshot atualizado em <b>${esc(fmtSyncedAt(SESS.snapshotSyncedAt))}</b><br>\n      · base: <b>municípios dominantes (CSV)</b> · destino: <b>${esc(destTxt)}</b> · span: <b>${esc(spanShort(st))}</b><br>\n      · <b>${fmt(desloc)}</b> CBs deslocadas somando a cadeia\n    </div>\n    <div class=\"opt-note\">A cópia <b>não</b> acompanha edições posteriores desta sessão — pra atualizar, compartilhe de novo e use o link novo. O link exige login no simulador; não é público.</div>\n    ${SHARE.last?`<div class=\"opt-note\"><b style=\"color:var(--ink)\">Último link gerado nesta sessão</b> — aponta pro estado de <b>antes</b> das edições feitas depois dele.<div class=\"shareurl\">${esc(SHARE.last)}</div></div>`:''}`;\n}\nasync function openShare(){\n  if(!SESS.timeline.length){ $('#ds').textContent='nada pra compartilhar — feche pelo menos um setor primeiro.'; $('#ds').classList.add('err'); return; }\n  await flushDirty();\n  SHARE.url=null;\n  const s=$('#shareStatus'); if(s){ s.textContent=''; s.classList.remove('err'); }\n  renderShareBody();\n  $('#shareBg').classList.remove('hidden');\n  const b=$('#shareCancel'); if(b) b.focus();\n}\nfunction closeShare(){ const b=$('#shareBg'); if(b) b.classList.add('hidden'); }\nasync function doShare(){\n  if(SHARE.busy) return;\n  const go=$('#shareGo'), stx=$('#shareStatus');\n  SHARE.busy=true;\n  if(go){ go.disabled=true; go.textContent='Compartilhando…'; }\n  if(stx){ stx.textContent=''; stx.classList.remove('err'); }\n  try{\n    // Resolved as a GLOBAL PROCESS, not a declared param — which only works because the block\n    // instance on the `main` artboard binds `sharedDataset: @timeline-shared-sessionsDataSet`.\n    // That binding is load-bearing: a Process is only define()d once something on the rendering\n    // tree references it, so without it this lookup returns null and sharing dies on the guard\n    // below. Do NOT remove the instance param because this file never reads d2.params.sharedDataset.\n    const ds=d2.data(SHARED_DS);\n    if(!ds) throw new Error('dataset de sessões compartilhadas indisponível');\n    await flushDirty();\n    const stages=buildShareStages();\n    const summary=buildShareSummary(stages);\n    let who='';\n    try{ const a=d2.auth(AUTH_REF); who=(a&&a.user&&(a.user.email||a.user.nome))||''; }catch(e){}\n    const created=await ds.create({\n      label:SESS.sessionLabel||('Sessão '+new Date().toLocaleString('pt-BR')),\n      snapshotRaw:SESS.snapshotRaw, snapshotSyncedAt:SESS.snapshotSyncedAt,\n      settings:{...DEFAULT_SETTINGS,...(SESS.settings||{})},\n      stages:stages, summary:summary, sharedBy:who, sharedAt:new Date().toISOString()\n    });\n    const id=(created&&created._id)||(created&&created.data&&created.data._id)||(ds.selected&&ds.selected()&&ds.selected()._id)||null;\n    if(!id) throw new Error('registro gravado, mas sem _id na resposta — não foi possível montar o link');\n    SHARE.url=window.location.origin+'/sessions/'+id;\n    SHARE.last=SHARE.url;\n    renderShareBody();\n  }catch(e){\n    if(stx){ stx.textContent='erro ao compartilhar: '+e.message; stx.classList.add('err'); }\n  }finally{\n    SHARE.busy=false;\n    if(go){ go.disabled=false; go.textContent='Compartilhar'; }\n  }\n}\n\n// ---------- rendering ----------\nfunction renderTimeline(){\n  const el=$('#movementChips'); if(!el) return;\n  if(!SESS.timeline.length){ el.innerHTML='<span class=\"empty\">Nenhuma etapa ainda — use \"Fechar setor ▶\" à direita.</span>'; return; }\n  el.innerHTML=SESS.timeline.map((mv,i)=>{\n    const ready=mv.dest!==undefined;\n    const c=ready?computeDerived(mv):null;\n    const stale=SESS.dirtyFrom!=null && i>=SESS.dirtyFrom;\n    return `<div class=\"tchip ${i===SESS.selected?'on':''} ${stale?'stale':''}\" data-i=\"${i}\">\n      <span class=\"tnum\">${i+1}</span><span class=\"tsec\">Setor ${mv.sector}</span>\n      <span class=\"tstat\">${ready?fmt(c.desloc)+' desloc.':'…'}</span>\n      ${stale?'<span class=\"tstale\" title=\"recalcula ao selecionar outra etapa\">⟳</span>':''}\n      <span class=\"trm\" data-rm=\"${i}\" title=\"remover esta etapa e as seguintes\">✕</span>\n    </div>`;\n  }).join('');\n  el.querySelectorAll('.tchip').forEach(ch=>{\n    ch.addEventListener('click',async (e)=>{\n      if(e.target.dataset.rm!=null && e.target.dataset.rm!==''){ removeFrom(+e.target.dataset.rm); return; }\n      const i=+ch.dataset.i;\n      if(i!==SESS.selected) await flushDirty();\n      SESS.selected=i; renderAll();\n    });\n  });\n}\nfunction renderRuleNote(){\n  const el=$('#ruleNote'); if(!el) return;\n  const st={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  const mv=SESS.timeline[SESS.selected];\n  let h='';\n  h+=`<b style=\"color:var(--ink)\">Base de municípios dominantes:</b> só os municípios que a base (CSV) atribui ao setor entram no mapa e na alocação`;\n  if(mv&&mv.domCsvMunis) h+=` · <b>${fmt(mv.domCsvMunis)}</b> na base do setor ${mv.sector}`;\n  if(mv&&mv.excludedCbs) h+=` · <b style=\"color:var(--closing)\">${fmt(mv.excludedMunis.length)} município(s) / ${fmt(mv.excludedCbs)} CBs</b> fora da base nesta etapa — não aparecem no mapa e as CBs seguem suas líderes (quebra de vínculo)`;\n  if(DOM.error) h+=` · <b style=\"color:var(--closing)\">⚠ base indisponível: ${esc(DOM.error)}</b>`;\n  h+=`<br>`;\n  h+=`<b style=\"color:var(--ink)\">Regra de destino:</b> `;\n  h+= st.destRule==='dominant'\n    ? `só municípios com <b>≥${fmt(st.destMinCbs)} CBs</b> trazem setor pra lista de destinos`\n    : `qualquer município do setor fechado vale como ponte <span style=\"opacity:.7\">(padrão)</span>`;\n  if(mv&&mv.munis&&st.destRule==='dominant'){\n    const ig=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__'&&mv.munis[m].cbs<st.destMinCbs);\n    if(ig.length) h+=` · <b style=\"color:var(--closing)\">${fmt(ig.length)}</b> ignorado(s) como ponte (ainda redistribuído(s)): ${ig.slice(0,5).join(', ')}${ig.length>5?' +'+(ig.length-5):''}`;\n    if(mv.destRuleFallback) h+=`<br><b style=\"color:var(--closing)\">⚠ nenhum município dominante nesta etapa — regra relaxada pra \"qualquer município\".</b>`;\n  }\n  el.innerHTML=h;\n}\nfunction renderSpanRule(){\n  const el=$('#spanRule'); if(!el) return;\n  const mv=SESS.timeline[SESS.selected], lim=spanLimits();\n  if(!mv||!mv.munis||mv.view==='asis'||!spanLimitsOn(lim)){ el.innerHTML=''; return; }\n  const sp=spanMapFor(mv,mv.assign||{}), v=spanViolationsOf(sp,lim);\n  const bits=[];\n  if(lim.target) bits.push(`alvo <b>${fmt(lim.target)}</b>`);\n  if(lim.max) bits.push(`máx <b>${fmt(lim.max)}</b>`);\n  if(lim.min) bits.push(`mín <b>${fmt(lim.min)}</b>`);\n  let h=`Pré-requisito de span: ${bits.join(' · ')} CBs — `;\n  if(v.over.length||v.under.length){\n    h+=`<b style=\"color:var(--closing)\">⚠ `;\n    if(v.over.length) h+=`acima do máximo: ${v.over.map(s=>s+' ('+fmt(sp[s])+')').join(', ')}`;\n    if(v.over.length&&v.under.length) h+=' · ';\n    if(v.under.length) h+=`abaixo do mínimo: ${v.under.map(s=>s+' ('+fmt(sp[s])+')').join(', ')}`;\n    h+=`</b>`;\n    if(mv.optTier===1) h+=`<br><span style=\"opacity:.85\">Nenhuma alocação contígua respeita o span mínimo nesta etapa — o modelo relaxou essa exigência.</span>`;\n    else if(mv.optTier===2) h+=`<br><span style=\"opacity:.85\">Nenhuma alocação contígua respeita os limites nesta etapa — o modelo relaxou essa exigência.</span>`;\n    else if(mv.optTier===-1) h+=`<br><span style=\"opacity:.85\">Etapa resolvida por heurística — os limites não são garantidos.</span>`;\n  } else h+=`<b style=\"color:var(--ok)\">✓ todos os setores receptores dentro dos limites</b>`;\n  el.innerHTML=`<div class=\"note\" style=\"margin:-2px 0 6px\">${h}</div>`;\n}\nfunction renderContig(){const mv=SESS.timeline[SESS.selected];const el=$('#contig');if(!el||!mv)return;\n  if(mv.view==='asis'){el.innerHTML='';return;}\n  const bad=contigBadFor(mv,mv.assign);\n  el.innerHTML=bad.length\n    ?`<div class=\"note\" style=\"color:var(--closing);font-weight:600;margin:-2px 0 6px\">⚠ Contiguidade quebrada em ${bad.join(', ')} — município(s) desconectado(s) do território do setor. Use ★ ÓTIMO ou reassocie no mapa.</div>`\n    :`<div class=\"note\" style=\"color:var(--ok);margin:-2px 0 6px\">✓ Contíguo — cada setor forma uma região conectada.</div>`;\n}\nfunction closingCbsOf(mv){ return Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,[,o])=>a+o.cbs,0); }\nfunction sectorTotalCbs(mv){ return Object.values(mv.munis).reduce((a,o)=>a+o.cbs,0); }\nfunction renderKpis(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);\n  if(mv.view==='asis'){const mc=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').length;\n    $('#kpis').innerHTML=[\n      {n:fmt(sectorTotalCbs(mv)),l:`CBs no setor ${mv.sector} (antes desta etapa)`,t:'Total de CBs no setor hoje — já considerando o que etapas anteriores da timeline mandaram pra cá.'},\n      {n:mv.csvMunisTotal?`${fmt(mc)}/${fmt(mv.csvMunisTotal)}`:fmt(mc),l:'Municípios do setor',t:`Municípios desenhados nesta etapa${mv.csvMunisTotal?` de ${fmt(mv.csvMunisTotal)} que a base de dominantes atribui ao setor`:''}.${(mv.missingMunis||[]).length?' Fora do mapa: '+mv.missingMunis.map(x=>x.muni+' ('+x.reason+')').join(' · '):''}${Object.keys(mv.ufGuessed||{}).length?' UF inferida da UF dominante do setor (nome repetido em outra UF carregada): '+Object.entries(mv.ufGuessed).map(([nm,u])=>nm+'→'+u).join(', '):''}`},\n      {n:fmt(mv.leaders.length),l:'Líderes (grupos)',t:'Líderes presentes no setor nesta etapa.'},\n      {n:fmt(mv.dest.length),l:'Setores adjacentes',t:`Setores vizinhos (ainda abertos) da MESMA gerência de vendas (CD_GV ${mv.gv||'?'}) que poderiam receber os municípios.${(mv.gvRejected||[]).length?' Fora do mapa, por serem de outra gerência: '+mv.gvRejected.join(', ')+'.':''}`},\n    ].map(k=>`<div class=\"kpi\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n    return;}\n  const sv=mv.dest.map(s=>c.span[s]);const spread=sv.length?Math.max(...sv)-Math.min(...sv):0;\n  const zero=mv.dest.filter(s=>c.gained[s]===0);const totCbs=closingCbsOf(mv);\n  $('#kpis').innerHTML=[\n    {n:fmt(c.desloc),l:mv.policy==='fora'?'CBs fora do setor':'CBs c/ quebra de relação',cls:'warn',t:'CBs cuja líder acabou noutro setor nesta etapa.'},\n    {n:fmt(spread),l:'Desequilíbrio (máx−mín CBs)',cls:spread>800?'warn':'ok',t:'Diferença de tamanho entre o maior e o menor setor depois desta etapa.'},\n    {n:fmt(totCbs),l:'CBs realocadas',cls:'',t:`Total de CBs do setor ${mv.sector} sendo redistribuídas nesta etapa.`},\n    {n:zero.length?zero.join(', '):'nenhum',l:'Setor sem líder herdada',cls:zero.length?'warn':'ok',t:'Setor que recebe município(s) mas nenhuma líder nesta etapa.'},\n  ].concat(mv.excludedCbs?[{n:fmt(mv.excludedCbs),l:`CBs ignoradas · ${fmt(mv.excludedMunis.length)} município(s) fora da base`,cls:'warn',t:'CBs em municípios que a base de dominantes não atribui a este setor: ficam fora do mapa e da alocação geográfica e seguem a líder do seu grupo.'}]:[])\n   .concat((mv.missingMunis||[]).length?[{n:fmt(mv.missingMunis.length),l:`Município(s) da base fora do mapa · ${fmt(mv.csvMunisTotal)} na base`,cls:'warn',t:`A base de dominantes atribui ${fmt(mv.csvMunisTotal)} município(s) ao setor ${mv.sector} e ${fmt(mv.csvMunisTotal-mv.missingMunis.length)} chegaram ao mapa. Fora: ${mv.missingMunis.map(x=>x.muni+' — '+x.reason+(x.cbs?' ('+fmt(x.cbs)+' CBs)':'')).join(' · ')}`}]:[])\n   .map(k=>`<div class=\"kpi ${k.cls}\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n}\nfunction renderSpans(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);\n  if(mv.view==='asis'){const items=[{s:mv.sector,val:sectorTotalCbs(mv),color:CLOSE_COLOR}].concat(mv.dest.map(s=>({s,val:+(mv.baseMetrics[s]?.cbs||0),color:mv.color[s]})));\n    const max=Math.max(...items.map(x=>x.val),1);\n    $('#spans').innerHTML=items.map(x=>`<div class=\"bar-row\"><div class=\"tag\" style=\"color:${x.color}\">${x.s}</div><div class=\"bar-track\">\n      <div class=\"bar-inc\" style=\"width:${x.val/max*100}%;background:${x.color}\"></div></div>\n      <div class=\"bar-val\">${fmt(x.val)}</div></div>`).join('')\n      +`<div class=\"note\">Estrutura antes desta etapa — o setor <b>${mv.sector}</b> ainda existe. Alterne pra <b>TOBE</b> pra ver a redistribuição.</div>`;\n    return;}\n  const lim=spanLimits(),on=spanLimitsOn(lim);\n  const rawMax=Math.max(...mv.dest.map(s=>c.span[s]),lim.target||0,lim.max||0,1);\n  const max=on?rawMax*1.04:rawMax;   // headroom so a marker sitting at the scale top stays visible\n  const mk=(v,color,label)=>v?`<div class=\"bar-mark\" style=\"left:${Math.min(99.6,v/max*100).toFixed(1)}%;background:${color}\" title=\"${label}\"></div>`:'';\n  const marks=mk(lim.min,'#ff9500','span mínimo '+fmt(lim.min))+mk(lim.target,'#007aff','span alvo '+fmt(lim.target))+mk(lim.max,'#ff3b30','span máximo '+fmt(lim.max));\n  $('#spans').innerHTML=mv.dest.map(s=>{const base=+(mv.baseMetrics[s]?.cbs||0),i=c.inc[s];\n    const over=lim.max&&c.span[s]>lim.max,under=lim.min&&c.span[s]<lim.min;\n    return `<div class=\"bar-row\"><div class=\"tag\" style=\"color:${mv.color[s]}\">${s}</div><div class=\"bar-track\">\n      <div class=\"bar-base\" style=\"width:${base/max*100}%;background:${mv.color[s]}\"></div>\n      <div class=\"bar-inc\" style=\"width:${i/max*100}%;background:${mv.color[s]}\"></div>${marks}</div>\n      <div class=\"bar-val\" style=\"${over?'color:var(--closing);font-weight:700':(under?'color:#ff9500;font-weight:700':'')}\">${fmt(c.span[s])} <span style=\"opacity:.6\">(+${fmt(i)})</span>${over?' ▲':''}${under?' ▼':''}</div></div>`;}).join('')\n    +`<div class=\"note\">Claro = base (já incluindo etapas anteriores) · cheio = CBs entrantes do ${mv.sector} nesta etapa.${c.orphanTotal?` Inclui <b>${fmt(c.orphanTotal-c.orphanStranded)}</b> CB(s) de municípios fora da base de dominantes que seguiram suas líderes.`:''}${on?' Marcas: <b style=\"color:#007aff\">│</b> alvo · <b style=\"color:#ff3b30\">│</b> máx · <b style=\"color:#ff9500\">│</b> mín.':''}</div>`;\n}\n// Two very different reasons land rows in the __OUTROS__ bucket — no IBGE shape, or outside the\n// sector's dominance base. Same bucket computationally, but they must not read as the same thing.\nfunction outsideNote(mv){\n  const bits=[];\n  if(mv.noShapeCbs) bits.push(`<b>${fmt(mv.noShapeCbs)}</b> CBs em ${fmt(mv.noShapeMunis.length)} município(s) sem shape na UF`);\n  if(mv.excludedCbs) bits.push(`<b>${fmt(mv.excludedCbs)}</b> CBs em ${fmt(mv.excludedMunis.length)} município(s) <b>fora da base de dominantes</b> deste setor — <b style=\"color:var(--closing)\">quebra de vínculo</b>`);\n  if(!bits.length) return '';\n  let h=`<div class=\"note\">Fora do mapa e da alocação geográfica: ${bits.join(' · ')}.`;\n  if(mv.excludedCbs){\n    const c=computeDerived(mv);\n    h+=` As <b>${fmt(mv.excludedCbs)}</b> CBs em quebra de vínculo <b>seguem a líder do seu grupo</b>`;\n    const byS=mv.dest.filter(s=>c.orph[s]).map(s=>`<b style=\"color:${mv.color[s]}\">${s}</b> (${fmt(c.orph[s])})`);\n    h+=byS.length?`: ${byS.join(' · ')}.`:'.';\n    if(c.orphanStranded) h+=` <b style=\"color:var(--closing)\">${fmt(c.orphanStranded)} CB(s) sem destino</b> — a líder não ficou com nenhum município realocado, então não há setor pra seguir.`;\n  }\n  if(mv.noShapeCbs) h+=` As CBs sem shape na UF continuam fora de qualquer setor destino.`;\n  if(mv.excludedMunis&&mv.excludedMunis.length) h+=`<br><span style=\"opacity:.85\">${mv.excludedMunis.slice(0,8).map(x=>x.muni+' ('+fmt(x.cbs)+')').join(', ')}${mv.excludedMunis.length>8?' +'+(mv.excludedMunis.length-8):''}</span>`;\n  return h+`</div>`;\n}\nfunction renderMuni(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  if(mv.view==='asis'){$('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(mv.munis[m].cbs)} CBs</div><span class=\"badge\" style=\"background:${CLOSE_COLOR};color:#fff;border-color:${CLOSE_COLOR}\">setor ${mv.sector}</span></div>`).join('')\n      +`<div class=\"note\">Hoje todos pertencem ao setor <b>${mv.sector}</b>. No <b>TOBE</b> cada um vira botões de destino.</div>`+outsideNote(mv);return;}\n  $('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(mv.munis[m].cbs)} CBs</div>\n    <div class=\"seg\" data-m=\"${m}\">${mv.dest.map(s=>`<button data-s=\"${s}\" class=\"${mv.assign[m]===s?'on':''}\" style=\"${mv.assign[m]===s?'background:'+mv.color[s]:''}\">${s}</button>`).join('')}</div></div>`).join('')\n    +outsideNote(mv);\n  $('#muniList').querySelectorAll('.seg button').forEach(b=>b.onclick=()=>{const m=b.parentElement.dataset.m,s=b.dataset.s;editSelected(m2=>{m2.assign[m]=s;});});\n}\nfunction renderLeaders(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);\n  if(mv.view==='asis'){$('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>Grupo</th><th>CBs</th><th>Setor</th></tr></thead><tbody>`\n    +mv.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${L.grupo}</td><td>${fmt(L.total)}</td><td><span class=\"home\" style=\"background:${CLOSE_COLOR}\"></span>${mv.sector}</td></tr>`).join('')\n    +`<tr style=\"font-weight:700\"><td>TOTAL</td><td></td><td>${fmt(mv.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td></tr></tbody>`;return;}\n  const showOrph=c.orphanTotal>0;\n  $('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>CBs</th><th>Home</th>${mv.dest.map(s=>`<th>${s}</th>`).join('')}${showOrph?'<th title=\"CBs em municípios fora da base de dominantes que seguiram esta líder\">Órfãs</th>':''}<th>Desloc.</th></tr></thead><tbody>`\n   +c.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${fmt(L.total)}</td>\n     <td>${L.home?`<span class=\"home\" style=\"background:${mv.color[L.home]}\"></span>${L.home}`:'–'}</td>\n     ${mv.dest.map(s=>`<td class=\"cell\" style=\"${s===L.home?'color:'+mv.color[s]+';font-weight:700':''}\">${L.byS[s]||'·'}</td>`).join('')}\n     ${showOrph?`<td class=\"cell\" style=\"${L.orphan&&!L.orphanHome?'color:var(--closing);font-weight:700':''}\" title=\"${L.orphan?(L.orphanHome?'seguem a líder para o setor '+L.orphanHome:'sem home — CBs sem setor pra seguir'):''}\">${L.orphan?fmt(L.orphan)+(L.orphanHome?'':' ⚠'):'·'}</td>`:''}\n     <td style=\"color:${L.desloc?'var(--closing)':'var(--mut)'};font-weight:${L.desloc?700:400}\">${L.desloc||'0'}</td></tr>`).join('')\n   +`<tr style=\"font-weight:700\"><td>TOTAL</td><td>${fmt(c.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td>${mv.dest.map(s=>`<td>${fmt(c.leaders.reduce((a,L)=>a+L.byS[s],0))}</td>`).join('')}${showOrph?`<td>${fmt(c.orphanTotal)}</td>`:''}<td style=\"color:var(--closing)\">${fmt(c.desloc)}</td></tr></tbody>`;\n}\nfunction renderMatrix(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  // `_out` is a flag stamped on the ROW, and rows are shared between etapas (absorbed + inherited\n  // orphans), so the last etapa to aggregate wins and reading it here can describe the wrong etapa.\n  // Derive the bucket from THIS mv instead. Summing rather than .find() because several out-municípios\n  // of the same group all collapse into __OUTROS__, and only the first was being counted.\n  const outOf=x=>!mv.geoFeat[mkey(x.municipio,x.uf)];\n  const cell=(m,g)=>mv.matrix.reduce((a,x)=>a+((((outOf(x)?'__OUTROS__':mkey(x.municipio,x.uf))===m)&&String(x.grupo)===g)?+x.cbs:0),0);\n  const mx=Math.max(...ms.flatMap(m=>mv.leaders.map(L=>cell(m,L.grupo))),1);\n  // No destination column here. This is the AS-IS matrix: every município in it still belongs to the\n  // closing sector, so showing where it is HEADED mixed the two states of the world in one table and\n  // made the ASIS view read like a plan. The destination lives on the map and in \"Municípios → setor\n  // destino\", which are about the to-be.\n  $('#matrix').innerHTML=`<thead><tr><th>Município</th>${mv.leaders.map(L=>`<th title=\"${L.lider}\">${L.grupo}</th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +ms.map(m=>`<tr><td>${m}</td>`\n     +mv.leaders.map(L=>{const v=cell(m,L.grupo);return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v||'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(mv.munis[m].cbs)}</td></tr>`).join('')+`</tbody>`;\n}\n// TOBE counterpart of the AS-IS matrix: the same município × grupo grid plus the two columns the AS-IS\n// view deliberately refuses to carry — where the município ends up, and under which líder.\n// \"Líder resultante\" is the líder holding the most CBs in that município. A município is served by several\n// grupos at once and the data names no single owner, so the dominant one is the honest answer and its\n// share is printed beside it instead of being hidden behind a single name.\nfunction renderMatrixTobe(){const mv=SESS.timeline[SESS.selected]; const el=$('#matrixTobe'); const nt=$('#tobeNote');\n  if(!el) return;\n  if(!mv||!mv.munis){ el.innerHTML=''; if(nt) nt.innerHTML=''; return; }\n  const c=computeDerived(mv);\n  const homeOf={}; c.leaders.forEach(L=>{ homeOf[L.grupo]=L.home; });\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  // One pass over the matrix instead of a scan per cell: the AS-IS table can afford the naive version\n  // on six municípios, but this one asks for the dominant líder too, which multiplies the lookups.\n  const outOf=x=>!mv.geoFeat[mkey(x.municipio,x.uf)];\n  const cellBy={}; mv.matrix.forEach(x=>{ const k=(outOf(x)?'__OUTROS__':mkey(x.municipio,x.uf))+'|'+String(x.grupo);\n    cellBy[k]=(cellBy[k]||0)+ +x.cbs; });\n  const cell=(m,g)=>cellBy[m+'|'+g]||0;\n  const mx=Math.max(...ms.flatMap(m=>mv.leaders.map(L=>cell(m,L.grupo))),1);\n  const domLeader=m=>{ let best=null,bc=0;\n    mv.leaders.forEach(L=>{ const v=cell(m,L.grupo); if(v>bc){ bc=v; best=L; } });\n    return best?{L:best,cbs:bc}:null; };\n  const short=L=>(L.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+L.grupo);\n  // NOT named `rows` — that is the module-level Databricks response helper, and shadowing it inside a\n  // render function is a trap for whoever adds a query here later.\n  const recs=ms.map(m=>{ const d=mv.assign[m]||null, dl=domLeader(m);\n    const home=dl?(homeOf[dl.L.grupo]||null):null;\n    // The mismatch worth seeing: the territory goes to one setor while the líder serving it ends up in\n    // another. Under \"manter vínculo\" those CBs stay with her, which is exactly what \"CBs fora do setor\"\n    // counts — so this column is where that KPI becomes a list of names.\n    return {m:m,d:d,dl:dl,home:home,off:!!(dl&&home&&d&&home!==d)}; });\n  el.innerHTML=`<thead><tr><th>Município</th><th>→ Setor</th><th>Líder resultante</th>${mv.leaders.map(L=>`<th title=\"${esc(L.lider||'')}\">${L.grupo}</th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +recs.map(r=>`<tr><td>${r.m}</td>`\n     +`<td>${r.d?`<span class=\"home\" style=\"background:${mv.color[r.d]}\"></span>${r.d}`:'–'}</td>`\n     +`<td${r.off?' style=\"color:var(--closing)\"':''} title=\"${r.dl?esc(short(r.dl.L)+' · grupo '+r.dl.L.grupo+' · '+fmt(r.dl.cbs)+' CBs neste município'+(r.home?' · termina no setor '+r.home:' · sem setor de destino')):'nenhuma CB neste município'}\">`\n       +(r.dl?`${esc(short(r.dl.L))} <span class=\"badge\">${r.home||'sem setor'}</span>${r.off?' ⚠':''}`:'–')\n     +`</td>`\n     +mv.leaders.map(L=>{const v=cell(r.m,L.grupo);return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v||'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(mv.munis[r.m].cbs)}</td></tr>`).join('')+`</tbody>`;\n  if(nt){\n    const offN=recs.filter(r=>r.off).length;\n    nt.innerHTML=(mv.policy!=='fora')\n      ? `Política <b>quebrar relação</b>: as CBs deixam a líder atual e passam à estrutura do setor destino — a coluna mostra quem as atende <b>hoje</b>, não quem assume depois.`\n      : `<b>Líder resultante</b> = a líder com mais CBs no município; o chip é o setor onde ela termina. `\n        +(offN?`<span style=\"color:var(--closing)\">${fmt(offN)} município(s) ⚠</span> vão para um setor diferente do da sua líder — as CBs seguem a líder (é disso que o KPI “CBs fora do setor” é feito).`\n          :`Em todos os municípios a líder termina no mesmo setor que recebe o território.`);\n  }\n}\nfunction renderPolicy(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);\n  $('#policyNote').innerHTML=mv.policy==='fora'\n    ?`<b style=\"color:var(--acc)\">Mantendo vínculo:</b> ${fmt(c.desloc)} CBs seguem com a líder mas ficam <b>fisicamente noutro setor</b>. Preserva relação; suja geografia.`\n    :`<b style=\"color:var(--acc)\">Quebrando vínculo:</b> ${fmt(c.desloc)} CBs <b>re-vinculam a uma líder do setor destino</b>. Geografia limpa; rompe relação.`;\n}\nfunction renderPlan(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>a.localeCompare(b));\n  mv._plan=ms.map(m=>({municipio:mName(m),uf:mUf(m),cbs:mv.munis[m].cbs,destino:mv.assign[m]}));\n  $('#plan').innerHTML=`Etapa ${SESS.selected+1} · Setor <b>${mv.sector}</b> → ${ms.map(m=>`${m}→<b style=\"color:${mv.color[mv.assign[m]]}\">${mv.assign[m]||'–'}</b>`).join(' · ')}<br>\n   Política: <b>${mv.policy==='fora'?'manter vínculo (CBs fora do setor)':'quebrar relação'}</b> · ${fmt(c.desloc)} CBs impactadas nesta etapa.`\n   +(c.orphanTotal?`<br>Fora da base de dominantes: ${fmt(c.orphanTotal)} CB(s) seguem suas líderes — ${mv.dest.filter(s=>c.orph[s]).map(s=>`${s} (${fmt(c.orph[s])})`).join(' · ')||'–'}${c.orphanStranded?` · <b style=\"color:var(--closing)\">${fmt(c.orphanStranded)} sem líder com destino</b>`:''}. Não entram no CSV, que é por município.`:'');\n}\nfunction renderReport(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);\n  mv.metric=mv.metric||'cbs';\n  const KEYS=METRICS_DEF.map(m=>m[0]);\n  const incBy={};mv.dest.forEach(s=>incBy[s]=Object.fromEntries(KEYS.map(k=>[k,0])));\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(!s||!incBy[s])return;KEYS.forEach(k=>incBy[s][k]+=o[k]||0);});\n  // orphan CBs land on their leader's home sector, not on a geographic destination\n  c.leaders.forEach(L=>{ const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n    if(om&&L.home&&incBy[L.home]) KEYS.forEach(k=>incBy[L.home][k]+=om[k]||0); });\n  const closeTot={};KEYS.forEach(k=>closeTot[k]=Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,[,o])=>a+(o[k]||0),0));\n  // they leave the closing sector too, so antes → depois still balances\n  c.leaders.forEach(L=>{ const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n    if(om&&L.home) KEYS.forEach(k=>closeTot[k]+=om[k]||0); });\n  const trend=mv.trend.slice(-6);\n  const chartHTML=mk=>{\n    const isM=['receita','rec_nat','rec_avon'].includes(mk); const fN=isM?money:fmt;\n    const data=mv.dest.map(s=>({label:s,color:mv.color[s],antes:+(mv.baseMetrics[s]?.[mk]||0),depois:+(mv.baseMetrics[s]?.[mk]||0)+(incBy[s]?.[mk]||0)}));\n    data.push({label:mv.sector+'✕',color:'#ff3b30',antes:closeTot[mk]||0,depois:0});\n    const max=Math.max(...data.flatMap(d=>[d.antes,d.depois]),1);\n    const W=720,H=290,base=238,top=42,maxH=base-top,n=data.length,gw=(W-16)/n;\n    let g='';\n    data.forEach((d,i)=>{const cx=8+gw*i+gw/2,bw=Math.min(30,gw*0.30);\n      const ah=d.antes/max*maxH,dh=d.depois/max*maxH,ax=cx-bw-2,dx=cx+2;\n      g+=`<rect x=\"${ax.toFixed(1)}\" y=\"${(base-ah).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${ah.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\" opacity=\"0.35\"/>`;\n      g+=`<rect x=\"${dx.toFixed(1)}\" y=\"${(base-dh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${dh.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\"/>`;\n      g+=`<text x=\"${(ax+bw/2).toFixed(1)}\" y=\"${(base-ah-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9\" fill=\"#9a9aa8\">${fN(d.antes)}</text>`;\n      g+=`<text x=\"${(dx+bw/2).toFixed(1)}\" y=\"${(base-dh-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9.5\" font-weight=\"700\" fill=\"${d.color}\">${fN(d.depois)}</text>`;\n      const dp=d.antes?((d.depois-d.antes)/d.antes*100):(d.depois?100:0);\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"16\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"${dp>=0?'#34c759':'#ff3b30'}\">${dp>0?'+':''}${dp.toFixed(0)}%</text>`;\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"${base+16}\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"#1c1c2b\">${d.label}</text>`;});\n    return `<svg viewBox=\"0 0 ${W} ${H}\" style=\"width:100%\"><line x1=\"8\" y1=\"${base}\" x2=\"${W-8}\" y2=\"${base}\" stroke=\"#e7e7ef\"/>${g}</svg>`;\n  };\n  let h=`<div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap\">\n      <h2 style=\"margin:0\">Impacto antes → depois desta etapa</h2>\n      <select id=\"metricSel\">${METRICS_DEF.map(m=>`<option value=\"${m[0]}\" ${m[0]===mv.metric?'selected':''}>${m[1]}</option>`).join('')}</select></div>\n    <div class=\"note\" style=\"margin:2px 0 4px\">Barra clara = <b>antes desta etapa</b> (já incluindo etapas anteriores) · barra cheia = <b>depois</b> · <b style=\"color:var(--acc)\">${mv.sector}✕</b> = setor fechado nesta etapa · % = variação</div>\n    ${chartHTML(mv.metric)}`;\n  h+=`<h2 style=\"margin-top:16px\">Antes → Depois por setor receptor (snapshot congelado)</h2>\n    <div class=\"scroll\"><table><thead><tr><th>Setor</th>${METRICS_DEF.map(m=>`<th>${m[1]}</th>`).join('')}</tr></thead><tbody>`;\n  mv.dest.forEach(s=>{const b=mv.baseMetrics[s]||{};const inc=incBy[s];\n    h+=`<tr><td><span class=\"home\" style=\"background:${mv.color[s]}\"></span>${s}</td>`+\n      METRICS_DEF.map(([k])=>{const bv=+b[k]||0,av=bv+(inc[k]||0);const d=bv?((av-bv)/bv*100):0;const f=METRICS_DEF.find(x=>x[0]===k)[2]==='$'?money:fmt;\n        return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;}).join('')+`</tr>`;});\n  h+=`<tr style=\"opacity:.8\"><td>${mv.sector} (sai)</td>`+METRICS_DEF.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(closeTot[k]||0)}</td>`).join('')+`</tr>`;\n  h+=`</tbody></table></div>`;\n  const incLvl={};mv.dest.forEach(s=>incLvl[s]={});\n  Object.keys(mv.munis).forEach(m=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(!s||!incLvl[s])return;const lv=mv.levelByMuni[m]||{};for(const nv in lv)incLvl[s][nv]=(incLvl[s][nv]||0)+lv[nv];});\n  c.leaders.forEach(L=>{ const ol=mv.orphanLevelByGroup&&mv.orphanLevelByGroup[L.grupo];\n    if(ol&&L.home&&incLvl[L.home]) Object.entries(ol).forEach(([nv,n])=>incLvl[L.home][nv]=(incLvl[L.home][nv]||0)+n); });\n  h+=`<h2 style=\"margin-top:18px\">Consultoras por nível — antes → depois por setor receptor</h2>\n    <div class=\"scroll\"><table><thead><tr><th>Setor</th>${NIVEIS.map(n=>`<th>${n}</th>`).join('')}<th>Total</th></tr></thead><tbody>`;\n  mv.dest.forEach(s=>{const base=mv.baseLevel[s]||{},inc=incLvl[s]||{};let tb=0,ta=0;\n    h+=`<tr><td><span class=\"home\" style=\"background:${mv.color[s]}\"></span>${s}</td>`+\n      NIVEIS.map(nv=>{const b=base[nv]||0,i=inc[nv]||0,a=b+i;tb+=b;ta+=a;\n        return `<td>${a?fmt(a):'·'}${i?`<div class=\"delta up\">+${fmt(i)}</div>`:''}</td>`;}).join('')+\n      `<td style=\"font-weight:700\">${fmt(ta)}${ta-tb?`<div class=\"delta up\">+${fmt(ta-tb)}</div>`:''}</td></tr>`;});\n  h+=`</tbody></table></div><div class=\"note\">Número = CBs por nível <b>depois</b> desta etapa · <span style=\"color:var(--ok)\">+N</span> = entrantes do ${mv.sector}.</div>`;\n  const spk=(key)=>{const vals=trend.map(t=>+t[key]||0);const mx=Math.max(...vals,1);\n    return `<div class=\"spark\">${vals.map(v=>`<div class=\"b\" style=\"height:${(v/mx*100).toFixed(0)}%\" title=\"${fmt(v)}\"></div>`).join('')}</div>\n      <div class=\"note\">${trend.map(t=>String(t.ciclo).slice(-2)).join(' · ')}</div>`;};\n  h+=`<h2 style=\"margin-top:18px\">Setor ${mv.sector} — últimos ${trend.length} ciclos até o snapshot congelado</h2>\n    <div class=\"note\" style=\"margin:-4px 0 8px\">Tendência histórica <b>do próprio setor</b> — não ajustada pela absorção hipotética de etapas anteriores (limitação conhecida: tendência é por setor, não decomponível por município).</div>\n    <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:10px\">\n    ${[['cbs','CBs'],['ativas','Ativas'],['receita','Receita'],['pontos','Pontos']].map(([k,l])=>`<div><div class=\"note\" style=\"margin:0 0 3px;font-weight:600;color:var(--ink)\">${l}</div>${spk(k)}</div>`).join('')}</div>`;\n  $('#tab-report').innerHTML=h;\n  const sel=$('#metricSel'); if(sel) sel.onchange=()=>{mv.metric=sel.value; renderReport();};\n}\nfunction renderCumulative(){\n  const el=$('#tab-cumulative'); if(!el) return;\n  if(!SESS.timeline.length){ el.innerHTML='<div class=\"note\">Nenhuma etapa na timeline ainda.</div>'; return; }\n  const ready=SESS.timeline.every(mv=>mv.munis);\n  if(!ready){ el.innerHTML='<div class=\"note\">Aguardando etapas terminarem de calcular…</div>'; return; }\n  const finalWorld=foldWorldState(SESS.timeline,SESS.timeline.length);\n  const openSectors=[...new Set(Object.values(finalWorld.muniOverride))];\n  let h=`<h2 style=\"margin:0 0 8px\">Linha do tempo — ${SESS.timeline.length} fechamento(s)</h2>\n    <div class=\"note\" style=\"margin:-2px 0 10px\">Setores fechados, em ordem: ${SESS.timeline.map(mv=>mv.sector).join(' → ')}. Cada linha abaixo soma o efeito de <b>toda a cadeia</b>, não apenas da última etapa.</div>`;\n  h+=`<div class=\"scroll\"><table><thead><tr><th>Setor</th><th>Situação</th>${METRICS_DEF.map(([,l])=>`<th>${l}</th>`).join('')}</tr></thead><tbody>`;\n  SESS.timeline.forEach((mv,i)=>{\n    h+=`<tr style=\"opacity:.75\"><td>${mv.sector}</td><td>fechado (etapa ${i+1})</td>`+METRICS_DEF.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(mv.pureOwnMetrics?.[k]||0)}</td>`).join('')+`</tr>`;\n  });\n  openSectors.forEach(s=>{\n    const base=SESS.sectorBaseCache[s]||{};\n    const infl=finalWorld.sectorInflow[s]||{};\n    h+=`<tr><td>${s}</td><td>recebeu território</td>`+METRICS_DEF.map(([k,,t])=>{\n      const bv=+base[k]||0, av=bv+(infl[k]||0); const d=bv?((av-bv)/bv*100):0; const f=(t==='$'?money:fmt);\n      return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;\n    }).join('')+`</tr>`;\n  });\n  h+=`</tbody></table></div><div class=\"note\">\"Antes\" de um setor receptor é o snapshot congelado (sem nenhum fechamento aplicado); \"depois\" soma tudo que a cadeia inteira jogou pra ele.</div>`;\n  el.innerHTML=h;\n}\nfunction project(feats){let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  const each=cb=>feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(p=>p.forEach(r=>r.forEach(cb)));});\n  each(p=>{mnx=Math.min(mnx,p[0]);mxx=Math.max(mxx,p[0]);mny=Math.min(mny,p[1]);mxy=Math.max(mxy,p[1]);});\n  const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=700,H=500,pad=22;\n  const gw=(mxx-mnx)*kx,gh=(mxy-mny),sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n  const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n  return p=>[ox+(p[0]-mnx)*kx*sc, oy+(mxy-p[1])*sc];\n}\nfunction pathFor(f,pr){const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  return ps.map(poly=>poly.map(r=>'M'+r.map(p=>{const q=pr(p);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');}\nfunction cxy(f,pr){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(poly=>poly[0].forEach(p=>{const q=pr(p);x+=q[0];y+=q[1];n++;}));return[x/n,y/n];}\n// ---------- label placement ----------\n// Pole of inaccessibility: the interior point farthest from ANY boundary of the dissolved area. A\n// centroid is only a mean — on a concave sector, one split across a river, or a member whose vertex\n// average falls in a bay, it lands outside the very territory it is supposed to name, which is what\n// pushed the sector numbers off their own shapes. Even-odd across every ring of every member is a\n// correct union test here because municipalities tile rather than overlap.\n// The search box is clipped to the viewBox, so the result is inside the territory AND on screen.\n// Rings are decimated first — this is a label, not a measurement — which keeps a 20-município sector\n// to a few million ops, and the result is cached per stage: geometry never changes between renders,\n// only the fill does.\nfunction labelPointFor(fs,pr){\n  const rings=[];\n  (fs||[]).forEach(f=>{ if(!f||!f.geometry) return;\n    const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n    ps.forEach(poly=>poly.forEach(r=>{\n      if(!r||r.length<4) return;\n      const step=Math.max(1,Math.floor(r.length/120)),pts=[];\n      for(let i=0;i<r.length;i+=step) pts.push(pr(r[i]));\n      if(pts.length>3) rings.push(pts);\n    }));});\n  if(!rings.length) return null;\n  let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  mnx=Math.max(mnx,2); mny=Math.max(mny,2); mxx=Math.min(mxx,698); mxy=Math.min(mxy,498);\n  if(!(mxx>mnx&&mxy>mny)) return null;\n  const inside=(x,y)=>{ let c=false;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      if(((a[1]>y)!==(b[1]>y)) && (x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])) c=!c; }\n    return c; };\n  const edgeDist=(x,y)=>{ let m=Infinity;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      const dx=b[0]-a[0],dy=b[1]-a[1],L=dx*dx+dy*dy;\n      let t=L?(((x-a[0])*dx+(y-a[1])*dy)/L):0; t=t<0?0:(t>1?1:t);\n      const ex=a[0]+t*dx-x,ey=a[1]+t*dy-y,d=ex*ex+ey*ey; if(d<m)m=d; }\n    return Math.sqrt(m); };\n  const W=mxx-mnx,H=mxy-mny,N=26;\n  let best=null,bd=-1;\n  for(let i=0;i<=N;i++) for(let j=0;j<=N;j++){\n    const x=mnx+W*i/N,y=mny+H*j/N;\n    if(!inside(x,y)) continue;\n    const d=edgeDist(x,y); if(d>bd){ bd=d; best=[x,y]; }\n  }\n  if(!best) return null;\n  // Hill-climb off the coarse grid: the grid only has to land somewhere in the right lobe, the\n  // refinement finds the deep point inside it — halving the step whenever no neighbour improves.\n  let step=Math.max(W,H)/N/2;\n  for(let k=0;k<7;k++){\n    let moved=false;\n    for(const o of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]){\n      const x=best[0]+o[0]*step,y=best[1]+o[1]*step;\n      if(x<mnx||x>mxx||y<mny||y>mxy||!inside(x,y)) continue;\n      const d=edgeDist(x,y); if(d>bd){ bd=d; best=[x,y]; moved=true; }\n    }\n    if(!moved) step/=2;\n  }\n  // The clearance travels with the point: it is the radius of the largest circle that fits inside the\n  // territory there, which is exactly the budget a caller needs to decide how much text will fit.\n  return {p:best,r:bd};\n}\nfunction labelPointOf(mv,key,fs,pr){\n  if(!mv._lbl) mv._lbl={};\n  if(!(key in mv._lbl)) mv._lbl[key]=labelPointFor(fs,pr);\n  return mv._lbl[key];\n}\nfunction drawMap(){\n  const mv=SESS.timeline[SESS.selected]; const svg=$('#map'); const ignNote=$('#mapIgnoredNote');\n  if(!mv){ svg.innerHTML=''; if(ignNote) ignNote.innerHTML=''; return; }\n  const c=computeDerived(mv);const NS='http://www.w3.org/2000/svg'; svg.innerHTML='';\n  const feats=[...mv.nbrFeat,...Object.values(mv.geoFeat)]; if(!feats.length) return;\n  const pr=project(feats);\n  const world=foldWorldState(SESS.timeline,SESS.selected);\n  // The context layer is ONE area per SECTOR, not one shape per município. The unit of this decision\n  // is the destination sector; a scatter of individually outlined municipalities carrying their own\n  // names invited exactly the wrong reading of the map. Members of a sector are filled AND stroked in\n  // the same colour, so the shared borders vanish and the group reads as a single dissolved\n  // territory — the IBGE shapes do not share vertices precisely enough to extract a clean union\n  // outline, and carrying a real polygon-clipping union for a context layer is not worth it.\n  // Labels are COLLECTED here and drawn last, after every path. Drawn inline they were buried by the\n  // next sector's fill, and placement cannot avoid a collision it has not seen yet.\n  const labels=[],placed=[];\n  const textW=(s,fs)=>String(s).length*fs*0.56;\n  // WCAG relative luminance of the fill a label sits on, so ink and halo can invert per label. \"Would\n  // white be better?\" has no single answer on this map: white reads on #5856d6 (L≈0.14) and disappears\n  // on #ff9500 and #34c759 (both L≈0.43, where black wins ~9:1 against white's ~2.2:1) and on every\n  // context fill, which is .32 opacity over white. Decided per label, never globally.\n  const lum=col=>{let h=String(col||'').replace('#','');\n    if(h.length===3) h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];\n    if(h.length!==6) return 1;\n    const c=[0,2,4].map(i=>parseInt(h.slice(i,i+2),16)/255)\n      .map(v=>v<=0.03928?v/12.92:Math.pow((v+0.055)/1.055,2.4));\n    return 0.2126*c[0]+0.7152*c[1]+0.0722*c[2];};\n  // Centred on (x,y) in BOTH axes. The old box hung off the first line's baseline, so every two-line\n  // label sat half its own height below the point it was supposed to mark.\n  const rectOf=(x,y,lines)=>{const w=Math.max.apply(null,lines.map(l=>textW(l.t,l.size)));\n    const h=lines.reduce((a,l)=>a+l.size*1.2,0);\n    return {x1:x-w/2-2.5,x2:x+w/2+2.5,y1:y-h/2-1.5,y2:y+h/2+1.5};};\n  const nbrBySec={};\n  mv.nbrFeat.forEach(f=>{\n    const nm=mkey(f.properties.name||f.properties.NOME||f.properties.nome||'',f.__uf);\n    const s=String((mv.nbrCurrentSector&&mv.nbrCurrentSector[nm])||'');\n    (nbrBySec[s]=nbrBySec[s]||[]).push(f);\n  });\n  Object.keys(nbrBySec).forEach(s=>{\n    const fs=nbrBySec[s];\n    const isDest=!!s&&mv.dest.includes(s);\n    const closedCtx=!!s&&world.closedSectors.has(s);\n    const col=isDest?(mv.color[s]||null):null;\n    fs.forEach(f=>{\n      const p=document.createElementNS(NS,'path');\n      p.setAttribute('d',pathFor(f,pr));\n      p.setAttribute('class','sect'+(isDest?'':(closedCtx?' closed-ctx':' plain')));\n      if(col){ p.setAttribute('fill',col); p.setAttribute('stroke',col); }\n      svg.appendChild(p);\n    });\n    if(!s) return;\n    // One number per sector, at the pole of inaccessibility of its DISSOLVED area — the whole group\n    // of members at once, not a representative member, so the number sits in the visual middle of the\n    // territory the operator actually sees.\n    const lp=labelPointOf(mv,'s:'+s,fs,pr);\n    // Context areas always read LIGHT — .sect paints dest colours at fill-opacity .32 over white, and\n    // plain/closed-ctx are near-white greys — so a sector number never wants white ink, whatever colour\n    // the sector itself was assigned.\n    if(lp&&lp.p) labels.push({anchor:lp.p,big:true,dark:false,lines:[{t:s,size:11.5,weight:700}]});\n  });\n  const asis=mv.view==='asis';\n  // The UF prefix is information only when the stage actually straddles a border. On a single-UF setor\n  // it is the same three characters repeated on every label — a large share of the clutter and none of\n  // the meaning. The hover tooltip carries the UF either way.\n  const showUf=new Set(Object.keys(mv.geoFeat).map(k=>mUf(k)).filter(Boolean)).size>1;\n  Object.entries(mv.geoFeat).forEach(([m,f])=>{const p=document.createElementNS(NS,'path');p.setAttribute('d',pathFor(f,pr));\n    const fillCol=asis?CLOSE_COLOR:(mv.color[mv.assign[m]]||'#444');\n    p.setAttribute('class','muni');p.setAttribute('fill',fillCol);\n    if(!asis) p.onclick=()=>{editSelected(m2=>{const i=m2.dest.indexOf(m2.assign[m]);if(m2.dest.length)m2.assign[m]=m2.dest[(i+1)%m2.dest.length];m2.selMuni=m;});};\n    p.style.cursor=asis?'default':'pointer';\n    p.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';\n      t.innerHTML=asis?`<b>${mDisp(m)}</b><br>${fmt(mv.munis[m]?.cbs||0)} CBs · setor ${mv.sector}`:`<b>${mDisp(m)}</b><br>${fmt(mv.munis[m]?.cbs||0)} CBs → ${mv.assign[m]||'–'}<br><span style=\"color:var(--mut)\">clique p/ trocar</span>`;};\n    p.onmouseleave=()=>$('#tip').style.opacity=0;svg.appendChild(p);\n    // The name is never cut and never dropped. The pole of inaccessibility is still where the label\n    // WANTS to sit — the visual middle of the territory rather than a centroid that can fall outside a\n    // concave shape — but its clearance no longer gates anything: cutting BA-SAO SEBASTIAO DO PASSE\n    // down to BA-SAO SEBAST saved space by destroying the one thing the label existed to convey, and\n    // measuring the name against the inscribed circle rejected the centre for nearly every município.\n    const lp=labelPointOf(mv,'m:'+m,[f],pr);\n    const at=(lp&&lp.p)||cxy(f,pr);\n    const nmFull=(showUf&&mUf(m)?mUf(m)+'-':'')+mName(m);\n    const num=fmt(mv.munis[m]?.cbs||0)+(asis?'':'·'+(mv.assign[m]||'–'));\n    labels.push({anchor:at,cbs:+(mv.munis[m]?.cbs||0),dark:lum(fillCol)<0.18,\n      lines:[{t:nmFull,size:8.2,weight:700},{t:num,size:7,weight:500,mut:true}]});\n  });\n  // Placement, not de-cluttering. Every label IS drawn: it starts at its anchor and, if the text does\n  // not fit the territory or the slot is already taken, walks outward ring by ring until it finds\n  // clear canvas, trailing a hairline leader back to the shape it names. The previous pass dropped the\n  // loser of every collision outright, which is why municípios silently had no name at all.\n  // Order still matters — sector numbers first, then municípios by CB weight — because the earlier a\n  // label is placed the closer to home it lands.\n  const CW=700,CH=500,CPAD=2;\n  const inBounds=r=>r.x1>=CPAD&&r.x2<=CW-CPAD&&r.y1>=CPAD&&r.y2<=CH-CPAD;\n  const hits=r=>placed.reduce((a,q)=>a+((r.x2<q.x1||r.x1>q.x2||r.y2<q.y1||r.y1>q.y2)?0:1),0);\n  // Ring 0 is the anchor itself. Rings step 9u out over 16 directions, stretched horizontally because\n  // a label is wider than it is tall and sideways room is the room it actually needs.\n  const CAND=[[0,0]];\n  for(let ring=1;ring<=7;ring++){ const d=ring*9;\n    for(let a=0;a<16;a++){ const th=a*Math.PI/8; CAND.push([Math.cos(th)*d*1.35,Math.sin(th)*d]); } }\n  labels.sort((a,b)=>(b.big?1:0)-(a.big?1:0)||((b.cbs||0)-(a.cbs||0)));\n  labels.forEach(L=>{\n    let chosen=null,best=null,bestN=Infinity;\n    // Centred on the anchor is ALWAYS the first choice. An earlier cut here refused the anchor whenever\n    // the text was wider than the município's inscribed circle — true of almost every real name at this\n    // scale (BA-SAO SEBASTIAO DO PASSE is ~140u against ~80u of clearance), so every single label got\n    // nudged outside and nothing ended up centred on anything. Spilling past a border is normal on a\n    // choropleth and the halo keeps it readable; only a genuine COLLISION earns a move.\n    for(const off of CAND){\n      const x=L.anchor[0]+off[0], y=L.anchor[1]+off[1];\n      const r=rectOf(x,y,L.lines);\n      if(!inBounds(r)) continue;\n      const n=hits(r);\n      if(!n){ chosen={x,y,r,off}; break; }\n      // No clear slot yet — remember the LEAST crowded one. Falling back to the first in-bounds\n      // candidate meant falling back to the anchor every time, so a crowded map piled every loser onto\n      // the same point. CAND is ordered nearest-first and the compare is strict, so ties keep the\n      // closest slot.\n      if(n<bestN){ bestN=n; best={x,y,r,off}; }\n    }\n    // Canvas genuinely full: take the nearest in-bounds slot and accept the overlap. A label sitting\n    // on top of another is recoverable by eye; a name that was never drawn is not.\n    if(!chosen) chosen=best||{x:L.anchor[0],y:L.anchor[1],r:rectOf(L.anchor[0],L.anchor[1],L.lines),off:[0,0]};\n    placed.push(chosen.r);\n    const g=document.createElementNS(NS,'g'); g.setAttribute('class','lbl'+(L.big?' big':''));\n    if(Math.abs(chosen.off[0])>0.5||Math.abs(chosen.off[1])>0.5){\n      // The leader stops at the label's edge rather than its centre, so it never runs under the glyphs.\n      const cx=(chosen.r.x1+chosen.r.x2)/2, cy=(chosen.r.y1+chosen.r.y2)/2;\n      const hw=(chosen.r.x2-chosen.r.x1)/2, hh=(chosen.r.y2-chosen.r.y1)/2;\n      const dx=L.anchor[0]-cx, dy=L.anchor[1]-cy;\n      const tt=Math.min(dx?hw/Math.abs(dx):Infinity, dy?hh/Math.abs(dy):Infinity);\n      const ln=document.createElementNS(NS,'line'); ln.setAttribute('class','lead');\n      ln.style.stroke=L.dark?'#fff':'#1c1c1e';\n      ln.setAttribute('x1',L.anchor[0].toFixed(1)); ln.setAttribute('y1',L.anchor[1].toFixed(1));\n      ln.setAttribute('x2',(cx+dx*tt).toFixed(1)); ln.setAttribute('y2',(cy+dy*tt).toFixed(1));\n      g.appendChild(ln);\n    }\n    const hTot=L.lines.reduce((a,l)=>a+l.size*1.2,0);\n    let ty=chosen.y-hTot/2+L.lines[0].size*0.86;\n    // Inline style rather than a class: a stylesheet rule would outrank a presentation attribute and\n    // undo the per-label contrast decision made above.\n    const ink=L.dark?'#fff':'#1c1c1e', inkMut=L.dark?'rgba(255,255,255,.85)':'#48484d';\n    const halo=L.dark?'rgba(0,0,0,.6)':'#fff';\n    L.lines.forEach(l=>{ const t=document.createElementNS(NS,'text');\n      t.setAttribute('x',chosen.x.toFixed(1)); t.setAttribute('y',ty.toFixed(1));\n      t.setAttribute('font-size',l.size); t.setAttribute('font-weight',l.weight||600);\n      t.style.fill=l.mut?inkMut:ink; t.style.stroke=halo;\n      t.textContent=l.t; g.appendChild(t); ty+=l.size*1.2; });\n    svg.appendChild(g);\n  });\n  $('#legend').innerHTML=asis\n    ?`<span><b style=\"background:${CLOSE_COLOR}\"></b>Setor ${mv.sector} (estrutura antes desta etapa — ${Object.keys(mv.geoFeat).length} municípios)</span>`\n    // No \"vizinhos (contexto)\" or \"setor já fechado antes\" entry any more: every feature still drawn\n    // belongs to a destination sector, so .sect.plain and .sect.closed-ctx can no longer be produced\n    // and a legend key for them would point at nothing on the map.\n    :mv.dest.map(s=>`<span><b style=\"background:${mv.color[s]}\"></b>Setor ${s} — ${c.gained[s]} líder(es)</span>`).join('');\n  // Municipalities outside the dominance base are deliberately NOT drawn — no shape, no hover, no\n  // legend entry. They are reported in the panel instead, so the map only ever shows territory that\n  // is actually being reallocated.\n  if(ignNote) ignNote.innerHTML=(mv.excludedMunis&&mv.excludedMunis.length)\n    ? `<b style=\"color:var(--closing)\">${fmt(mv.excludedMunis.length)} município(s) / ${fmt(mv.excludedCbs||0)} CBs</b> fora da base de dominantes deste setor não são desenhados no mapa — as CBs seguem a líder do seu grupo (detalhe em “Municípios → setor destino”).`\n    : '';\n}\nasync function runSql(){const sql=$('#sqlIn').value.trim();$('#sqlStatus').textContent='rodando…';$('#sqlStatus').classList.remove('err');\n  try{ await ensurePinned(); const resp=await q(sql);const rs=rows(resp);const cols=(resp.manifest?.schema?.columns||[]).map(c=>c.name);\n    $('#sqlOut').innerHTML=`<thead><tr>${cols.map(c=>`<th>${c}</th>`).join('')}</tr></thead><tbody>`+rs.slice(0,200).map(r=>`<tr>${cols.map(c=>`<td>${r[c]??'·'}</td>`).join('')}</tr>`).join('')+`</tbody>`;\n    $('#sqlStatus').textContent=rs.length+' linha(s)';}\n  catch(e){$('#sqlStatus').textContent='bloqueado/erro: '+e.message;$('#sqlStatus').classList.add('err');}\n}\nfunction renderAll(){\n  renderStage();\n  renderTimeline(); renderSectorPicker();\n  const shb=$('#openShare'); if(shb) shb.disabled=!SESS.timeline.length;\n  const dpb=$('#dupSession'); if(dpb) dpb.disabled=!SESS.timeline.length;\n  const mv=SESS.timeline[SESS.selected];\n  // resolveSnapshotPin falls back to a live sub-select when it cannot pin a literal; saying\n  // \"congelado\" in that case would be a lie, so the pill reports what is actually in the SQL.\n  const pinned=SESS.snapLiteral && !/^\\(SELECT/i.test(SESS.snapLiteral);\n  $('#liveState').textContent = SESS.snapshotRaw\n    ? (pinned?'● snapshot congelado · ':'◍ snapshot ao vivo (não fixado) · ')+'atualizado em '+fmtSyncedAt(SESS.snapshotSyncedAt)+' · ↻'\n    : '● iniciando';\n  const mp=$('#basePill');\n  if(mp){\n    const warn=[];\n    if(DOM.bySetor&&!DOM.hasUf) warn.push('⚠ sem coluna UF');\n    if(DOM.badUf) warn.push('⚠ '+fmt(DOM.badUf)+' UF não reconhecida(s)');\n    if(DOM.clashes) warn.push('⚠ '+fmt(DOM.clashes)+' homônimo(s) no mesmo setor');\n    mp.textContent=DOM.bySetor?('◇ base dominantes: '+fmt(DOM.setores)+' setores · '+fmt(DOM.rows)+' municípios'+(warn.length?' · '+warn.join(' · '):'')):(DOM.error?'◇ base dominantes: indisponível':'◇ base dominantes: carregando…');\n    mp.title=DOM.error||(DOM.bySetor?((DOM.hasUf\n        ?('Coluna de UF usada: \"'+DOM.ufCol+'\".')\n        :('SEM coluna de UF. Colunas lidas: '+((DOM.headers||[]).join(' | ')||'?')+'. A UF de cada município vem desta base — sem ela, homônimos voltam a ser casados só pelo nome.'))\n      +(DOM.badUf?('\\n'+DOM.badUf+' linha(s) com UF irreconhecível — tratadas como desconhecida.'):'')\n      +(DOM.clashes?('\\n'+DOM.clashes+' município(s) aparecem no mesmo setor com UFs diferentes. O SQL agrupa só por DS_MUNICIPIO, então esses chegam somados numa linha única e não há como separá-los.'):'')\n      ):'')||DOM.url||''; }\n  const rp=$('#rulePill'); if(rp) rp.textContent='◈ destino: '+ruleShort(SESS.settings);\n  const sp=$('#spanPill'); if(sp) sp.textContent='◎ span: '+spanShort(SESS.settings);\n  renderRuleNote(); renderSpanRule();\n  if(!mv){ ['#map','#matrix','#matrixTobe','#tobeNote','#leaders'].forEach(s=>{const el=$(s);if(el)el.innerHTML='';}); $('#kpis').innerHTML=''; $('#spans').innerHTML=''; $('#muniList').innerHTML=''; $('#plan').innerHTML=''; return; }\n  $('#mapTitle').textContent=`Etapa ${SESS.selected+1}/${SESS.timeline.length} — fechando setor ${mv.sector}`;\n  $('#viewToggle').querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.v===mv.view));\n  $('#policy').querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.p===mv.policy));\n  $('#optBadge').textContent=mv.optBadge||'';\n  drawMap(); renderContig(); renderSpanRule(); renderKpis(); renderSpans(); renderMuni(); renderLeaders(); renderMatrix(); renderMatrixTobe(); renderPolicy(); renderPlan(); renderReport(); renderCumulative();\n}\n\n// ---------- event wiring ----------\n$('#go').onclick=()=>addMovement($('#sectorPick').value);\n$('#confirmSnap').onclick=()=>{ SESS.stage='main'; renderAll(); };\n$('#rand').onclick=()=>{\n  const closed=new Set(SESS.timeline.map(mv=>mv.sector));\n  const pool=SESS.sectorsList.filter(s=>!closed.has(s.setor));\n  const cand=pool.filter(s=>s.munis>=3&&s.munis<=10&&s.cbs>300);\n  const list=cand.length?cand:pool; if(!list.length) return;\n  const pick=list[Math.floor(Math.random()*list.length)];\n  $('#sectorPick').value=pick.setor;\n  addMovement(pick.setor);\n};\n$('#sectorPick').addEventListener('keydown',e=>{ if(e.key==='Enter') addMovement($('#sectorPick').value); });\n$('#confirmSessionPick').onchange=()=>loadSession($('#confirmSessionPick').value);\n$('#optim').onclick=()=>editSelected(mv=>{ mv.assign=optimizeFor(mv); });\n$('#autoTobe').onclick=()=>editSelected(mv=>{ mv.assign=autoAssignFor(mv); mv.optBadge='TOBE por adjacência'; });\n$('#viewToggle').querySelectorAll('button').forEach(b=>b.onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;mv.view=b.dataset.v;renderAll();});\n$('#policy').querySelectorAll('button').forEach(b=>b.onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;mv.policy=b.dataset.p;renderKpis();renderPolicy();renderPlan();});\nroot.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('on',x===b));\n  ['matrix','tobe','report','cumulative','sql'].forEach(t=>$('#tab-'+t).classList.toggle('hidden',t!==b.dataset.tab));});\n$('#runSql').onclick=runSql;\n$('#copyPlan').onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;navigator.clipboard.writeText(JSON.stringify({setor:mv.sector,politica:mv.policy,plano:mv._plan},null,2));$('#copyPlan').textContent='copiado ✓';setTimeout(()=>$('#copyPlan').textContent='Copiar',1500);};\n$('#csvPlan').onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;const csv='municipio,uf,cbs,setor_destino\\n'+mv._plan.map(p=>`${p.municipio},${p.uf||''},${p.cbs},${p.destino}`).join('\\n');\n  const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));a.download=`plano_migracao_setor_${mv.sector}.csv`;a.click();};\n$('#saveSession').onclick=saveSession;\n$('#dupSession').onclick=duplicateSession;\n$('#sessionPick').onchange=()=>loadSession($('#sessionPick').value);\n$('#liveState').onclick=refreshSnapshot;\n$('#liveState').addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); refreshSnapshot(); } });\n$('#openOptions').onclick=openOptions;\n$('#optClose').onclick=closeOptions;\n$('#optCancel').onclick=closeOptions;\n$('#optApply').onclick=applyOptions;\n$('#optMinCbs').addEventListener('input',renderOptPreview);\n['#optTargetSpan','#optMaxSpan','#optMinSpan'].forEach(id=>$(id).addEventListener('input',renderOptPreview));\nroot.querySelectorAll('input[name=\"destRule\"]').forEach(r=>r.addEventListener('change',renderOptPreview));\n$('#optionsBg').addEventListener('click',e=>{ if(e.target.id==='optionsBg') closeOptions(); });\nroot.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeOptions(); closeShare(); } });\n\n$('#openShare').onclick=openShare;\n$('#shareX').onclick=closeShare;\n$('#shareCancel').onclick=closeShare;\n$('#shareGo').onclick=doShare;\n$('#shareCopy').onclick=()=>{ if(!SHARE.url) return; navigator.clipboard.writeText(SHARE.url);\n  const b=$('#shareCopy'); if(b){ b.textContent='copiado ✓'; setTimeout(()=>{ const x=$('#shareCopy'); if(x) x.textContent='Copiar link'; },1500); } };\n$('#shareOpen').onclick=()=>{ if(SHARE.url) window.open(SHARE.url,'_blank'); };\n$('#shareBg').addEventListener('click',e=>{ if(e.target.id==='shareBg') closeShare(); });\n\n// ---------- init ----------\n(async()=>{\n  loader(true,'congelando snapshot da sessão…');\n  try{\n    await Promise.all([ensurePinned(), loadSectors(), refreshSessionPicker()]);\n    SESS.stage='confirm';\n  }catch(e){ setError('erro init: '+e.message); }\n  finally{ loader(false); renderAll(); const rd=$('#ready'); if(rd) rd.setAttribute('data-loaded','1'); }\n})();\n\n  }\n}\n"}},"sessionsDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"sessionsDataset"}}}},"dominantesCsvUrl":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"dominantesCsvUrl"}}}}}}}}}},{"_id":"6a7ca2f7572546f02874bc0e","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"RhYKiJbwSgOb93uEW9R5f","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const T='product.perfil_cb.crm_perfil_cb_diaria_historico';\nconst ELO='product.elo_modelo_combinado.commercial_consultant_index';\nconst PALETTE=['#5856d6','#ff9500','#34c759','#af52de','#ff2d55','#a2845e'];\nconst CLOSE_COLOR='#8e8e93';\nconst UF_CODE={RO:'11',AC:'12',AM:'13',RR:'14',PA:'15',AP:'16',TO:'17',MA:'21',PI:'22',CE:'23',RN:'24',PB:'25',PE:'26',AL:'27',SE:'28',BA:'29',MG:'31',ES:'32',RJ:'33',SP:'35',PR:'41',SC:'42',RS:'43',MS:'50',MT:'51',GO:'52',DF:'53'};\nconst UF_BORDERS={AC:['AM','RO'],AL:['BA','PE','SE'],AM:['AC','RO','RR','PA','MT'],AP:['PA'],\n BA:['AL','SE','PE','PI','TO','GO','MG','ES'],CE:['PI','PB','PE','RN'],DF:['GO','MG'],\n ES:['BA','MG','RJ'],GO:['DF','MT','MS','MG','BA','TO'],MA:['PA','TO','PI'],\n MG:['BA','GO','DF','MS','SP','RJ','ES'],MS:['MT','GO','MG','SP','PR'],MT:['RO','AM','PA','TO','GO','MS'],\n PA:['AP','AM','MT','TO','MA','RR'],PB:['RN','CE','PE'],PE:['PB','CE','PI','BA','AL'],\n PI:['MA','TO','BA','PE','CE'],PR:['SP','MS','SC'],RJ:['ES','MG','SP'],RN:['CE','PB'],\n RO:['AC','AM','MT'],RR:['AM','PA'],RS:['SC'],SC:['PR','RS'],SE:['BA','AL'],\n SP:['MG','RJ','PR','MS'],TO:['PA','MA','PI','BA','GO','MT']};\nconst NIVEIS=['Bronze','Cristal','Prata','Ouro','Diamante','Diamante+'];\nconst METRICS_DEF=[['cbs','CBs'],['ativas','Ativas'],['pedidos','Pedidos'],['pontos','Pontos'],['receita','Receita','$'],['rec_nat','Rec. Natura','$'],['rec_avon','Rec. Avon','$']];\nconst OPT_K=1.5;\nconst SESSIONS_DS='timeline-shared-sessionsDataSet';   // same table as SHARED_DS — see notes at listSessions()\n// Span defaults are the real operating band for a setor receptor, not placeholders: 2200 ≤ span ≤ 3500\n// with 2700 as the target. null meant \"sem limite\", so every new session started with the optimiser\n// balancing against nothing. Existing sessions are NOT touched — their persisted settings carry explicit\n// values that spread OVER these in openOptions/applyOptions, which is deliberate: their etapas were\n// computed under the old band and changing it silently would leave the stored result and the rule that\n// produced it out of sync.\nconst DEFAULT_SETTINGS={destRule:'any',destMinCbs:9,targetSpan:2700,maxSpan:3500,minSpan:2200};\nconst DOM_MEDIA_BASE='https://natura-geomarketing.data2.media/file/';\nconst DOM_CSV_FALLBACK=DOM_MEDIA_BASE+'yw_TQdp0QXEk6JsrliAqP'; \nconst numOrNull=v=>{const n=Math.round(+v);return (isFinite(n)&&n>0)?n:null;};\nconst spanShort=st=>{const p=[];if(st&&st.targetSpan)p.push('alvo '+st.targetSpan);if(st&&st.maxSpan)p.push('máx '+st.maxSpan);if(st&&st.minSpan)p.push('mín '+st.minSpan);return p.length?p.join(' · '):'livre';};\nconst ruleShort=st=>((st&&st.destRule)==='dominant')?('dominantes ≥'+((st&&st.destMinCbs)||DEFAULT_SETTINGS.destMinCbs)+' CBs'):'qualquer municipio';\n\nexport default {\n  css: `\n  :host{display:block;height:100%;--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--acc2:#0060df;--closing:#ff3b30;--ok:#34c759}\n  *{box-sizing:border-box}\n  .simroot{position:relative;height:100%;overflow:auto;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}\n  header{padding:10px 16px;background:rgba(246,246,248,.85);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-wrap:wrap}\n  header h1{font-size:14.5px;margin:0;font-weight:600;letter-spacing:-.1px;color:var(--ink)}\n  .mac-spacer{flex:1}\n  .howto{padding:8px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--mut);align-items:center}\n  .howto b{color:var(--ink)} .howto span{max-width:340px}\n  .pill{font-size:10.5px;padding:2.5px 9px;border-radius:99px;border:1px solid var(--line);color:var(--mut);background:var(--panel2)}\n  .pill.live{color:var(--ink);cursor:pointer;user-select:none;transition:border-color .1s,color .1s,background .1s}\n  .pill.live:hover{border-color:var(--acc);color:var(--acc);background:#eaf3ff}\n  .pill.live:focus-visible{outline:2px solid rgba(0,122,255,.35);outline-offset:1px}\n  .pill.live.busy{border-color:var(--acc);color:var(--acc);cursor:progress}\n  .status{font-size:11px;color:var(--mut)}.status.err{color:var(--closing)}\n  select,input,textarea,button{font:inherit}\n  select,input{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:7px;padding:5.5px 9px}\n  select:focus,input:focus{outline:2px solid rgba(0,122,255,.35);outline-offset:0;border-color:var(--acc)}\n  button.act{background:var(--acc);color:#fff;border:1px solid transparent;border-radius:7px;padding:6px 13px;font-weight:600;cursor:pointer;box-shadow:0 1px 1px rgba(0,0,0,.08)}\n  button.act:hover{background:var(--acc2)}\n  button.ghost{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5.5px 12px;cursor:pointer}\n  button.ghost:hover{background:var(--panel2)}\n  button.ghost.on{border-color:var(--acc);color:var(--acc);background:#eaf3ff}\n  button.ghost:disabled,button.act:disabled{opacity:.4;cursor:not-allowed}\n  .sessbar{padding:9px 20px;background:var(--panel2);border-bottom:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap;align-items:center}\n  .rail{padding:9px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:8px;align-items:center;overflow-x:auto}\n  #movementChips{display:flex;gap:8px;flex:0 0 auto}\n  #addMovementGroup{display:flex;gap:8px;align-items:center;flex:0 0 auto;margin-left:auto;padding-left:12px;border-left:1px solid var(--line)}\n  #addMovementGroup input{min-width:150px}\n  .tchip{display:flex;align-items:center;gap:7px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;cursor:pointer;font-size:12px;flex:0 0 auto}\n  .tchip.on{border-color:var(--acc);background:#eaf3ff}\n  .tchip.stale{border-style:dashed;opacity:.85}\n  .tchip .tstale{color:#ff9500;font-weight:700}\n  .tchip .tnum{background:var(--ink);color:#fff;border-radius:99px;width:16px;height:16px;font-size:9.5px;display:flex;align-items:center;justify-content:center;font-weight:700}\n  .tchip .tsec{font-weight:600}\n  .tchip .tstat{color:var(--mut)}\n  .tchip .trm{color:var(--mut);font-weight:700;padding:0 3px}\n  .tchip .trm:hover{color:var(--closing)}\n  .rail .empty{color:var(--mut);font-size:12px}\n  .wrap{display:grid;grid-template-columns:minmax(360px,1fr) 430px;gap:14px;padding:14px 16px;align-items:start}\n  @media(max-width:1150px){.wrap{grid-template-columns:1fr}}\n  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;margin-bottom:14px;box-shadow:0 1px 2px rgba(0,0,0,.04)}\n  .card h2{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);margin:0 0 9px;font-weight:600}\n  /* user-select, not pointer-events. .lbl already sets pointer-events:none, which stops the text from\n     RECEIVING clicks but does not exclude it from the browser's selection range — so cycling a\n     município's destination with repeated clicks kept dragging a double-click selection across the\n     labels. Nothing drawn in an svg here is text anyone needs to copy. */\n  svg{width:100%;height:auto;display:block;user-select:none;-webkit-user-select:none}\n  /* White hairlines inside the closing sector: the municipios read as one territory cut into parts\n     rather than as separate outlined units, and the saturated fills carry the grouping instead of the\n     ink. Neighbour sectors keep their own coloured strokes (.sect) — that contrast is what separates\n     what is being reallocated from what is only context. Selection no longer draws an outline; the\n     white border was the click affordance AND the default is now white, so it had nothing left to say. */\n  .muni{stroke:#fff;stroke-width:1.1;cursor:pointer;transition:filter .1s}.muni:hover{filter:brightness(1.15)}\n  .sect{stroke-width:1;fill-opacity:.32;stroke-opacity:.6}\n  .sect.plain{fill:#e8e8ec;stroke:#d4d4d9;fill-opacity:1;stroke-opacity:1}\n  .sect.closed-ctx{fill:#e2e2e7;stroke:#aeaeb2;stroke-dasharray:3 2;fill-opacity:1;stroke-opacity:1}\n  .lbl{pointer-events:none}\n  /* No plate behind the text: a halo painted UNDER the glyphs (paint-order) keeps a name legible on any\n     fill without stamping an opaque box over the territory it names. Ink and halo are chosen per label\n     from the luminance of the fill underneath, so they are deliberately NOT declared here — a\n     stylesheet rule outranks a presentation attribute and would silently override that choice. */\n  .lbl text{text-anchor:middle;paint-order:stroke fill;stroke-width:2.2px;stroke-linejoin:round;stroke-linecap:round}\n  .lbl.big text{stroke-width:2.9px}\n  .lbl line.lead{stroke-opacity:.5;stroke-width:.8}\n  .legend{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;font-size:12px}\n  .legend b{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:5px;vertical-align:-1px}\n  .kpis{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-bottom:12px}\n  .kpi{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:9px 11px}\n  .kpi .n{font-size:19px;font-weight:700}.kpi .l{font-size:10.5px;color:var(--mut);margin-top:2px}\n  .kpi.warn{border-color:#ffd1cc}.kpi.warn .n{color:var(--closing)}.kpi.ok{border-color:#bdeecb}.kpi.ok .n{color:var(--ok)}\n  .bar-row{display:flex;align-items:center;gap:8px;margin:6px 0}\n  .bar-row .tag{width:42px;font-weight:600;font-size:12px}\n  .bar-track{flex:1;height:18px;background:var(--panel2);border-radius:5px;overflow:hidden;display:flex;position:relative}\n  .bar-mark{position:absolute;top:0;bottom:0;width:2px;opacity:.9;z-index:2}\n  .bar-base{height:100%;opacity:.4}.bar-inc{height:100%}\n  .bar-val{width:132px;text-align:right;font-size:11px;color:var(--mut);font-variant-numeric:tabular-nums}\n  table{border-collapse:collapse;width:100%;font-size:11.5px}\n  th,td{padding:5px 6px;text-align:right;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}\n  th:first-child,td:first-child{text-align:left}\n  thead th{color:var(--mut);font-weight:600;font-size:10.5px;position:sticky;top:0;background:var(--panel)}\n  .home{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:4px}\n  .muni-item{display:flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:5px 8px;margin-bottom:6px}\n  .muni-item .nm{flex:1;font-weight:600;font-size:12px}.muni-item .ct{color:var(--mut);font-size:11px}\n  .seg{display:flex;border:1px solid var(--line);border-radius:7px;overflow:hidden}\n  .seg button{border:0;background:transparent;color:var(--mut);padding:4px 8px;font-size:11px;font-weight:600;cursor:pointer}\n  .seg button.on{color:#fff}\n  .toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}\n  .toggle{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;font-size:12px}\n  .toggle button{background:transparent;border:0;color:var(--mut);padding:6px 10px;cursor:pointer;font-weight:600}\n  .toggle button.on{background:var(--panel2);color:var(--ink)}\n  .note{color:var(--mut);font-size:10.5px;margin-top:7px;line-height:1.5}\n  .cell{font-variant-numeric:tabular-nums}\n  .lider-foot td{background:var(--panel2);font-weight:600;font-size:11px;color:var(--ink);border-bottom:2px solid var(--line);padding:5px 6px;text-align:left}\n  .scroll{max-height:240px;overflow:auto}\n  /* Tables meant to be READ in full rather than sampled. Only the vertical cap is dropped — overflow\n     stays auto so the wide grupo grid can still scroll sideways inside its card, which is the one axis\n     that genuinely cannot fit. */\n  .scroll.grow{max-height:none}\n  .tip{position:fixed;pointer-events:none;background:#1c1c1e;color:#fff;border-radius:8px;padding:7px 9px;font-size:11px;opacity:0;transition:opacity .1s;z-index:9;max-width:230px}\n  .spark{display:flex;align-items:flex-end;gap:3px;height:44px}\n  .spark .b{flex:1;background:var(--acc);border-radius:2px 2px 0 0;min-height:2px;opacity:.85}\n  .delta{font-size:10px}.delta.up{color:var(--ok)}.delta.dn{color:var(--closing)}\n  .tabs{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}\n  .tabs button{background:transparent;border:1px solid var(--line);border-radius:8px;color:var(--mut);padding:6px 12px;cursor:pointer;font-weight:600}\n  .tabs button.on{background:var(--panel2);color:var(--ink);border-color:var(--acc)}\n  .hidden{display:none}\n  .sqlbox{width:100%;height:60px;font-family:ui-monospace,Menlo,monospace;font-size:11px;resize:vertical}\n  .load{position:absolute;inset:0;background:rgba(245,245,247,.85);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:20;font-size:13px;color:var(--acc);font-weight:600;text-align:center;padding:20px}\n  .load.hidden{display:none}\n  .badge{font-size:10px;padding:1px 6px;border-radius:5px;background:var(--panel2);border:1px solid var(--line);color:var(--mut)}\n  .sheet-bg{position:fixed;inset:0;background:rgba(28,28,30,.34);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;z-index:40;padding:24px}\n  .sheet-bg.hidden{display:none}\n  .sheet{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:0 24px 60px rgba(0,0,0,.22);width:min(580px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden}\n  .sheet-hd{display:flex;align-items:center;gap:9px;padding:12px 16px;border-bottom:1px solid var(--line)}\n  .sheet-hd h3{margin:0;font-size:14px;font-weight:600;letter-spacing:-.1px}\n  .sheet-x{background:transparent;border:0;color:var(--mut);font-size:13px;cursor:pointer;padding:3px 7px;border-radius:6px}\n  .sheet-x:hover{background:var(--panel2);color:var(--ink)}\n  .sheet-bd{padding:14px 16px;overflow:auto}\n  .sheet-ft{display:flex;align-items:center;gap:8px;padding:11px 16px;border-top:1px solid var(--line);background:var(--panel2)}\n  .opt-lbl{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600}\n  .opt-sub{font-size:11.5px;color:var(--mut);margin:4px 0 10px;line-height:1.5}\n  .opt-row{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-bottom:8px;cursor:pointer;background:var(--panel)}\n  .opt-row:hover{background:var(--panel2)}\n  .opt-row.on{border-color:var(--acc);background:#eaf3ff}\n  .opt-row input[type=radio]{margin:2px 0 0;accent-color:var(--acc);flex:0 0 auto}\n  .opt-t{font-size:12.5px;font-weight:600;display:flex;align-items:center;gap:6px;flex-wrap:wrap}\n  .opt-d{font-size:11.5px;color:var(--mut);margin-top:3px;line-height:1.55}\n  .opt-inline{font-size:11.5px;color:var(--mut);margin-top:9px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}\n  .opt-inline.off{opacity:.4;pointer-events:none}\n  .opt-note{border:1px solid var(--line);background:var(--panel2);border-radius:10px;padding:9px 11px;font-size:11.5px;color:var(--mut);line-height:1.6;margin-top:12px}\n  .opt-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:2px}\n  .opt-fld{display:flex;flex-direction:column;gap:5px;border:1px solid var(--line);border-radius:9px;padding:8px 10px;background:var(--panel2)}\n  .opt-fld span{font-size:11px;color:var(--mut);font-weight:600;line-height:1.35}\n  .opt-fld i{font-style:normal;font-weight:400;opacity:.72}\n  .opt-fld input{width:100%;background:var(--panel)}\n  .opt-err{margin-top:9px;font-size:11.5px;color:var(--closing);font-weight:600}\n  .opt-hr{height:1px;background:var(--line);margin:17px 0 0}\n  .shareurl{margin-top:9px;padding:9px 10px;border-radius:8px;background:var(--panel);border:1px solid var(--acc);\n    font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--ink);word-break:break-all;user-select:all}\n`,\n  render(d2){\n    return `<div class=\"simroot\"><header id=\"topHeader\" class=\"hidden\">\n  <h1>Simulador de Timeline de Fechamentos</h1>\n  <span class=\"pill live\" id=\"liveState\" role=\"button\" tabindex=\"0\" title=\"Clique pra buscar o snapshot mais recente e recalcular a timeline inteira\">● iniciando</span>\n  <span class=\"pill ro\">read-only · nunca escreve</span>\n  <span class=\"pill\" id=\"basePill\"></span>\n  <span class=\"pill\" id=\"rulePill\"></span>\n  <span class=\"pill\" id=\"spanPill\"></span>\n  <span class=\"status\" id=\"ds\"></span>\n  <div class=\"mac-spacer\"></div>\n  <select id=\"sessionPick\" style=\"min-width:200px\"></select>\n  <button class=\"ghost\" id=\"openOptions\" title=\"Configurações desta sessão\">⚙ Opções</button>\n  <button class=\"act\" id=\"saveSession\">Salvar sessão</button>\n  <button class=\"ghost\" id=\"dupSession\" title=\"Cria uma cópia independente desta sessão — a original fica intacta e a cópia passa a ser a sessão ativa\">⧉ Duplicar</button>\n  <button class=\"ghost\" id=\"openShare\" title=\"Publicar uma cópia congelada desta sessão em modo leitura\">⇪ Compartilhar</button>\n  <span class=\"status\" id=\"sessStatus\"></span>\n</header>\n\n<div class=\"rail hidden\" id=\"timelineRail\">\n  <div id=\"movementChips\"></div>\n  <div id=\"addMovementGroup\">\n    <input id=\"sectorPick\" list=\"sectorPickList\" placeholder=\"código do setor\" style=\"min-width:150px\">\n    <datalist id=\"sectorPickList\"></datalist>\n    <button class=\"ghost\" id=\"rand\" title=\"Setor aleatório\">🎲</button>\n    <button class=\"act\" id=\"go\">Fechar setor ▶ (nova etapa)</button>\n  </div>\n</div>\n\n<div class=\"card\" id=\"confirmCard\" style=\"margin:14px 20px\">\n  <h2 style=\"margin:0 0 9px\">Confirmar snapshot antes de simular</h2>\n  <div class=\"note\" id=\"confirmBody\">congelando snapshot…</div>\n  <button class=\"act\" id=\"confirmSnap\" style=\"margin-top:12px\">Confirmar snapshot ▶ ir pra simulação</button>\n  <div class=\"note\" style=\"margin-top:16px\">ou continue uma sessão salva:</div>\n  <select id=\"confirmSessionPick\" style=\"min-width:260px;margin-top:4px\"></select>\n  <div class=\"status\" id=\"confirmStatus\" style=\"margin-top:8px\"></div>\n</div>\n\n<div class=\"howto hidden\" id=\"howtoBox\">\n  <b>Como usar &nbsp;→&nbsp;</b>\n  <span><b>1.</b> Escolha um setor pra <b>fechar</b> (ou 🎲) — isso cria a etapa 1 da timeline.</span>\n  <span><b>2.</b> Feche outro setor pra criar a etapa 2 — ela já considera pra onde a etapa 1 mandou cada município.</span>\n  <span><b>3.</b> Clique numa etapa (chip acima) pra revisitar/editar — editar uma etapa recalcula automaticamente todas as etapas seguintes.</span>\n  <span><b>4.</b> Veja o efeito acumulado da cadeia inteira na aba <b>Impacto acumulado</b>.</span>\n</div>\n<div class=\"wrap hidden\" id=\"mainWrap\">\n  <div>\n    <div class=\"card\">\n      <div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:9px\">\n        <h2 id=\"mapTitle\" style=\"margin:0\">Mapa — clique num município p/ trocar o setor destino</h2>\n        <div class=\"toggle\" id=\"viewToggle\" title=\"ASIS = estrutura do setor antes desta etapa (já considerando etapas anteriores) · TOBE = após a redistribuição desta etapa\">\n          <button data-v=\"asis\">◧ ASIS (antes desta etapa)</button>\n          <button data-v=\"tobe\" class=\"on\">◨ TOBE (simulação)</button>\n        </div>\n      </div>\n      <svg id=\"map\" viewBox=\"0 0 700 500\" preserveAspectRatio=\"xMidYMid meet\"></svg>\n      <div class=\"legend\" id=\"legend\"></div>\n      <div class=\"note\">Coloridos = municípios do setor fechado nesta etapa (cor = destino). Ao redor, cada área é um <b>setor vizinho inteiro</b> com o código no meio — não municípios soltos: cor = setor destino, cinza = contexto, cinza tracejado = setor já fechado numa etapa anterior. Shapes reais IBGE/tbrugz.</div>\n      <div class=\"note\" id=\"mapIgnoredNote\"></div>\n    </div>\n\n    <div class=\"card\">\n      <h2>Líderes — destino e CBs deslocadas (nesta etapa)</h2>\n      <div class=\"note\" style=\"margin:-4px 0 9px\"><b>Home</b> = setor que ficou com a maioria das CBs da líder. <b style=\"color:var(--closing)\">Deslocadas</b> = CBs dela que caíram noutro setor.</div>\n      <div class=\"scroll grow\"><table id=\"leaders\"></table></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"tabs\">\n        <button data-tab=\"matrix\" class=\"on\">Matriz ASIS</button>\n        <button data-tab=\"tobe\">Matriz TOBE</button>\n        <button data-tab=\"report\">Relatório de impacto (etapa)</button>\n        <button data-tab=\"cumulative\">Impacto acumulado (timeline)</button>\n        <button data-tab=\"sql\">Console SQL</button>\n      </div>\n      <div id=\"tab-matrix\"><div class=\"scroll grow\"><table id=\"matrix\"></table></div></div>\n      <div id=\"tab-tobe\" class=\"hidden\"><div class=\"scroll grow\"><table id=\"matrixTobe\"></table></div>\n        <div class=\"note\" id=\"tobeNote\"></div></div>\n      <div id=\"tab-report\" class=\"hidden\"></div>\n      <div id=\"tab-cumulative\" class=\"hidden\"></div>\n      <div id=\"tab-sql\" class=\"hidden\">\n        <textarea class=\"sqlbox\" id=\"sqlIn\">SELECT DS_MUNICIPIO, COUNT(DISTINCT CD_CONSULTORA) qtd FROM product.perfil_cb.crm_perfil_cb_diaria_historico WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=3504 GROUP BY DS_MUNICIPIO ORDER BY qtd DESC</textarea>\n        <div class=\"toolbar\" style=\"margin:8px 0\"><button class=\"act\" id=\"runSql\">▶ Rodar</button><span class=\"status\" id=\"sqlStatus\"></span></div>\n        <div class=\"note\" style=\"margin:-4px 0 8px\">Roda contra o snapshot congelado desta sessão (não \"ao vivo\") — mesma coerência da timeline.</div>\n        <div class=\"scroll\"><table id=\"sqlOut\"></table></div>\n      </div>\n    </div>\n  </div>\n\n  <div>\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"margin-bottom:11px\">\n        <button class=\"ghost\" id=\"optim\" title=\"testa todas alocações contíguas e escolhe a que equilibra a carga entre setores + minimiza a quebra (evita empilhar tudo num setor)\">★ Modelo ÓTIMO (equilíbrio + quebra)</button>\n        <button class=\"ghost\" id=\"autoTobe\" title=\"preenche cada município pelo setor vizinho mais próximo (geografia)\">TOBE por adjacência</button>\n        <span class=\"badge\" id=\"optBadge\"></span>\n      </div>\n      <div class=\"note\" style=\"margin:-4px 0 10px\"><b style=\"color:var(--ink)\">★ ÓTIMO</b> = equilibra a carga entre os setores <b>e</b> minimiza a quebra (contíguo; não empilha tudo num setor). <b style=\"color:var(--ink)\">TOBE</b> = sugestão por vizinhança. Editar esta etapa recalcula automaticamente as etapas seguintes da timeline.</div>\n      <div class=\"note\" id=\"ruleNote\" style=\"margin:-6px 0 11px;padding:7px 9px;background:var(--panel2);border:1px solid var(--line);border-radius:8px\"></div>\n      <div class=\"kpis\" id=\"kpis\"></div>\n      <div id=\"contig\"></div>\n      <div id=\"spanRule\"></div>\n      <h2>Span por setor (CBs) — base + entrantes</h2>\n      <div id=\"spans\"></div>\n      <div style=\"display:flex;justify-content:space-between;align-items:center;margin:14px 0 7px\">\n        <h2 style=\"margin:0\">CBs deslocadas</h2>\n        <div class=\"toggle\" id=\"policy\">\n          <button data-p=\"fora\" class=\"on\">= fora do setor</button>\n          <button data-p=\"quebra\">= quebra de relação</button>\n        </div>\n      </div>\n      <div class=\"note\" id=\"policyNote\"></div>\n    </div>\n\n    <div class=\"card\">\n      <h2>Municípios → setor destino</h2>\n      <div class=\"note\" style=\"margin:-4px 0 9px\">Clique no <b>código do setor</b> pra mandar o município <b>inteiro</b> pra lá (regra de negócio). A cor acompanha o mapa.</div>\n      <div id=\"muniList\"></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"justify-content:space-between\">\n        <h2 style=\"margin:0\">Plano de migração (etapa selecionada)</h2>\n        <div><button class=\"ghost\" id=\"copyPlan\">Copiar</button> <button class=\"ghost\" id=\"csvPlan\">CSV</button></div>\n      </div>\n      <div id=\"plan\" class=\"note\"></div>\n    </div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"shareBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"shareTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"shareTitle\">Compartilhar sessão</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"shareX\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\" id=\"shareBody\"></div>\n    <div class=\"sheet-ft\">\n      <span class=\"status\" id=\"shareStatus\"></span>\n      <div class=\"mac-spacer\"></div>\n      <button class=\"ghost hidden\" id=\"shareCopy\">Copiar link</button>\n      <button class=\"ghost hidden\" id=\"shareOpen\">Abrir</button>\n      <button class=\"ghost\" id=\"shareCancel\">Cancelar</button>\n      <button class=\"act\" id=\"shareGo\">Compartilhar</button>\n    </div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"optionsBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"optTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"optTitle\">Opções da sessão</h3><span class=\"badge\" id=\"optSessName\"></span><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"optClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-lbl\">Regra rígida — seleção dos setores destino</div>\n      <div class=\"opt-sub\">Define quais setores vizinhos podem entrar na lista de destinos de cada etapa. Vale pra toda a timeline desta sessão.</div>\n      <label class=\"opt-row\" data-r=\"any\">\n        <input type=\"radio\" name=\"destRule\" value=\"any\">\n        <div>\n          <div class=\"opt-t\">Adjacente a qualquer município <span class=\"badge\">padrão</span></div>\n          <div class=\"opt-d\">Um setor entra na lista de destinos se tiver município vizinho a <b>qualquer</b> município do setor fechado — inclusive os menores. É o comportamento de hoje.</div>\n        </div>\n      </label>\n      <label class=\"opt-row\" data-r=\"dominant\">\n        <input type=\"radio\" name=\"destRule\" value=\"dominant\">\n        <div>\n          <div class=\"opt-t\">Adjacente a municípios dominantes</div>\n          <div class=\"opt-d\">Só os municípios do setor fechado com <b>CBs suficientes</b> valem como ponte. Os pequenos continuam sendo redistribuídos normalmente — só não trazem setor novo pra lista de destinos.</div>\n          <div class=\"opt-inline\" id=\"optMinCbsWrap\">Ignorar municípios com menos de <input type=\"number\" id=\"optMinCbs\" min=\"1\" max=\"9999\" step=\"1\" style=\"width:74px\"> CBs</div>\n        </div>\n      </label>\n      <div class=\"opt-hr\"></div>\n      <div class=\"opt-lbl\" style=\"margin-top:15px\">Pré-requisitos do setor destino — tamanho em CBs</div>\n      <div class=\"opt-sub\">Vale pro tamanho de cada setor receptor <b>depois</b> da redistribuição (base + entrantes). Em branco = desligado.</div>\n      <div class=\"opt-grid\">\n        <label class=\"opt-fld\"><span>Span alvo <i>(tamanho ideal)</i></span><input type=\"number\" id=\"optTargetSpan\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"livre\"></label>\n        <label class=\"opt-fld\"><span>Span máximo <i>(nenhum setor acima)</i></span><input type=\"number\" id=\"optMaxSpan\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n        <label class=\"opt-fld\"><span>Span mínimo <i>(nenhum setor abaixo)</i></span><input type=\"number\" id=\"optMinSpan\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n      </div>\n      <div class=\"opt-err hidden\" id=\"optSpanErr\"></div>\n      <div class=\"opt-note\" id=\"optPreview\"></div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"optStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"optCancel\">Cancelar</button><button class=\"act\" id=\"optApply\">Aplicar</button></div>\n  </div>\n</div>\n\n<div class=\"tip\" id=\"tip\"></div>\n<div class=\"load hidden\" id=\"loader\">carregando…</div>\n<span id=\"ready\" style=\"display:none\"></span></div>`;\n  },\n  mounted(d2, root){\n\nconst $=s=>root.querySelector(s);\nconst fmt=n=>n==null?'–':Math.round(+n).toLocaleString('pt-BR');\nconst money=n=>{n=+n||0;return n>=1e6?'R$'+(n/1e6).toFixed(1)+'M':n>=1e3?'R$'+(n/1e3).toFixed(0)+'k':'R$'+Math.round(n)};\nconst norm=s=>(s||'').normalize('NFKD').replace(/[̀-ͯ]/g,'').toUpperCase().replace(/[^A-Z0-9]+/g,' ').trim();\nconst esc=s=>(s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\nconst blankM=()=>({cbs:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0,ativas:0});\n// ---------- município identity ----------\n// Brazil has many municipalities sharing a name (Bom Jesus alone exists in PI, RN, RS, SC, PB, GO).\n// A stage loads the dominant UF PLUS every bordering UF, so those collisions land in the SAME\n// feature set routinely: keyed by name alone the last shape loaded won, the map could draw a\n// município from the wrong state, buildAdj merged two unrelated municipalities into one node, and\n// GROUP BY DS_MUNICIPIO summed two real municipalities into one row. The key is NAME/UF everywhere\n// — SQL rows, GeoJSON features, the dominantes base, assignments, persisted sessions. norm() maps\n// every non-alphanumeric to a space, so '/' can never occur inside a name and the split is exact.\n// A missing UF degrades to a bare NAME key rather than inventing one.\nconst mkey=(name,uf)=>{const n=norm(name),u=String(uf==null?'':uf).trim().toUpperCase();return u?n+'/'+u:n;};\nconst mName=k=>{const s=String(k==null?'':k),i=s.lastIndexOf('/');return i<0?s:s.slice(0,i);};\nconst mUf=k=>{const s=String(k==null?'':k),i=s.lastIndexOf('/');return i<0?'':s.slice(i+1);};\nconst mDisp=k=>{const u=mUf(k);return u?mName(k)+' ('+u+')':mName(k);};\n// The base CSV is the ONLY source of a município's UF now, so its column is accepted in whatever\n// shape the base is maintained in: sigla, IBGE numeric code, or full state name. Anything that does\n// not resolve to a real sigla is treated as UNKNOWN rather than passed through — a bogus UF would key\n// a município to a shape that does not exist and drop it off the map without a word.\nconst UF_OF_CODE={}; Object.keys(UF_CODE).forEach(k=>{ UF_OF_CODE[UF_CODE[k]]=k; });\nconst UF_OF_NAME={ACRE:'AC',ALAGOAS:'AL',AMAPA:'AP',AMAZONAS:'AM',BAHIA:'BA',CEARA:'CE','DISTRITO FEDERAL':'DF','ESPIRITO SANTO':'ES',GOIAS:'GO',MARANHAO:'MA','MATO GROSSO':'MT','MATO GROSSO DO SUL':'MS','MINAS GERAIS':'MG',PARA:'PA',PARAIBA:'PB',PARANA:'PR',PERNAMBUCO:'PE',PIAUI:'PI','RIO DE JANEIRO':'RJ','RIO GRANDE DO NORTE':'RN','RIO GRANDE DO SUL':'RS',RONDONIA:'RO',RORAIMA:'RR','SANTA CATARINA':'SC','SAO PAULO':'SP',SERGIPE:'SE',TOCANTINS:'TO'};\nconst ufNorm=v=>{const s=norm(v); if(!s) return '';\n  if(UF_CODE[s]) return s;\n  if(UF_OF_CODE[s]) return UF_OF_CODE[s];\n  return UF_OF_NAME[s]||'';};\nconst loader=(on,t)=>{const l=$('#loader');l.classList.toggle('hidden',!on);if(t)l.textContent=t;};\nconst setError=(msg)=>{ const t=$('#ds'); if(t){t.textContent=msg;t.classList.add('err');} const c=$('#confirmStatus'); if(c){c.textContent=msg;c.classList.add('err');} };\n\n// ---------- base de municípios dominantes (CSV) ----------\n// WHICH municipalities belong to a sector is a business fact, not something to infer from CB volume:\n// it comes from a maintained CSV (one row per setor x município) linked through the `dominantesCsvUrl`\n// param. Anything the closing sector has CBs in but that the base does NOT assign to it is spillover:\n// not realocated, not drawn on the map, and its CBs follow their leader. Loaded once per URL and\n// cached for the whole session; a failure is thrown, never swallowed — a silent fallback here would\n// quietly change every number on the screen.\nconst DOM={url:null,promise:null,bySetor:null,ufBySetorMuni:null,rows:0,setores:0,hasUf:false,ufCol:null,badUf:0,clashes:0,headers:null,error:null};\nfunction parseCsv(text){\n  const out=[]; let row=[],cur='',q=false;\n  const push=()=>{ row.push(cur); cur=''; };\n  const endRow=()=>{ push(); if(row.length>1||row[0]!=='') out.push(row); row=[]; };\n  for(let i=0;i<text.length;i++){ const ch=text[i];\n    if(q){ if(ch==='\"'){ if(text[i+1]==='\"'){ cur+='\"'; i++; } else q=false; } else cur+=ch; }\n    else if(ch==='\"') q=true;\n    else if(ch===',') push();\n    else if(ch==='\\n') endRow();\n    else if(ch!=='\\r') cur+=ch;\n  }\n  if(cur!==''||row.length) endRow();\n  return out;\n}\n// `dominantesCsvUrl` is declared as a FileComponentUrlReference, so the instance points at the Drive\n// FILE, not at a URL literal — the Drive is policy-gated behind the app's auth strategy and a baked\n// string would be both stale and unauthorised. What the resolver hands back varies by where it is\n// resolved (URL string / file record / bare reference), so accept every shape instead of silently\n// reading undefined and falling through to the fallback.\n// The Drive is gated by the app's AuthenticationStrategy, so this read has to travel as the LOGGED-IN\n// USER — an anonymous CDN GET is exactly what the policy is there to refuse. `<system>.data2.media` is\n// only a cache in front of the SSP: on a cache miss it 302s to `<system>.data2apis.com`, which is where\n// the policy is actually evaluated.\n// COOKIES CANNOT CARRY THAT SESSION, which is why the previous `credentials:'include'` attempt could\n// never have worked: the app runs on <app>.<system>.data2.app and the SSP on <system>.data2apis.com —\n// cross-site — and the SSP answers `Access-Control-Allow-Origin: *` with no `Allow-Credentials`, so the\n// browser rejects a credentialed CORS read before it ever leaves the page. What the SSP actually reads\n// is `Authorization: Bearer <token>`: the runtime keeps that token in localStorage under\n// `__data2-session-<systemDomain>` (`__data2-session-data2` inside the builder preview) and replays it\n// on every dataset and rush call — same token, same header, same session, so the Drive policy sees the\n// logged-in user. Preflight passes because the SSP echoes whatever header the request asks for.\nfunction d2SessionToken(sysDomain){\n  const read=k=>{ try{ return (k&&window.localStorage.getItem(k))||''; }catch(e){ return ''; } };\n  return read(sysDomain?('__data2-session-'+sysDomain):'')||read('__data2-session-data2')||'';\n}\nfunction domCsvSystemDomain(url){\n  const m=/^https?:\\/\\/([^.\\/]+)\\.data2\\.(?:media|app)\\//i.exec(url)||/^https?:\\/\\/([^.\\/]+)\\.data2apis\\.com\\//i.exec(url);\n  return m?m[1]:'';\n}\nfunction domCsvCandidates(url){\n  const out=[];\n  const isMedia=/^https?:\\/\\/[^.\\/]+\\.data2\\.media\\//i.test(url);\n  const api=isMedia?url.replace(/^(https?:\\/\\/[^.\\/]+)\\.data2\\.media\\//i,'$1.data2apis.com/'):null;\n  const tok=d2SessionToken(domCsvSystemDomain(url));\n  const auth=tok?{authorization:'Bearer '+tok}:null;\n  // Authenticated origin read first — the only candidate a GATED Drive can actually answer.\n  if(api&&auth) out.push({url:api,headers:auth,label:'api+sessão'});\n  if(!api&&auth) out.push({url:url,headers:auth,label:'origem+sessão'});\n  // Anonymous last: only ever succeeds on an UNGATED Drive — or on a stale CDN cache hit, which is NOT\n  // proof the file is public (the origin keeps answering 401 once the edge entry expires).\n  if(api) out.push({url:api,headers:null,label:'api anônimo'});\n  out.push({url:url,headers:null,label:'origem anônima'});\n  return out;\n}\nfunction domCsvUrl(){\n  const pick=v=>{\n    if(!v) return '';\n    if(typeof v==='string') return v;\n    const q=v.params||{};\n    const ref=q.reference||v.reference;\n    return q.fileUrl||q.url||v.fileUrl||v.url||(ref?DOM_MEDIA_BASE+ref:'');\n  };\n  return String(pick(d2.param('dominantesCsvUrl'))||DOM_CSV_FALLBACK||'').trim();\n}\nasync function ensureDominantes(){\n  const url=domCsvUrl();\n  if(!url) throw new Error('base de municípios dominantes não configurada — ligue o arquivo CSV no parâmetro dominantesCsvUrl da instância do componente');\n  if(DOM.url===url && DOM.bySetor) return DOM.bySetor;\n  if(DOM.url===url && DOM.promise) return DOM.promise;\n  DOM.url=url; DOM.error=null;\n  DOM.promise=(async()=>{\n    // Self-diagnosing on purpose: every candidate reports its own outcome and the message names the\n    // session it was carrying. \"Unauthorized\" alone cannot distinguish \"not logged into THIS strategy\"\n    // from \"cookie not sent cross-site\" from \"param never bound, so it tried the fallback URL\" — and\n    // guessing between those from the outside costs a round trip each time.\n    let r=null; const tried=[];\n    for(const c of domCsvCandidates(url)){\n      let res=null;\n      try{ res=await fetch(c.url,{cache:'no-store',credentials:'omit',headers:c.headers||undefined}); }\n      catch(e){ tried.push(c.label+' → rede/CORS'); continue; }\n      if(res.ok){ r=res; break; }\n      tried.push(c.label+' → '+res.status);\n    }\n    if(!r){\n      let sess='sessão desconhecida';\n      try{\n        const a=d2.auth(AUTH_REF);\n        sess=(a&&a.isAuthed)?('logado como '+((a.user&&(a.user.email||a.user.nome))||'?')):('NÃO logado na estratégia '+AUTH_REF);\n      }catch(e){ sess='estratégia '+AUTH_REF+' não resolvida'; }\n      const tk=d2SessionToken(domCsvSystemDomain(url));\n      throw new Error('base de municípios dominantes: não foi possível ler o arquivo — '+sess+' · token de sessão '+(tk?'presente':'AUSENTE no localStorage')+'. Tentativas: '+tried.join(' · '));\n    }\n    const table=parseCsv(await r.text());\n    if(table.length<2) throw new Error('base de municípios dominantes vazia');\n    const head=table[0].map(h=>norm(h));\n    const iS=head.indexOf('CD SETOR'), iM=head.indexOf('MUNICIPIO');\n    if(iS<0||iM<0) throw new Error('base de municípios dominantes sem as colunas CD_SETOR / MUNICIPIO');\n    // CD_GV is the gerência de vendas the setor answers to, and it is the authority for the hard rule\n    // that a closing sector only ever hands territory to sectors of its OWN gerência. Its absence is\n    // fatal rather than a warning: with no GV there is no way to PROVE two sectors share one, and\n    // falling through unchecked is exactly the leak this column exists to close.\n    const iG=['CD GV','CDGV','GV','CD GERENCIA','CD GERENCIA VENDAS','CD GERENCIA DE VENDAS','GERENCIA DE VENDAS','GERENCIA'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    if(iG==null) throw new Error('base de municípios dominantes sem a coluna CD_GV — a regra de gerência de vendas não pode ser aplicada. Colunas lidas: '+head.slice(0,40).join(', '));\n    // The UF column is what the whole keying now rests on — SQL no longer carries DS_ESTADO — so its\n    // absence is a first-class finding, not a footnote. The header actually matched is reported on the\n    // base pill, and when nothing matches the pill lists every column read, because guessing a header\n    // name from the outside costs a round trip each time.\n    const iU=['UF','SIGLA UF','SG UF','CD UF','ESTADO','DS ESTADO','SIGLA','ESTADO SIGLA','UF MUNICIPIO'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    const by={},ufBy={},gvBy={}; let n=0,bad=0; const clash={},gvClash={};\n    for(let i=1;i<table.length;i++){\n      const line=table[i]; if(!line) continue;\n      const s=String(line[iS]==null?'':line[iS]).trim(), m=norm(line[iM]);\n      if(!s||!m) continue;\n      // One setor spans many rows (one per município) and every one of them repeats its CD_GV. Two\n      // DIFFERENT values for the same setor means the base itself disagrees about which gerência owns\n      // it — counted and reported, never keyed to whichever row happened to be read last.\n      const gvv=String(line[iG]==null?'':line[iG]).trim();\n      if(gvv){ if(gvBy[s]!==undefined && gvBy[s]!==gvv) gvClash[s]=true; else gvBy[s]=gvv; }\n      const raw=(iU==null)?'':line[iU];\n      const u=(iU==null)?'':ufNorm(raw);\n      if(iU!=null && String(raw==null?'':raw).trim() && !u) bad++;\n      // The same name twice in ONE setor under two UFs is the single case the reverted SQL cannot\n      // survive: mSQL groups by DS_MUNICIPIO alone, so both municipalities arrive summed into one row\n      // and no lookup can split them again. Counted and reported — never quietly keyed to whichever\n      // UF happened to be read last.\n      const kk=s+'|'+m;\n      // An UNRESOLVED uf must never outrank a resolved one. Reading a blank/bogus UF row first wrote ''\n      // here, and the next row carrying the real sigla was then rejected as a clash — so the key stayed\n      // falsy for good, ufFor() fell through, and the município degraded to a bare NAME key that matches\n      // no shape and no dominance entry. With DISTRITO/ZONA in the base a município spans several rows,\n      // so which row lands first is pure luck. A clash is only two RESOLVED siglas that disagree.\n      if(!u){ if(ufBy[kk]===undefined) ufBy[kk]=''; }\n      else if(!ufBy[kk]) ufBy[kk]=u;\n      else if(ufBy[kk]!==u) clash[kk]=true;\n      (by[s]=by[s]||new Set()).add(u?m+'/'+u:m); n++;\n    }\n    if(!n) throw new Error('base de municípios dominantes sem linhas válidas');\n    DOM.bySetor=by; DOM.ufBySetorMuni=ufBy; DOM.rows=n; DOM.setores=Object.keys(by).length;\n    DOM.gvBySetor=gvBy; DOM.gvClashes=Object.keys(gvClash).length; DOM.gvCol=String(table[0][iG]||'').trim();\n    DOM.gvSetores=Object.keys(gvBy).length;\n    DOM.hasUf=(iU!=null); DOM.ufCol=(iU==null?null:String(table[0][iU]||'').trim());\n    DOM.badUf=bad; DOM.clashes=Object.keys(clash).length; DOM.headers=head.slice(0,40); DOM.error=null;\n    return by;\n  })();\n  DOM.promise.catch(e=>{ DOM.promise=null; DOM.bySetor=null; DOM.error=e.message; renderAll(); });\n  return DOM.promise;\n}\n\nlet SESS={ snapshotRaw:null, snapshotSyncedAt:null, snapLiteral:null, eloMaxCycle:null, timeline:[], selected:0,\n           sessionId:null, sessionLabel:null, sessionsList:[], sectorsList:[], sectorBaseCache:{}, stage:'loading', dirtyFrom:null,\n           snapBusy:false, settings:{...DEFAULT_SETTINGS} };\n\nasync function q(sql){\n  let dbx,res;\n  try{\n    res=await d2.rush('AwW_11Nkg-dKksP7ic8V8').invoke({sql});\n  }catch(e){\n    throw new Error('Databricks indisponível (rush AwW_11Nkg-dKksP7ic8V8): '+e.message);\n  }\n  // The listener answers 200 with {sql} and NO `result` when its upstream probe times out: the\n  // handler builds `result: $result.data`, so an empty upstream simply drops the key rather than\n  // failing. Reading straight through to res.data.result.data reported \"Cannot read properties of\n  // undefined (reading 'data')\" — which reads like a bug in THIS component and sent a debugging\n  // session after the wrong thing. Name the layer that actually failed instead.\n  const payload=res&&res.data;\n  if(!payload) throw new Error('rush AwW_11Nkg-dKksP7ic8V8 não devolveu corpo — listener fora do ar?');\n  if(!payload.result) throw new Error('a sonda Databricks não respondeu: o listener AwW_11Nkg-dKksP7ic8V8 devolveu 200 sem `result`, o que normalmente é timeout do upstream (natura-x-app). O SQL não chegou a rodar.');\n  dbx=payload.result.data;\n  if(!dbx) throw new Error('sonda Databricks respondeu sem dados (result.data vazio).');\n  if(dbx.state && dbx.state!=='SUCCEEDED') throw new Error('Databricks '+dbx.state+': '+(dbx.error?.message||JSON.stringify(dbx.error)||'erro desconhecido'));\n  return dbx;\n}\nfunction rows(resp){const c=(resp.columns||[]).map(x=>x.name);\n  return (resp.rows||[]).map(a=>Object.fromEntries(c.map((k,i)=>[k,a[i]])));}\n// Stamps the UF onto every feature at load: the fetch is per-UF, so this is the ONLY point where a\n// shape's state is known for certain. Downstream the features are flattened into one array and the\n// origin would be unrecoverable. Persisted with the feature in saved sessions.\nasync function geoUF(uf){const code=UF_CODE[uf]||uf;const r=await fetch('https://raw.githubusercontent.com/tbrugz/geodata-br/master/geojson/geojs-'+code+'-mun.json');if(!r.ok)throw new Error('geo '+uf);\n  const g=await r.json(); const U=String(uf).toUpperCase();\n  (g.features||[]).forEach(f=>{ f.__uf=U; });\n  return g;}\n\nfunction buildAdj(features){\n  const pts=f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates],s=new Set();\n    ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>s.add(Math.round(p[0]*1000)/1000+','+Math.round(p[1]*1000)/1000))));return s;};\n  const feat={},idx={};\n  features.forEach(f=>{const nm=mkey(f.properties.name||f.properties.NOME||f.properties.nome,f.__uf);feat[nm]={f,pts:pts(f)};});\n  for(const nm in feat) feat[nm].pts.forEach(p=>(idx[p]=idx[p]||[]).push(nm));\n  const nbrs=nm=>{const c={};feat[nm].pts.forEach(p=>idx[p].forEach(o=>{if(o!==nm)c[o]=(c[o]||0)+1;}));\n    return Object.entries(c).filter(([,n])=>n>=2).map(([o,n])=>({o,w:n}));};\n  return {feat,nbrs};\n}\nfunction centroidLL(f){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  ps.forEach(poly=>poly[0].forEach(p=>{x+=p[0];y+=p[1];n++;}));return[x/n,y/n];}\n\n// ---------- snapshot pinning ----------\nasync function resolveSnapshotPin(){\n  const [rawR,liveCountR,eloR]=await Promise.all([\n    q(`SELECT MAX(DT_HISTORICO) snap FROM ${T} WHERE CD_PAIS=1`).then(rows),\n    q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)`).then(rows),\n    q(`SELECT MAX(operational_cycle) mx FROM ${ELO} WHERE country_code=1`).then(rows).catch(()=>[]),\n  ]);\n  const raw=rawR[0]&&rawR[0].snap;\n  const liveCount=+((liveCountR[0]||{}).n||0);\n  const eloMax=+((eloR[0]||{}).mx||0);\n  const LIVE_SUBQ=`(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)`;\n  const dbTs=(raw||'').replace('T',' ').replace(/Z$/,'');\n  const candidates=[`TIMESTAMP'${dbTs}'`,`'${dbTs}'`,`DATE'${dbTs}'`];\n  const tests=await Promise.all(candidates.map(c=>\n    q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=${c}`).then(rows).then(r=>+((r[0]||{}).n||0)).catch(()=>-1)\n  ));\n  let chosen=LIVE_SUBQ;\n  for(let i=0;i<candidates.length;i++){ if(liveCount>0 && tests[i]===liveCount){ chosen=candidates[i]; break; } }\n  return {snapshotRaw:raw, snapLiteral:chosen, eloMaxCycle:eloMax, syncedAt:new Date().toISOString()};\n}\n// DISPLAY CONTRACT: the UI never prints the snapshot's own DT_HISTORICO (snapshotRaw). What the user\n// sees is `snapshotSyncedAt` — the date AND hour at which THIS instance last pulled/pinned the\n// snapshot it is querying. snapshotRaw is still resolved, still drives every SQL literal, and is\n// still persisted by saveSession/doShare; it is only kept off the screen.\nfunction fmtSyncedAt(iso){\n  if(!iso) return '—';\n  const d=new Date(iso);\n  if(isNaN(d.getTime())) return String(iso);\n  return d.toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});\n}\nasync function ensurePinned(){\n  if(SESS.snapLiteral) return;\n  const p=await resolveSnapshotPin();\n  SESS.snapshotRaw=p.snapshotRaw; SESS.snapLiteral=p.snapLiteral; SESS.eloMaxCycle=p.eloMaxCycle;\n  SESS.snapshotSyncedAt=p.syncedAt;\n}\n\n// ---------- world-state fold ----------\nfunction accMetrics(base,add){\n  const out=base?{...base}:blankM();\n  Object.keys(blankM()).forEach(k=>{ out[k]=(out[k]||0)+((add&&add[k])||0); });\n  return out;\n}\nfunction foldWorldState(timeline,uptoIdx){\n  const closedSectors=new Set(),muniOverride={},muniGeo={},muniRow={},muniLevel={},sectorInflow={},sectorInflowLevel={},sectorOrphanRows={};\n  for(let k=0;k<uptoIdx;k++){\n    const mv=timeline[k]; if(!mv||!mv.sector||!mv.munis) continue;\n    closedSectors.add(String(mv.sector));\n    Object.keys(mv.munis).forEach(m=>{\n      if(m==='__OUTROS__') return;\n      const dest=mv.assign[m]; if(!dest) return;\n      muniOverride[m]=String(dest);\n      if(mv.geoFeat&&mv.geoFeat[m]) muniGeo[m]=mv.geoFeat[m];\n      const rws=(mv.matrix||[]).filter(r=>mkey(r.municipio,r.uf)===m);\n      muniRow[m]=(muniRow[m]||[]).concat(rws);\n      if(mv.levelByMuni&&mv.levelByMuni[m]) muniLevel[m]={...(muniLevel[m]||{}),...mv.levelByMuni[m]};\n      sectorInflow[dest]=accMetrics(sectorInflow[dest],mv.munis[m]);\n      const lv=(mv.levelByMuni&&mv.levelByMuni[m])||{};\n      sectorInflowLevel[dest]=sectorInflowLevel[dest]||{};\n      Object.entries(lv).forEach(([nv,c])=>{ sectorInflowLevel[dest][nv]=(sectorInflowLevel[dest][nv]||0)+c; });\n    });\n    // Orphan CBs follow their leader's home sector, so later etapas must inherit them the same way\n    // the allocated municipalities are inherited — otherwise a stage's span would not match the\n    // base carried into the next one.\n    if(mv.orphanMetricsByGroup && Object.keys(mv.orphanMetricsByGroup).length){\n      const d=computeDerived(mv);\n      d.leaders.forEach(L=>{\n        if(!L.home) return;\n        const om=mv.orphanMetricsByGroup[L.grupo];\n        if(om) sectorInflow[L.home]=accMetrics(sectorInflow[L.home],om);\n        // sectorInflow only reaches a sector while it is a DESTINATION (it lands in baseMetrics).\n        // Keep the rows as well, so the sector still carries these CBs on the day it is the one\n        // being closed — that read goes through mv.matrix, which baseMetrics never touches.\n        const orws=mv.orphanRowsByGroup&&mv.orphanRowsByGroup[L.grupo];\n        if(orws&&orws.length) sectorOrphanRows[L.home]=(sectorOrphanRows[L.home]||[]).concat(orws);\n        const ol=mv.orphanLevelByGroup&&mv.orphanLevelByGroup[L.grupo];\n        if(ol){ sectorInflowLevel[L.home]=sectorInflowLevel[L.home]||{};\n          Object.entries(ol).forEach(([nv,c])=>{ sectorInflowLevel[L.home][nv]=(sectorInflowLevel[L.home][nv]||0)+c; }); }\n      });\n    }\n  }\n  return {closedSectors,muniOverride,muniGeo,muniRow,muniLevel,sectorInflow,sectorInflowLevel,sectorOrphanRows};\n}\n\n// ---------- per-movement compute ----------\nfunction aggregateMovement(mv){\n  const munis={},leaders={};\n  const exSet=new Set((mv.excludedMunis||[]).map(x=>x.muni));\n  const acc=(o,r)=>{o.cbs+=+r.cbs;o.receita+=+r.receita||0;o.rec_nat+=+r.rec_nat||0;o.rec_avon+=+r.rec_avon||0;o.pontos+=+r.pontos||0;o.pedidos+=+r.pedidos||0;o.ativas+=+r.ativas||0;};\n  // Orphans = CBs in municipalities outside the sector's dominance base. They are NOT allocated\n  // geographically; they follow their own leader's home sector. Kept per leader group so every\n  // consumer (span, cascade, impact report) can route them the same way.\n  mv.orphanByGroup={}; mv.orphanMetricsByGroup={}; mv.orphanRowsByGroup={};\n  mv.matrix.forEach(r=>{\n    const m=mkey(r.municipio,r.uf);\n    r._out=!mv.geoFeat[m];   // assign, never just set: absorbed rows are reused across etapas\n    const mm=r._out?'__OUTROS__':m;\n    munis[mm]=munis[mm]||{...blankM(),name:mm}; acc(munis[mm],r);\n    const g=String(r.grupo); leaders[g]=leaders[g]||{grupo:g,lider:r.lider,total:0,byMuni:{}};\n    leaders[g].total+=+r.cbs; leaders[g].byMuni[mm]=(leaders[g].byMuni[mm]||0)+ +r.cbs;\n    if(r._out && exSet.has(m)){\n      mv.orphanByGroup[g]=(mv.orphanByGroup[g]||0)+ +r.cbs;\n      // The ROW too, not just the totals: when the leader's home sector is itself closed in a later\n      // etapa, that etapa rebuilds its matrix from SQL + absorbed municípios, and these CBs are in\n      // neither (their município never moved). Without the row they silently vanish from the sector\n      // they were just credited to — see sectorOrphanRows in foldWorldState.\n      (mv.orphanRowsByGroup[g]=mv.orphanRowsByGroup[g]||[]).push(r);\n      mv.orphanMetricsByGroup[g]=accMetrics(mv.orphanMetricsByGroup[g],\n        {cbs:+r.cbs,receita:+r.receita||0,rec_nat:+r.rec_nat||0,rec_avon:+r.rec_avon||0,pontos:+r.pontos||0,pedidos:+r.pedidos||0,ativas:+r.ativas||0});\n    }\n  });\n  // Level counts only exist per municipality, never per leader, so an excluded municipality's level\n  // mix is credited to the group holding most of its CBs (sub-threshold by definition, so usually one).\n  mv.orphanLevelByGroup={};\n  (mv.excludedMunis||[]).forEach(({muni})=>{\n    const lv=(mv.levelByMuni&&mv.levelByMuni[muni])||{}; if(!Object.keys(lv).length) return;\n    let bg=null,bc=-1;\n    mv.matrix.forEach(r=>{ if(mkey(r.municipio,r.uf)!==muni) return; const cc=+r.cbs; if(cc>bc){bc=cc;bg=String(r.grupo);} });\n    if(!bg) return;\n    mv.orphanLevelByGroup[bg]=mv.orphanLevelByGroup[bg]||{};\n    Object.entries(lv).forEach(([nv,cc])=>{ mv.orphanLevelByGroup[bg][nv]=(mv.orphanLevelByGroup[bg][nv]||0)+cc; });\n  });\n  mv.munis=munis; mv.leaders=Object.values(leaders).sort((a,b)=>b.total-a.total);\n}\nfunction autoAssignFor(mv){\n  const a={};\n  Object.keys(mv.munis).forEach(m=>{ if(m==='__OUTROS__')return;\n    const v=mv.allowed[m]||{}; const votes=Object.keys(v);\n    if(votes.length){ let best=votes[0],bw=-1; for(const s of votes) if(v[s]>bw){bw=v[s];best=s;} a[m]=best; }\n    else if(mv.dest.length){ const c=mv.muniCentroid[m]; let best=mv.dest[0],bd=Infinity;\n      for(const s of mv.dest){ const sc=mv.secCentroid[s]; if(!sc||!c)continue; const d=(c[0]-sc[0])**2+(c[1]-sc[1])**2; if(d<bd){bd=d;best=s;} }\n      a[m]=best; }\n  });\n  return a;\n}\nfunction deslocFor(mv,assign){\n  let d=0;\n  mv.leaders.forEach(L=>{const byS={};let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=assign[m];if(s){byS[s]=(byS[s]||0)+c;tot+=c;}}\n    const home=mv.dest.reduce((a,s)=>(byS[s]||0)>(byS[a]||0)?s:a,mv.dest[0]);\n    d+=tot-(byS[home]||0);});\n  return d;\n}\nfunction spanMapFor(mv,a){const inc={};mv.dest.forEach(s=>inc[s]=0);\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=a[m];if(s&&inc[s]!=null)inc[s]+=o.cbs;});\n  // Orphan CBs follow their leader's home sector, and that home depends on THIS candidate\n  // assignment — so they have to be counted here, or the optimizer would score and constrain a\n  // span that differs from the one the UI shows. Skipped entirely when there are no orphans.\n  if(mv.orphanByGroup&&Object.keys(mv.orphanByGroup).length){\n    mv.leaders.forEach(L=>{\n      const orphan=mv.orphanByGroup[L.grupo]||0; if(!orphan) return;\n      const byS={}; let tot=0;\n      for(const [m,c] of Object.entries(L.byMuni)){ if(m==='__OUTROS__') continue; const s=a[m]; if(s&&inc[s]!=null){ byS[s]=(byS[s]||0)+c; tot+=c; } }\n      if(!tot) return;   // no home to follow -> stranded, counted nowhere\n      const home=mv.dest.reduce((x,s)=>(byS[s]||0)>(byS[x]||0)?s:x,mv.dest[0]);\n      if(inc[home]!=null) inc[home]+=orphan;\n    });\n  }\n  const sp={};mv.dest.forEach(s=>sp[s]=(+(mv.baseMetrics[s]?.cbs||0))+inc[s]);return sp;}\nfunction maxSpanOfFor(mv,a){return Math.max(...Object.values(spanMapFor(mv,a)),0);}\nfunction spanLimits(){const st={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  return {target:numOrNull(st.targetSpan),max:numOrNull(st.maxSpan),min:numOrNull(st.minSpan)};}\nfunction spanLimitsOn(lim){return !!(lim.target||lim.max||lim.min);}\nfunction spanViolationsOf(sp,lim){const over=[],under=[];\n  Object.entries(sp).forEach(([s,v])=>{ if(lim.max&&v>lim.max)over.push(s); if(lim.min&&v<lim.min)under.push(s); });\n  return {over,under};}\nfunction spanViolationsFor(mv,a){return spanViolationsOf(spanMapFor(mv,a),spanLimits());}\n// With a target span the objective becomes RMS distance from the ideal size (punishes too big AND\n// too small). With no target we keep the legacy objective: shave the biggest sector.\nfunction spanCostFor(mv,a){const lim=spanLimits(),vals=Object.values(spanMapFor(mv,a));\n  if(!vals.length) return 0;\n  if(lim.target) return Math.sqrt(vals.reduce((s,v)=>s+(v-lim.target)**2,0)/vals.length);\n  return Math.max(...vals);}\n// ---------- ÓTIMO: objetivo multi-critério ----------\n// Three normalised objectives, each 0..1, so the weights below are directly comparable. The old\n// score added a span cost measured in CBs to a desloc measured in CBs, so on a large sector the\n// balance term was numerically ~100x the desloc term and silently decided everything on its own.\n//   balance — RMS spread of the resulting spans (against the target span when one is set,\n//             otherwise against their own mean), over that reference. 0 = every destination\n//             sector ends up the same size.\n//   dist    — how far each município travels, CB-weighted: half from the share of its outer\n//             border that faces the sector it was given to, half from centroid distance. 0 =\n//             every município went to a sector it physically touches. Adjacency used to be a\n//             pure yes/no constraint (contiguity); this is what makes it a preference too.\n//   orphan  — stranded orphan CBs (leader left without a home sector, so they land nowhere at\n//             all) plus CBs split away from their leader's home, over the CBs at stake.\n//             Stranded ones count OPT_STRAND_K times: a stranded CB leaves the model entirely,\n//             a deslocated one merely changes hands.\nconst OPT_W={balance:1, dist:0.85, orphan:1};\nconst OPT_STRAND_K=3;\nconst OPT_SIMS=24;             // simultaneous simulations per run, scaled down on big stages\nconst OPT_PASSES=14;           // local-search sweeps inside one simulation\nconst OPT_EVAL_BUDGET=120000;  // total candidate evaluations across the whole portfolio\nconst OPT_EXHAUSTIVE_MAX=60000;\nfunction optDistPrep(mv){\n  let mx=0;\n  Object.keys(mv.munis).forEach(m=>{ if(m==='__OUTROS__') return; const c=mv.muniCentroid[m]; if(!c) return;\n    mv.dest.forEach(s=>{ const sc=mv.secCentroid[s]; if(!sc) return;\n      const d=Math.hypot(c[0]-sc[0],c[1]-sc[1]); if(d>mx) mx=d; }); });\n  return mx||1;\n}\nfunction optDistCost(mv,m,s,dmax){\n  const v=mv.allowed[m]||{}; let tot=0; for(const k in v) tot+=v[k];\n  const share=tot?((v[s]||0)/tot):0;\n  const c=mv.muniCentroid[m], sc=mv.secCentroid[s];\n  // Missing centroid on either side is neutral (0.5), never 0 — an unmeasurable move must not\n  // score as the cheapest one on the board.\n  const geo=(c&&sc)?Math.min(1,Math.hypot(c[0]-sc[0],c[1]-sc[1])/dmax):0.5;\n  return 0.5*(1-share)+0.5*geo;\n}\nfunction optEval(mv,a,dmax){\n  const lim=spanLimits();\n  const sp=spanMapFor(mv,a), vals=Object.values(sp);\n  let balance=0;\n  if(vals.length){\n    const ref=lim.target||(vals.reduce((x,y)=>x+y,0)/vals.length);\n    if(ref) balance=Math.min(1,Math.sqrt(vals.reduce((s,v)=>s+(v-ref)**2,0)/vals.length)/ref);\n  }\n  let cbs=0,dist=0;\n  for(const m in a){ const o=mv.munis[m]; if(!o) continue; const w=+o.cbs||0; cbs+=w; dist+=w*optDistCost(mv,m,a[m],dmax); }\n  dist=cbs?(dist/cbs):0;\n  let stranded=0,desloc=0,tot=0;\n  mv.leaders.forEach(L=>{\n    const byS={}; let t=0;\n    for(const [m,c] of Object.entries(L.byMuni)){ if(m==='__OUTROS__') continue; const s=a[m]; if(s){ byS[s]=(byS[s]||0)+c; t+=c; } }\n    const orphan=(mv.orphanByGroup&&mv.orphanByGroup[L.grupo])||0;\n    tot+=t+orphan;\n    if(!t){ stranded+=orphan; return; }   // no allocated CBs -> no home -> these orphans land nowhere\n    const home=mv.dest.reduce((x,s)=>(byS[s]||0)>(byS[x]||0)?s:x,mv.dest[0]);\n    desloc+=t-(byS[home]||0);\n  });\n  const orphanTerm=tot?Math.min(1,(desloc+OPT_STRAND_K*stranded)/tot):0;\n  return { cost:OPT_W.balance*balance+OPT_W.dist*dist+OPT_W.orphan*orphanTerm,\n           balance, dist, desloc, stranded, sp, viol:spanViolationsOf(sp,lim) };\n}\nfunction optScoreFor(mv,a){ return optEval(mv,a,mv.__optDmax||optDistPrep(mv)).cost; }\n// Seeded PRNG on purpose. computeMovement runs the optimizer for EVERY stage and recomputeFrom\n// re-runs every stage downstream of an edit, so a Math.random() portfolio would hand back a\n// different allocation on each pass and a saved timeline would never reproduce.\nfunction optRng(seed){ let a=seed>>>0;\n  return ()=>{ a=(a+0x6D2B79F5)>>>0; let t=Math.imul(a^(a>>>15),1|a);\n    t=(t+Math.imul(t^(t>>>7),61|t))^t; return ((t^(t>>>14))>>>0)/4294967296; }; }\nfunction optSeed(mv,ms){ let h=2166136261>>>0;\n  const s=String(mv.sector)+'|'+ms.join(',')+'|'+mv.dest.join(',');\n  for(let i=0;i<s.length;i++) h=Math.imul(h^s.charCodeAt(i),16777619);\n  return h>>>0; }\nfunction contigBadFor(mv,assign){\n  const bySec={}; for(const m in assign){(bySec[assign[m]]=bySec[assign[m]]||[]).push(m);}\n  const bad=[];\n  for(const s in bySec){const grp=bySec[s],inGrp=new Set(grp);\n    const seeds=grp.filter(m=>(mv.allowed[m]||{})[s]);\n    if(!seeds.length){bad.push(s);continue;}\n    const seen=new Set(seeds),st=[...seeds];\n    while(st.length){const m=st.pop();(mv.closeAdj[m]||[]).forEach(o=>{if(inGrp.has(o)&&!seen.has(o)){seen.add(o);st.push(o);}});}\n    if(seen.size!==grp.length)bad.push(s);}\n  return bad;\n}\nfunction greedyFor(mv,ms,opts){const lim=spanLimits();const a={};\n  ms.forEach((m,i)=>{let best=opts[i][0],bd=Infinity;\n    for(const s of opts[i]){a[m]=s;\n      // over-max only grows as we keep assigning, so it is safe to penalise mid-build;\n      // under-min shrinks, so penalising it here would just chase noise.\n      const over=lim.max?spanViolationsOf(spanMapFor(mv,a),{max:lim.max,min:null}).over.length:0;\n      const d=optScoreFor(mv,a)+(contigBadFor(mv,a).length?1e9:0)+over*1e6;\n      if(d<bd){bd=d;best=s;}}\n    a[m]=best;});\n  return a;}\nfunction optimizeFor(mv){\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__');\n  if(!ms.length||!mv.dest.length){ mv.optBadge=mv.dest.length?'':'sem setor destino identificado'; return {}; }\n  const opts=ms.map(m=>{const al=(mv.reachable[m]&&mv.reachable[m].length)?mv.reachable[m]:mv.dest.slice();return al.length?al:mv.dest.slice();});\n  const combos=opts.reduce((a,o)=>a*o.length,1);\n  const lim=spanLimits();\n  const dmax=optDistPrep(mv); mv.__optDmax=dmax;\n\n  // Feasibility ladder — same meaning as before, because renderSpanRule and the saved sessions\n  // both read mv.optTier: 0 = contíguo + max + min span, 1 = contíguo + max span, 2 = contíguo\n  // only. Every candidate any simulation produces is offered to all three tiers; we ship the\n  // strictest tier that got a solution, so an unreachable min/max relaxes loudly instead of\n  // stranding the stage with nothing.\n  const tier=[null,null,null],tSc=[Infinity,Infinity,Infinity],tEv=[null,null,null];\n  let feasible=0,evals=0;\n  const offer=(a,ev)=>{\n    if(contigBadFor(mv,a).length) return false;\n    feasible++;\n    const put=t=>{ if(ev.cost<tSc[t]){ tSc[t]=ev.cost; tier[t]={...a}; tEv[t]=ev; } };\n    put(2);\n    if(!ev.viol.over.length){ put(1); if(!ev.viol.under.length) put(0); }\n    return true;\n  };\n  const evalOf=a=>{ evals++; return optEval(mv,a,dmax); };\n\n  // Small enough to settle the question outright: enumerate every contiguous allocation and the\n  // winner is provably the best one under this objective. No search needed, so none is run.\n  const exhaustive=combos<=OPT_EXHAUSTIVE_MAX;\n  let sims=0;\n  if(exhaustive){\n    const cur={};\n    const rec=i=>{ if(i===ms.length){ offer(cur,evalOf(cur)); return; }\n      for(const s of opts[i]){ cur[ms[i]]=s; rec(i+1); } };\n    rec(0);\n  } else {\n    // Too big to enumerate, so run a portfolio of independent simulations instead. Each one is a\n    // seed assignment, then hill-climbing on single-município reassignments, then one perturbation\n    // and another climb to escape whatever local optimum it settled into. Different seeds land in\n    // different basins; the ladder above keeps the best across all of them.\n    const improve=a=>{\n      let ev=evalOf(a); offer(a,ev);\n      for(let pass=0;pass<OPT_PASSES;pass++){\n        let moved=false;\n        for(let i=0;i<ms.length;i++){\n          const m=ms[i],cur=a[m]; let best=cur,bev=ev;\n          for(const s of opts[i]){\n            if(s===cur) continue;\n            a[m]=s;\n            const e=evalOf(a);\n            // contiguity is a hard constraint, and the cheaper test runs first on purpose\n            if(e.cost<bev.cost-1e-12 && !contigBadFor(mv,a).length){ best=s; bev=e; }\n          }\n          a[m]=best;\n          if(best!==cur){ ev=bev; moved=true; offer(a,ev); }\n        }\n        if(!moved) break;\n      }\n    };\n    const rng=optRng(optSeed(mv,ms));\n    const perSim=Math.max(1,ms.length*Math.max(1,mv.dest.length)*OPT_PASSES);\n    const budget=Math.max(4,Math.min(OPT_SIMS,Math.floor(OPT_EVAL_BUDGET/perSim)||4));\n    const seeds=[];\n    if(mv.assign&&ms.every(m=>mv.assign[m])) seeds.push({...mv.assign});   // never regress on what is already on screen\n    seeds.push(autoAssignFor(mv));                                        // adjacency-first seed\n    seeds.push(greedyFor(mv,ms,opts));                                    // balance-first seed\n    while(seeds.length<budget){\n      // randomised construction biased toward nearby sectors — the bias keeps each seed sane, the\n      // randomness is what sends the runs into different basins\n      const a={};\n      ms.forEach((m,i)=>{ let bs=opts[i][0],bw=-Infinity;\n        for(const s of opts[i]){ const w=(1-optDistCost(mv,m,s,dmax))+rng()*0.9; if(w>bw){bw=w;bs=s;} }\n        a[m]=bs; });\n      seeds.push(a);\n    }\n    seeds.forEach(seed=>{\n      const a={...seed};\n      // a restored or legacy assign can still name a sector this stage no longer offers\n      ms.forEach((m,i)=>{ if(!opts[i].includes(a[m])) a[m]=opts[i][0]; });\n      improve(a);\n      const kick=Math.max(1,Math.round(ms.length*0.25));\n      for(let k=0;k<kick;k++){ const i=Math.floor(rng()*ms.length); a[ms[i]]=opts[i][Math.floor(rng()*opts[i].length)]; }\n      improve(a);\n    });\n    sims=seeds.length;\n  }\n\n  const t=tier.findIndex(x=>x);\n  if(t<0){ mv.optTier=-1; mv.optBadge='sem solução contígua — heurística'; return greedyFor(mv,ms,opts); }\n  const best=tier[t], ev=tEv[t]; mv.optTier=t;\n  const relax=[];\n  if(t>0 && lim.min) relax.push('span mínimo '+fmt(lim.min));\n  if(t>1 && lim.max) relax.push('span máximo '+fmt(lim.max));\n  mv.optStats={balance:ev.balance,dist:ev.dist,desloc:ev.desloc,stranded:ev.stranded,sims,evals,feasible,combos,exhaustive};\n  mv.optBadge=`ótimo · ${exhaustive?`espaço completo (${fmt(combos)})`:`${fmt(sims)} simulações`}`\n    +` · equilíbrio ±${Math.round(ev.balance*100)}% · adjacência ${Math.round((1-ev.dist)*100)}%`\n    +` · quebra ${fmt(ev.desloc)} CBs · órfãs sem destino ${fmt(ev.stranded)}`\n    +` · pico ${fmt(maxSpanOfFor(mv,best))} CBs`\n    +(lim.target?` · alvo ${fmt(lim.target)}`:'')\n    +(relax.length?` · ⚠ inatingível: ${relax.join(' + ')}`:(spanLimitsOn(lim)?' · limites ok':''));\n  return best;\n}\nfunction computeDerived(mv){\n  const inc={},gained={},orph={};mv.dest.forEach(s=>{inc[s]=0;gained[s]=0;orph[s]=0;});\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(s&&inc[s]!=null)inc[s]+=o.cbs;});\n  let desloc=0,orphanTotal=0,orphanStranded=0;\n  const L2=mv.leaders.map(L=>{const byS={};mv.dest.forEach(s=>byS[s]=0);let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=mv.assign[m];if(s&&byS[s]!=null){byS[s]+=c;tot+=c;}}\n    // no allocated CBs => no home. Picking dest[0] by default would silently dump this leader's\n    // orphan CBs into an arbitrary sector, so we leave it null and report them as stranded.\n    const home=(tot>0&&mv.dest.length)?mv.dest.reduce((a,s)=>byS[s]>byS[a]?s:a,mv.dest[0]):null;\n    const dl=tot-(home?byS[home]:0);desloc+=dl;if(home)gained[home]++;\n    const orphan=(mv.orphanByGroup&&mv.orphanByGroup[L.grupo])||0;\n    orphanTotal+=orphan;\n    if(orphan){ if(home&&orph[home]!=null) orph[home]+=orphan; else orphanStranded+=orphan; }\n    return {...L,byS,home,desloc:dl,assignedTotal:tot,orphan,orphanHome:(orphan&&home)?home:null};});\n  const span={};mv.dest.forEach(s=>span[s]=(+(mv.baseMetrics[s]?.cbs||0))+inc[s]+orph[s]);\n  return {inc,gained,desloc,leaders:L2,span,orph,orphanTotal,orphanStranded};\n}\n\nasync function computeMovement(sec,world){\n  sec=String(sec);\n  const mv={sector:sec,policy:'fora',view:'tobe',selMuni:null,optBadge:''};\n  const SNAP=SESS.snapLiteral;\n  const mSQL=`SELECT DS_MUNICIPIO municipio, CD_GRUPO grupo, MAX(DS_LEADER_NAME) lider,\n     COUNT(DISTINCT CD_CONSULTORA) cbs, ROUND(SUM(VL_PEDIDOS_CICLO),0) receita,\n     ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat, ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon,\n     ROUND(SUM(QT_PONTOS_CICLO),0) pontos, ROUND(SUM(QT_PEDIDOS_CICLO),0) pedidos,\n     SUM(CASE WHEN ST_FEZ_PED_CICLO_ATUAL THEN 1 ELSE 0 END) ativas\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${sec} AND DT_HISTORICO=${SNAP}\n    GROUP BY DS_MUNICIPIO, CD_GRUPO`;\n  const ufSQL=`SELECT DS_ESTADO uf, COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${sec} AND DT_HISTORICO=${SNAP} GROUP BY DS_ESTADO ORDER BY n DESC`;\n  const trendSQL=`SELECT operational_cycle ciclo, COUNT(DISTINCT person_code) cbs,\n     COUNT(DISTINCT CASE WHEN registered_bc_ind=1 THEN person_code END) ativas,\n     ROUND(SUM(revenue_nat_ss_amt),0) receita, ROUND(SUM(points_nat_ss_amt),0) pontos\n    FROM ${ELO} WHERE country_code=1 AND sector_code=${sec} AND operational_cycle<=${SESS.eloMaxCycle||999999} GROUP BY operational_cycle ORDER BY operational_cycle DESC LIMIT 8`;\n  const lvlSQL=`SELECT DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs,\n     ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat, ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${sec} AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY DS_NVL_PLAN_CRESC ORDER BY cbs DESC`;\n  const lvlMuniSQL=`SELECT DS_MUNICIPIO municipio, DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${sec} AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY DS_MUNICIPIO, DS_NVL_PLAN_CRESC`;\n  const [mR,ufR,tR,lR,lmR]=await Promise.all([q(mSQL),q(ufSQL),q(trendSQL),q(lvlSQL),q(lvlMuniSQL)]);\n\n  const ownMatrix=rows(mR).filter(r=>+r.cbs>0);\n  mv.pureOwnMetrics=blankM();\n  ownMatrix.forEach(r=>{ mv.pureOwnMetrics.cbs+=+r.cbs; mv.pureOwnMetrics.receita+=+r.receita||0; mv.pureOwnMetrics.rec_nat+=+r.rec_nat||0; mv.pureOwnMetrics.rec_avon+=+r.rec_avon||0; mv.pureOwnMetrics.pontos+=+r.pontos||0; mv.pureOwnMetrics.pedidos+=+r.pedidos||0; mv.pureOwnMetrics.ativas+=+r.ativas||0; });\n\n  mv.levelByMuni={};\n  Object.entries(world.muniLevel||{}).forEach(([m,lv])=>{ if(world.muniOverride[m]===sec) mv.levelByMuni[m]={...lv}; });\n  // keyed further down, once the UF resolver exists — see the stamp block after buildAdj.\n\n  mv.trend=rows(tR).map(r=>({...r,cbs:+r.cbs,ativas:+r.ativas,receita:+r.receita,pontos:+r.pontos})).filter(r=>r.cbs>10).reverse();\n  mv.levels=rows(lR);\n\n  const absorbedRows=[];\n  Object.entries(world.muniOverride).forEach(([m,destSec])=>{ if(destSec===sec && world.muniRow[m]) absorbedRows.push(...world.muniRow[m]); });\n  // CBs an earlier etapa sent here by FOLLOWING THEIR LEADER (quebra de vínculo) carry no\n  // muniOverride — the município stayed where it was, only the CBs moved — so absorbedRows misses\n  // them. Leaving them out made this sector's ASIS total smaller than the span the previous etapa's\n  // TOBE promised for it, by exactly that etapa's \"CBs ignoradas\", and dropped those CBs from the\n  // redistribution as if they had never existed.\n  const inheritedOrphanRows=(world.sectorOrphanRows&&world.sectorOrphanRows[sec])||[];\n  mv.matrix=ownMatrix.concat(absorbedRows,inheritedOrphanRows);\n\n  const ufList=rows(ufR).map(r=>r.uf).filter(Boolean);\n  const domUF=ufList[0]||'PR';\n  mv.domUF=domUF;\n  const ufs=[domUF,...(UF_BORDERS[domUF]||[])];\n  const geos=await Promise.all(ufs.map(u=>geoUF(u).catch(()=>null)));\n  const allFeat=geos.filter(Boolean).flatMap(g=>g.features);\n  const adj=buildAdj(allFeat);\n\n  // ---- UF resolution ----\n  // SQL no longer selects DS_ESTADO. The base CSV is the authority on a município's UF, indexed by the\n  // (setor, município) pair it already maintains, and rows are stamped ONCE here so every consumer\n  // downstream keeps reading r.uf exactly as it did before.\n  // Fallback for a município the base does not carry — spillover, where the sector has CBs but no\n  // dominance: adopt the UF of the loaded shape when that name is unique across the UFs in this\n  // window. A name shared by two of them stays unresolved instead of guessed; it is spillover either\n  // way, and a guess would put a shape from the wrong state on the map.\n  const featUfByName={};\n  Object.keys(adj.feat).forEach(k=>{ const nm=mName(k),u=mUf(k);\n    if(!(nm in featUfByName)) featUfByName[nm]=u; else if(featUfByName[nm]!==u) featUfByName[nm]=null; });\n  const ufGuessed={};\n  const ufFor=(setorOf,muni)=>{const nm=norm(muni);\n    const fromCsv=DOM.ufBySetorMuni&&DOM.ufBySetorMuni[String(setorOf)+'|'+nm];\n    if(fromCsv) return fromCsv;\n    const uniq=featUfByName[nm];\n    if(uniq) return uniq;\n    // uniq===null means the name is shared by two of the LOADED UFs — Terra Nova exists in BA and in PE,\n    // and a BA sector loads PE as a border — so the unique-name fallback gave up. Giving up here too\n    // yields a bare NAME key, which matches NO shape: the município leaves the map without a word. The\n    // sector's own dominant UF is a real signal rather than a coin flip: when a shape of this name\n    // exists in domUF, that is the one this sector means. Recorded so the inference is never invisible.\n    if(uniq===null && adj.feat[mkey(nm,domUF)]){ ufGuessed[nm]=domUF; return domUF; }\n    return '';};\n  // Absorbed and orphan rows inherited from an earlier etapa were stamped when THAT etapa ran, under\n  // the setor they belonged to then; `if(!r.uf)` keeps that original resolution intact.\n  mv.matrix.forEach(r=>{ if(!r.uf) r.uf=ufFor(sec,r.municipio); });\n  rows(lmR).forEach(r=>{ const k=mkey(r.municipio,ufFor(sec,r.municipio)); (mv.levelByMuni[k]=mv.levelByMuni[k]||{})[r.nivel]=+r.cbs; });\n\n  const totBy={}; mv.matrix.forEach(r=>{const n=mkey(r.municipio,r.uf); totBy[n]=(totBy[n]||0)+ +r.cbs;});\n  const footprint=new Set(Object.entries(totBy).filter(([,c])=>c>0).map(([m])=>m));\n  const withShape=[...footprint].filter(n=>adj.feat[n]||world.muniGeo[n]);\n  mv.cbsByMuni={...totBy}; mv.shapeMunis=withShape.slice();\n  mv.noShapeMunis=[...footprint].filter(n=>!(adj.feat[n]||world.muniGeo[n])).map(n=>({muni:n,cbs:totBy[n]||0})).sort((a,b)=>b.cbs-a.cbs);\n  mv.noShapeCbs=mv.noShapeMunis.reduce((a,x)=>a+x.cbs,0);\n\n  // ---- RULE: which municipalities belong to this sector ----\n  // Comes from the dominantes CSV (setor x município), NOT from CB volume. A municipality where the\n  // closing sector happens to have a handful of CBs but which the base assigns to ANOTHER sector is\n  // spillover: it is not reallocated, it is not drawn, and its CBs follow their leader (quebra de\n  // vínculo). Municipalities an EARLIER etapa moved into this sector count as belonging to it too —\n  // otherwise the cascade would throw away the territory the sector just received.\n  const domBase=await ensureDominantes();\n  const domOwn=domBase[String(sec)];\n  const inherited=new Set(Object.keys(world.muniOverride).filter(m=>world.muniOverride[m]===sec));\n  if((!domOwn||!domOwn.size)&&!inherited.size) throw new Error('setor '+sec+' não existe na base de municípios dominantes (CSV) — atualize a base antes de fechar este setor');\n  const belongs=n=>(domOwn&&(domOwn.has(n)||(!DOM.hasUf&&domOwn.has(mName(n)))))||inherited.has(n);\n  mv.domCsvMunis=(domOwn&&domOwn.size)||0;\n  const dropped=withShape.filter(n=>!belongs(n));\n  if(withShape.length && dropped.length===withShape.length) throw new Error('setor '+sec+': nenhum dos municípios com CBs está na base de dominantes — nada pra realocar');\n  const droppedSet=new Set(dropped);\n  mv.excludedMunis=dropped.map(n=>({muni:n,cbs:totBy[n]||0})).sort((a,b)=>b.cbs-a.cbs);\n  mv.excludedCbs=dropped.reduce((a,n)=>a+(totBy[n]||0),0);\n\n  const closingNames=withShape.filter(n=>!droppedSet.has(n));\n  const closingSet=new Set(closingNames);\n  mv.geoFeat={}; closingNames.forEach(n=>{ mv.geoFeat[n]=(adj.feat[n]&&adj.feat[n].f)||world.muniGeo[n]; });\n\n  // ---- why is the map short of the base? ----\n  // Three gates drop a município between the CSV and the map: footprint (no CBs under this setor at\n  // the snapshot), withShape (no geometry in the loaded UFs) and belongs (key mismatch). Only the last\n  // two had a counter, so the FIRST one was invisible — the operator counted 5 shapes against a base\n  // that says 6 and had nothing on screen explaining the missing one. Named here, with its reason.\n  const drawnCsv=new Set(closingNames.map(n=>DOM.hasUf?n:mName(n)));\n  const footKeyOf=k=>DOM.hasUf?(footprint.has(k)?k:null):([...footprint].find(x=>mName(x)===k)||null);\n  mv.csvMunisTotal=(domOwn&&domOwn.size)||0;\n  mv.ufGuessed={...ufGuessed};\n  mv.missingMunis=[...(domOwn||[])].filter(k=>!drawnCsv.has(k)).map(k=>{\n    const fk=footKeyOf(k);\n    const hasShape=!!(adj.feat[fk||k]||world.muniGeo[fk||k]);\n    // The SAME name present in footprint under a DIFFERENT key means UF resolution disagreed with the\n    // base: the CBs are there, they just landed on a key that no shape and no dominance entry can\n    // match. That is a different failure from \"this município has no CBs\", and conflating the two sends\n    // the next investigation into the CB base when the answer is in the UF column.\n    const alias=[...footprint].filter(x=>x!==k&&mName(x)===mName(k));\n    let reason;\n    if(!fk&&alias.length) reason=`UF divergente — as CBs entraram na chave ${alias.join(' / ')} e a base diz ${k}`;\n    else if(!fk) reason=hasShape?'sem CBs neste setor no snapshot — desenhado como vizinho':'sem CBs neste setor no snapshot';\n    else if(!hasShape) reason='sem shape nas UFs carregadas';\n    else reason='chave não casou com a base de dominantes';\n    return {muni:k,cbs:(fk?totBy[fk]:alias.reduce((a,x)=>a+(totBy[x]||0),0))||0,reason:reason};\n  });\n\n  const extVotes={}; const nbrNames=new Set();\n  closingNames.forEach(n=>{\n    if(!adj.feat[n]) return;\n    // droppedSet is excluded too: a municipality outside this sector's dominance base must not\n    // nominate a destination sector, and it is not drawn on the map at all.\n    adj.nbrs(n).forEach(({o,w})=>{ if(!closingSet.has(o) && !droppedSet.has(o)){ nbrNames.add(o); (extVotes[n]=extVotes[n]||[]).push({o,w}); } });\n  });\n  // mv.nbrFeat is assembled far below, once mv.dest is final: the map draws only the closing sector's\n  // municípios and the destination sectors' territory, so the feature list cannot be built until the\n  // neighbour query has resolved each neighbour's sector AND the destination list has settled.\n  let neigh=[];\n  if(nbrNames.size){\n    // The IN-list carries bare names — that is what the column holds, and the query is back to not\n    // selecting DS_ESTADO. Every returned row names its own CD_SETOR, which is exactly the key the\n    // base CSV is indexed by, so each row's UF is resolved from the base right after the fetch.\n    const inList=[...new Set([...nbrNames].map(n=>mName(n)))].map(n=>`'${n.replace(/'/g,\"''\")}'`).join(',');\n    const nSQL=`SELECT DS_MUNICIPIO municipio, CD_SETOR setor, COUNT(DISTINCT CD_CONSULTORA) cbs\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR<>${sec} AND CD_SETOR>0 AND DT_HISTORICO=${SNAP} AND DS_MUNICIPIO IN (${inList}) GROUP BY DS_MUNICIPIO, CD_SETOR`;\n    neigh=rows(await q(nSQL));\n  }\n  const domSecOf={};\n  neigh.forEach(r=>{ if(!r.uf) r.uf=ufFor(r.setor,r.municipio); });\n  const byM={}; neigh.forEach(r=>{const m=mkey(r.municipio,r.uf); if(!nbrNames.has(m)) return; (byM[m]=byM[m]||[]).push(r);});\n  Object.entries(byM).forEach(([m,rs])=>{ rs.sort((a,b)=>+b.cbs-+a.cbs); domSecOf[m]=String(rs[0].setor); });\n  nbrNames.forEach(n=>{ if(world.muniOverride[n]) domSecOf[n]=world.muniOverride[n]; });\n  mv.nbrCurrentSector=domSecOf;\n\n  // ---- HARD RULE: which sectors are eligible as destinations ----\n  // 'any'      = a sector qualifies if it borders ANY municipality of the closing sector (legacy default)\n  // 'dominant' = only municipalities with >= destMinCbs CBs act as a bridge; smaller ones are still\n  //              redistributed, they just cannot pull a new sector into the destination list.\n  //\n  // ---- HARD RULE (não negociável): destino tem de ser da MESMA gerência de vendas ----\n  // Território não atravessa fronteira de GV num fechamento real: outra gerência é outra gestora,\n  // outra meta e outro P&L, então oferecer um setor de fora produz um plano que ninguém pode\n  // executar. CD_GV da base de dominantes é a autoridade, e o filtro roda ANTES de qualquer peso,\n  // regra de ponte ou corte de top-6 — nenhum caminho a jusante pode reintroduzir um setor de fora.\n  const gvBySetor=DOM.gvBySetor||{};\n  const gvOf=s=>gvBySetor[String(s)]||'';\n  const ownGv=gvOf(sec);\n  if(!ownGv) throw new Error('setor '+sec+' não tem CD_GV na base de municípios dominantes — sem a gerência de vendas do setor que fecha não há como garantir que os destinos sejam da mesma gerência');\n  // Um candidato que a base não carrega NÃO pode ser provado como da mesma gerência, então é recusado\n  // em vez de presumido: a regra só vale se nunca vazar.\n  const sameGv=s=>gvOf(s)===ownGv;\n  mv.gv=ownGv;\n  // Foreign-GV neighbours are not drawn AT ALL. Filtering them only out of the destination list still\n  // left them sitting on the map as context, which invites the exact question the rule exists to\n  // settle — \"why is 3471 right there if nothing can be sent to it?\". A neighbour whose sector is\n  // UNKNOWN is not being asserted to be foreign, so it stays as plain geography.\n  // Dropping them also tightens the frame: project() fits the bounding box of whatever survives, so the\n  // closing sector is drawn larger once foreign territory stops padding the extent.\n  mv.gvRejected=[];\n  const gvHidden=new Set();\n  nbrNames.forEach(n=>{ const s=domSecOf[n];\n    if(s && !sameGv(s)){ gvHidden.add(n); if(mv.gvRejected.indexOf(s)<0) mv.gvRejected.push(s); } });\n  gvHidden.forEach(n=>{ nbrNames.delete(n); delete domSecOf[n]; });\n  Object.keys(extVotes).forEach(n=>{ extVotes[n]=extVotes[n].filter(v=>!gvHidden.has(v.o)); });\n  const destRule=(SESS.settings&&SESS.settings.destRule)||DEFAULT_SETTINGS.destRule;\n  const destMinCbs=Math.max(1,Math.round(+((SESS.settings||{}).destMinCbs)||DEFAULT_SETTINGS.destMinCbs));\n  mv.destRule=destRule; mv.destMinCbs=destMinCbs; mv.destRuleFallback=false;\n  const isBridge=n=>destRule!=='dominant'||(totBy[n]||0)>=destMinCbs;\n  mv.bridgeMunis=closingNames.filter(isBridge);\n  mv.ignoredBridges=closingNames.filter(n=>!isBridge(n));\n\n  mv.allowed={}; mv.allowedRaw={};\n  const wAny={}, wDom={};\n  closingNames.forEach(n=>{\n    const v={}, bridge=isBridge(n);\n    (extVotes[n]||[]).forEach(({o,w})=>{\n      const s=domSecOf[o];\n      if(!s || s===sec || world.closedSectors.has(s)) return;\n      // Belt to the braces above: domSecOf has already had every foreign-GV neighbour removed, so this\n      // cannot fire any more. It stays because it costs one comparison and the failure it guards — a\n      // destination in the wrong gerência — is the whole reason this code exists.\n      if(!sameGv(s)) return;\n      v[s]=(v[s]||0)+w;\n      wAny[s]=(wAny[s]||0)+w;\n      if(bridge) wDom[s]=(wDom[s]||0)+w;\n    });\n    mv.allowed[n]=v; mv.allowedRaw[n]={...v};\n  });\n  let secWeight=wAny;\n  if(destRule==='dominant'){\n    if(Object.keys(wDom).length) secWeight=wDom;\n    else { secWeight=wAny; mv.destRuleFallback=true; }   // no dominant municipality at all -> relax, never strand the stage\n  }\n  mv.dest=Object.keys(secWeight).sort((a,b)=>secWeight[b]-secWeight[a]).slice(0,6);\n  if(!mv.dest.length){\n    // O fallback de emergência lê domSecOf direto, contornando os votos — então repete o filtro de GV\n    // aqui. Sem isso a regra vazaria exatamente no caso em que ninguém está olhando.\n    mv.dest=[...new Set(Object.values(domSecOf))].filter(s=>s!==sec&&!world.closedSectors.has(s)&&sameGv(s)).slice(0,6);\n    closingNames.forEach(n=>{ if(!Object.keys(mv.allowed[n]).length){ mv.dest.forEach(s=>mv.allowed[n][s]=1); } });\n  }\n  // Cinto e suspensório: qualquer rota que tenha preenchido mv.dest (pesos, fallback, um branch novo\n  // amanhã) passa por esta linha, e nada de fora da gerência do setor que fecha sobrevive a ela.\n  mv.dest=mv.dest.filter(sameGv);\n  // WHAT GETS DRAWN — decided here because it depends on the final destination list. The closing\n  // sector's municípios draw as before. For each destination sector we now draw its WHOLE municipality\n  // set, not just the municípios that happened to border the closing sector — sourced from the SAME\n  // dominantes CSV (domBase) already used above for the closing sector's own municípios, never from\n  // CD_SETOR in the CRM table: that code is not a stable key there (reused across unrelated setores),\n  // so pulling by it drew scattered territory from the wrong parts of the country. The adjacency query\n  // above still decides WHICH sectors qualify as destinations (that vote is unchanged); this only\n  // widens what gets drawn once that list is settled, so a destination sector renders as its real CSV\n  // territory instead of a rim of border municípios.\n  // nbrCurrentSector deliberately keeps its full mapping: drawMap groups whatever survives in nbrFeat\n  // by it.\n  const drawSet=new Set(mv.dest);\n  mv.dest.forEach(s=>{\n    (domBase[String(s)]||new Set()).forEach(n=>{\n      if(nbrNames.has(n)) return;\n      nbrNames.add(n); domSecOf[n]=world.muniOverride[n]||s;\n    });\n  });\n  mv.nbrFeat=[...nbrNames].filter(n=>drawSet.has(domSecOf[n])).map(n=>adj.feat[n]&&adj.feat[n].f).filter(Boolean);\n  // votes may reference a sector that did not survive the rule / top-6 cut — drop them so\n  // autoAssign + contiguity seeds can never point at a sector outside mv.dest\n  closingNames.forEach(n=>{ Object.keys(mv.allowed[n]||{}).forEach(s=>{ if(!mv.dest.includes(s)) delete mv.allowed[n][s]; }); });\n  mv.color={}; mv.dest.forEach((s,i)=>mv.color[s]=PALETTE[i%PALETTE.length]);\n\n  const accC={}; Object.entries(domSecOf).forEach(([nb,s])=>{ if(!mv.dest.includes(s))return; const f=(adj.feat[nb]&&adj.feat[nb].f)||world.muniGeo[nb]; if(!f)return; const c=centroidLL(f); accC[s]=accC[s]||[0,0,0]; accC[s][0]+=c[0]; accC[s][1]+=c[1]; accC[s][2]++; });\n  mv.secCentroid={}; mv.dest.forEach(s=>{ if(accC[s]) mv.secCentroid[s]=[accC[s][0]/accC[s][2],accC[s][1]/accC[s][2]]; });\n  mv.muniCentroid={}; closingNames.forEach(n=>{ if(mv.geoFeat[n]) mv.muniCentroid[n]=centroidLL(mv.geoFeat[n]); });\n  mv.closeAdj={}; closingNames.forEach(n=>{ mv.closeAdj[n]=adj.feat[n]?adj.nbrs(n).filter(({o})=>closingSet.has(o)).map(({o})=>o):[]; });\n  const comp={}; let ci=0; closingNames.forEach(n=>{ if(comp[n]!=null)return; const st=[n]; comp[n]=ci; while(st.length){ const x=st.pop(); (mv.closeAdj[x]||[]).forEach(o=>{ if(comp[o]==null){ comp[o]=ci; st.push(o); } }); } ci++; });\n  const compAllowed={}; closingNames.forEach(n=>{ const cid=comp[n]; (compAllowed[cid]=compAllowed[cid]||new Set()); Object.keys(mv.allowed[n]||{}).forEach(s=>compAllowed[cid].add(s)); });\n  mv.reachable={}; closingNames.forEach(n=>{ mv.reachable[n]=[...(compAllowed[comp[n]]||[])].filter(s=>mv.dest.includes(s)); });\n\n  if(mv.dest.length){\n    const rList=mv.dest.join(',');\n    const bSQL=`SELECT CD_SETOR setor, COUNT(DISTINCT CD_CONSULTORA) cbs,\n       SUM(CASE WHEN ST_FEZ_PED_CICLO_ATUAL THEN 1 ELSE 0 END) ativas,\n       ROUND(SUM(VL_PEDIDOS_CICLO),0) receita, ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat,\n       ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon, ROUND(SUM(QT_PONTOS_CICLO),0) pontos, ROUND(SUM(QT_PEDIDOS_CICLO),0) pedidos\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${rList}) AND DT_HISTORICO=${SNAP} GROUP BY CD_SETOR`;\n    const blSQL=`SELECT CD_SETOR setor, DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${rList}) AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY CD_SETOR, DS_NVL_PLAN_CRESC`;\n    const [bR,blR]=await Promise.all([q(bSQL),q(blSQL)]);\n    mv.baseMetrics={}; rows(bR).forEach(r=>{ mv.baseMetrics[String(r.setor)]=r; if(!SESS.sectorBaseCache[String(r.setor)]) SESS.sectorBaseCache[String(r.setor)]=r; });\n    mv.baseLevel={}; rows(blR).forEach(r=>{ const s=String(r.setor); (mv.baseLevel[s]=mv.baseLevel[s]||{})[r.nivel]=+r.cbs; });\n  } else { mv.baseMetrics={}; mv.baseLevel={}; }\n\n  mv.dest.forEach(s=>{\n    if(!mv.baseMetrics[s]) mv.baseMetrics[s]={setor:s,cbs:0,ativas:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0};\n    const infl=world.sectorInflow[s];\n    if(infl) Object.keys(blankM()).forEach(k=>{ mv.baseMetrics[s][k]=(+mv.baseMetrics[s][k]||0)+(infl[k]||0); });\n    const inflLv=world.sectorInflowLevel[s];\n    if(inflLv){ mv.baseLevel[s]=mv.baseLevel[s]||{}; Object.entries(inflLv).forEach(([nv,c])=>{ mv.baseLevel[s][nv]=(mv.baseLevel[s][nv]||0)+c; }); }\n  });\n\n  aggregateMovement(mv);\n  mv.assign=optimizeFor(mv);\n  return mv;\n}\n\n// ---------- cascade engine ----------\nasync function computeAt(i){\n  await ensurePinned();\n  const world=foldWorldState(SESS.timeline,i);\n  const sec=SESS.timeline[i].sector;\n  const mv=await computeMovement(sec,world);\n  SESS.timeline[i]=mv;\n}\nasync function recomputeFrom(i){\n  for(let k=i;k<SESS.timeline.length;k++){\n    loader(true,`recalculando etapa ${k+1} de ${SESS.timeline.length}…`);\n    try{ await computeAt(k); }\n    catch(e){\n      // A stage that failed to recompute still holds its PREVIOUS result, so the timeline would\n      // silently mix pre- and post-change etapas. Flag this stage and everything after it as stale\n      // (dashed chip + ⟳) so the state is visible and flushDirty retries from here.\n      SESS.dirtyFrom = SESS.dirtyFrom==null ? k : Math.min(SESS.dirtyFrom,k);\n      throw e;\n    }\n  }\n}\nasync function flushDirty(){\n  if(SESS.dirtyFrom==null) return;\n  const from=SESS.dirtyFrom; SESS.dirtyFrom=null;\n  loader(true,'recalculando etapas seguintes…');\n  try{ await recomputeFrom(from); }\n  catch(e){ $('#ds').textContent='erro ao recalcular cadeia: '+e.message; $('#ds').classList.add('err'); }\n  finally{ loader(false); }\n}\nasync function addMovement(sec){\n  sec=(sec==null?'':String(sec)).trim();\n  if(!sec) return;\n  const closedNow=new Set(SESS.timeline.map(mv=>mv.sector));\n  if(closedNow.has(sec)){ $('#ds').textContent='setor '+sec+' já fechado nesta timeline.'; $('#ds').classList.add('err'); return; }\n  if(!SESS.sectorsList.some(s=>s.setor===sec)){ $('#ds').textContent='setor \"'+sec+'\" não existe no snapshot congelado.'; $('#ds').classList.add('err'); return; }\n  await flushDirty();\n  loader(true,'fechando setor '+sec+' — puxando dado do snapshot congelado…');\n  $('#liveState').classList.remove('err');\n  try{\n    SESS.timeline.push({sector:String(sec)});\n    await computeAt(SESS.timeline.length-1);\n    SESS.selected=SESS.timeline.length-1;\n    $('#ds').classList.remove('err'); $('#ds').textContent='';\n  }catch(e){\n    SESS.timeline.pop();\n    $('#ds').textContent='erro ao fechar setor '+sec+': '+e.message; $('#ds').classList.add('err');\n  } finally { loader(false); renderAll(); }\n}\nfunction removeFrom(i){\n  SESS.timeline.length=i;\n  SESS.selected=Math.min(SESS.selected,Math.max(0,SESS.timeline.length-1));\n  if(SESS.dirtyFrom!=null && SESS.dirtyFrom>=SESS.timeline.length) SESS.dirtyFrom=null;\n  renderAll();\n}\nasync function editSelected(mutator){\n  const mv=SESS.timeline[SESS.selected]; if(!mv) return;\n  mutator(mv);\n  if(SESS.selected<SESS.timeline.length-1){\n    SESS.dirtyFrom = SESS.dirtyFrom==null ? SESS.selected+1 : Math.min(SESS.dirtyFrom, SESS.selected+1);\n  }\n  renderAll();\n}\n\n// ---------- sectors list / picker ----------\nasync function loadSectors(){\n  const sql=`SELECT CD_SETOR setor, MAX(DS_ESTADO) uf, COUNT(DISTINCT CD_CONSULTORA) cbs, COUNT(DISTINCT DS_MUNICIPIO) munis\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR>0 AND DT_HISTORICO=(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1) GROUP BY CD_SETOR ORDER BY CD_SETOR`;\n  SESS.sectorsList=rows(await q(sql)).map(r=>({setor:String(r.setor),uf:r.uf,cbs:+r.cbs,munis:+r.munis}));\n}\nfunction renderSectorPicker(){\n  const closed=new Set(SESS.timeline.map(mv=>mv.sector));\n  const avail=SESS.sectorsList.filter(s=>!closed.has(s.setor));\n  const dl=$('#sectorPickList');\n  if(dl) dl.innerHTML=avail.map(s=>`<option value=\"${s.setor}\">${s.setor} · ${s.uf||'?'} · ${fmt(s.cbs)} CBs · ${s.munis} munic.</option>`).join('');\n  const ready=SESS.stage==='main';\n  $('#rand').disabled=!ready||!avail.length; $('#go').disabled=!ready||!avail.length; $('#sectorPick').disabled=!ready;\n}\nfunction renderStage(){\n  const isMain=SESS.stage==='main';\n  const cc=$('#confirmCard'), hb=$('#howtoBox'), mw=$('#mainWrap');\n  const hdr=$('#topHeader'), rl=$('#timelineRail');\n  if(hdr) hdr.classList.toggle('hidden', !isMain);\n  if(rl) rl.classList.toggle('hidden', !isMain);\n  if(cc) cc.classList.toggle('hidden', isMain);\n  if(hb) hb.classList.toggle('hidden', !isMain);\n  if(mw) mw.classList.toggle('hidden', !isMain);\n  const cb=$('#confirmBody');\n  if(cb && !isMain) cb.innerHTML = SESS.snapshotRaw\n    ? `Snapshot congelado · atualizado em <b>${fmtSyncedAt(SESS.snapshotSyncedAt)}</b> · <b>${SESS.sectorsList.length}</b> setor(es) disponível(eis) pra fechar. Nenhuma consulta de detalhe roda até você confirmar e escolher o primeiro setor.`\n    : 'congelando snapshot…';\n}\n\n// ---------- session persistence ----------\n// ONE table (`timeline-shared-sessions`) holds BOTH kinds of row, so SESSIONS_DS and SHARED_DS\n// deliberately resolve to the SAME DataSet — the two names mark intent, not two stores:\n//   · working session  — carries `movements`; saveSession updates it in place\n//   · shared snapshot  — carries `sharedAt` + `stages`; doShare CREATES it and nothing updates it\n// listSessions MUST exclude the shared rows. They are write-once by contract, and if one showed\n// up in the picker, loading it and pressing Salvar would fire ds.update() straight at a frozen\n// snapshot. Filtering here is what keeps that unreachable — it is the guard, not a cosmetic.\n// Both lookups stay because a policy-gated DataSet's Process resolves late; same race the reader\n// component polls for. SESSIONS_DS lives at module scope so no call site can hit it in the TDZ.\nasync function listSessions(){\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS)); if(!ds) return [];\n  await ds.refresh();\n  const recs=ds.records()||[];\n  return [...recs].filter(r=>!r.sharedAt).sort((a,b)=>new Date(b.updatedAt||0)-new Date(a.updatedAt||0));\n}\nasync function refreshSessionPicker(){\n  const list=await listSessions().catch(()=>[]);\n  SESS.sessionsList=list;\n  const opts=list.map(s=>`<option value=\"${s._id}\">${esc(s.label||'sem nome')} · ${fmt((s.movements||[]).length)} etapa(s)</option>`).join('')+`<option value=\"\">+ Nova sessão</option>`;\n  const sel=$('#sessionPick'); if(sel){ sel.innerHTML=opts; sel.value=SESS.sessionId||''; }\n  const csel=$('#confirmSessionPick'); if(csel){ csel.innerHTML=opts; csel.value=SESS.sessionId||''; }\n}\nasync function saveSession(){\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS));\n  if(!ds){ $('#sessStatus').textContent='dataset de sessões indisponível'; return; }\n  if(!SESS.timeline.length){ $('#sessStatus').textContent='nada pra salvar ainda'; return; }\n  await flushDirty();\n  const label=SESS.sessionLabel||('Sessão '+new Date().toLocaleString('pt-BR'));\n  const nowIso=new Date().toISOString();\n  const payload={\n    label,\n    settings:{...DEFAULT_SETTINGS,...(SESS.settings||{})},\n    snapshotRaw:SESS.snapshotRaw, snapshotSyncedAt:SESS.snapshotSyncedAt, snapLiteral:SESS.snapLiteral, eloMaxCycle:SESS.eloMaxCycle,\n    movements:SESS.timeline.map(mv=>({\n      sector:mv.sector, assign:mv.assign, policy:mv.policy,\n      matrix:mv.matrix, trend:mv.trend, levels:mv.levels, levelByMuni:mv.levelByMuni,\n      pureOwnMetrics:mv.pureOwnMetrics, domUF:mv.domUF, dest:mv.dest, color:mv.color,\n      gv:mv.gv, gvRejected:mv.gvRejected,\n      missingMunis:mv.missingMunis, csvMunisTotal:mv.csvMunisTotal, ufGuessed:mv.ufGuessed,\n      baseMetrics:mv.baseMetrics, baseLevel:mv.baseLevel, geoFeat:mv.geoFeat, nbrFeat:mv.nbrFeat,\n      nbrCurrentSector:mv.nbrCurrentSector, allowed:mv.allowed, allowedRaw:mv.allowedRaw, reachable:mv.reachable,\n      destRule:mv.destRule, destMinCbs:mv.destMinCbs, destRuleFallback:mv.destRuleFallback, optTier:mv.optTier,\n      cbsByMuni:mv.cbsByMuni, shapeMunis:mv.shapeMunis, excludedMunis:mv.excludedMunis, domCsvMunis:mv.domCsvMunis,\n      excludedCbs:mv.excludedCbs, noShapeMunis:mv.noShapeMunis, noShapeCbs:mv.noShapeCbs,\n      secCentroid:mv.secCentroid, muniCentroid:mv.muniCentroid, closeAdj:mv.closeAdj, optBadge:mv.optBadge,\n    })),\n    updatedAt:nowIso,\n  };\n  $('#sessStatus').textContent='salvando…';\n  try{\n    if(SESS.sessionId){\n      await ds.update({_id:SESS.sessionId,...payload});\n    } else {\n      payload.createdAt=nowIso;\n      const created=await ds.create(payload);\n      SESS.sessionId=(created&&created._id)||(created&&created.data&&created.data._id)||(ds.selected&&ds.selected()&&ds.selected()._id)||null;\n    }\n    SESS.sessionLabel=label;\n    await refreshSessionPicker();\n    $('#sessStatus').textContent='salvo ✓';\n  }catch(e){ $('#sessStatus').textContent='erro ao salvar: '+e.message; }\n  setTimeout(()=>{ if($('#sessStatus')) $('#sessStatus').textContent=''; },2200);\n}\n// Duplicate = drop sessionId so saveSession takes its CREATE branch instead of its update branch.\n// The source record is never written to, the copy gets a fresh _id and its own createdAt, and the\n// copy becomes the active session (so the next Salvar edits the copy, not the original).\nasync function duplicateSession(){\n  if(!SESS.timeline.length){ $('#sessStatus').textContent='nada pra duplicar ainda'; return; }\n  const prevId=SESS.sessionId, prevLabel=SESS.sessionLabel;\n  SESS.sessionId=null;\n  SESS.sessionLabel=(SESS.sessionLabel||'Sessão')+' (cópia)';\n  await saveSession();\n  if(!SESS.sessionId){\n    // saveSession swallows its own errors into #sessStatus, so a still-null id after the await is\n    // the only failure signal we get. Put the original identity back — otherwise a failed copy\n    // silently leaves the session detached, and the next Salvar would fork a new record instead\n    // of updating the one the user thought they were editing.\n    SESS.sessionId=prevId; SESS.sessionLabel=prevLabel;\n    await refreshSessionPicker().catch(()=>{});\n    return;\n  }\n  if($('#sessStatus')) $('#sessStatus').textContent='duplicado ✓';\n}\nasync function loadSession(id){\n  if(!id) return newSession();\n  const list=(SESS.sessionsList&&SESS.sessionsList.length)?SESS.sessionsList:await listSessions().catch(()=>[]);\n  const rec=list.find(s=>String(s._id)===String(id));\n  if(!rec) return newSession();\n  loader(true,'carregando sessão…');\n  SESS.sessionId=rec._id; SESS.sessionLabel=rec.label;\n  SESS.snapshotRaw=rec.snapshotRaw; SESS.snapshotSyncedAt=rec.snapshotSyncedAt||null; SESS.snapLiteral=rec.snapLiteral; SESS.eloMaxCycle=rec.eloMaxCycle;\n  SESS.settings={...DEFAULT_SETTINGS,...(rec.settings||{})};\n  SESS.timeline=(rec.movements||[]).map(m=>({sector:String(m.sector)}));\n  SESS.selected=0; SESS.sectorBaseCache={}; SESS.stage='main'; SESS.dirtyFrom=null;\n  try{\n    for(let i=0;i<SESS.timeline.length;i++){\n      const saved=(rec.movements||[])[i]||{};\n      // Sessions saved before the UF became part of the key hold bare-name keys in matrix rows,\n      // assign, geoFeat and closeAdj alike. Restoring that cache would mix key shapes the moment the\n      // stage is recomputed — and it would preserve the very collision this fixes — so those stages\n      // are replayed against the pinned snapshot and the manual assignment is remapped by name.\n      const legacyKeys=!!(saved.matrix&&saved.matrix.length&&saved.matrix.every(r=>r&&r.uf==null));\n      // Sessões salvas antes da regra de gerência podem carregar um destino de outra GV no cache.\n      // Restaurar esse cache manteria o setor proibido na tela para sempre, então essas etapas são\n      // reprocessadas contra o snapshot fixado — igual ao tratamento das chaves legadas.\n      const crossGv=await savedCrossesGv(saved);\n      if(saved.matrix&&!legacyKeys&&!crossGv){\n        loader(true,`restaurando etapa ${i+1}/${SESS.timeline.length} — setor ${SESS.timeline[i].sector} (cache, sem consulta)…`);\n        const mv={sector:String(saved.sector), policy:saved.policy||'fora', view:'tobe', selMuni:null, optBadge:saved.optBadge||''};\n        mv.matrix=saved.matrix; mv.trend=saved.trend||[]; mv.levels=saved.levels||[]; mv.levelByMuni=saved.levelByMuni||{};\n        mv.pureOwnMetrics=saved.pureOwnMetrics||blankM(); mv.domUF=saved.domUF; mv.dest=saved.dest||[]; mv.color=saved.color||{};\n        mv.baseMetrics=saved.baseMetrics||{}; mv.baseLevel=saved.baseLevel||{}; mv.geoFeat=saved.geoFeat||{}; mv.nbrFeat=saved.nbrFeat||[];\n        mv.nbrCurrentSector=saved.nbrCurrentSector||{}; mv.allowed=saved.allowed||{}; mv.reachable=saved.reachable||{};\n        mv.allowedRaw=saved.allowedRaw||saved.allowed||{};\n        mv.destRule=saved.destRule||SESS.settings.destRule; mv.destMinCbs=saved.destMinCbs||SESS.settings.destMinCbs; mv.destRuleFallback=!!saved.destRuleFallback;\n        mv.optTier=(saved.optTier==null?0:saved.optTier);\n        mv.gv=saved.gv||''; mv.gvRejected=saved.gvRejected||[];\n        mv.missingMunis=saved.missingMunis||[]; mv.csvMunisTotal=saved.csvMunisTotal||0;\n        mv.ufGuessed=saved.ufGuessed||{};\n        mv.cbsByMuni=saved.cbsByMuni||{}; mv.shapeMunis=saved.shapeMunis||[];\n        mv.excludedMunis=saved.excludedMunis||[]; mv.excludedCbs=saved.excludedCbs||0; mv.domCsvMunis=saved.domCsvMunis||0;\n        mv.noShapeMunis=saved.noShapeMunis||[]; mv.noShapeCbs=saved.noShapeCbs||0;\n        mv.secCentroid=saved.secCentroid||{}; mv.muniCentroid=saved.muniCentroid||{}; mv.closeAdj=saved.closeAdj||{};\n        aggregateMovement(mv);\n        mv.assign=saved.assign||{};\n        mv.dest.forEach(s=>{ if(!SESS.sectorBaseCache[s]) SESS.sectorBaseCache[s]=mv.baseMetrics[s]; });\n        SESS.timeline[i]=mv;\n      } else {\n        loader(true,`replay ${i+1}/${SESS.timeline.length} — setor ${SESS.timeline[i].sector}…`);\n        await computeAt(i);\n        if(saved.assign) SESS.timeline[i].assign=legacyKeys?remapAssign(SESS.timeline[i],saved.assign):saved.assign;\n        if(saved.policy) SESS.timeline[i].policy=saved.policy;\n      }\n    }\n    await loadSectors();\n  }catch(e){ $('#ds').textContent='erro ao carregar sessão: '+e.message; $('#ds').classList.add('err'); }\n  finally{ loader(false); }\n  renderAll();\n}\n// Re-points a legacy bare-name assignment at the NAME/UF key it can only mean. When a name resolves\n// to more than one município in the recomputed stage the manual choice is genuinely ambiguous —\n// dropping it lets the optimizer allocate instead of guessing on the operator's behalf.\n// True when a cached stage names a destination outside the closing sector's gerência de vendas.\n// Judges the cache by TODAY's rule, reading the (memoised) dominantes base rather than trusting what\n// the session was saved with. Unknown GV on the closing sector answers false: the stage then loads\n// from cache instead of being replayed into the hard throw in computeMovement, which would leave the\n// operator staring at a broken session with no way back.\nasync function savedCrossesGv(saved){\n  if(!saved||!saved.sector||!(saved.dest||[]).length) return false;\n  let gvBy={};\n  try{ await ensureDominantes(); gvBy=DOM.gvBySetor||{}; }catch(e){ return false; }\n  const own=gvBy[String(saved.sector)]||'';\n  if(!own) return false;\n  if(saved.dest.some(s=>(gvBy[String(s)]||'')!==own)) return true;\n  // A stage saved before foreign-GV neighbours stopped being drawn can hold a perfectly clean dest and\n  // STILL carry their shapes in nbrFeat/nbrCurrentSector — it would repaint the old map straight from\n  // cache. An unknown gerência is not evidence of a foreign one, so only a known, differing GV replays.\n  return Object.values(saved.nbrCurrentSector||{}).some(s=>{ const g=gvBy[String(s)]||''; return g&&g!==own; });\n}\nfunction remapAssign(mv,savedAssign){\n  const keys=Object.keys(mv.munis||{}).filter(k=>k!=='__OUTROS__');\n  const out={};\n  Object.entries(savedAssign||{}).forEach(([k,v])=>{\n    if(keys.indexOf(k)>=0){ out[k]=v; return; }\n    const cand=keys.filter(x=>mName(x)===mName(k));\n    if(cand.length===1) out[cand[0]]=v;\n  });\n  return out;\n}\nfunction newSession(){\n  SESS.sessionId=null; SESS.sessionLabel=null; SESS.timeline=[]; SESS.selected=0; SESS.sectorBaseCache={};\n  SESS.settings={...DEFAULT_SETTINGS};\n  SESS.snapshotRaw=null; SESS.snapshotSyncedAt=null; SESS.snapLiteral=null; SESS.eloMaxCycle=null; SESS.stage='loading'; SESS.dirtyFrom=null;\n  (async()=>{\n    loader(true,'iniciando nova sessão…');\n    try{ await Promise.all([ensurePinned(), loadSectors()]); SESS.stage='confirm'; }\n    catch(e){ setError('erro init: '+e.message); }\n    finally{ loader(false); renderAll(); }\n  })();\n}\n\n// ---------- snapshot refresh ----------\n// Deliberately breaks the frozen-snapshot guarantee, on demand: re-resolves DT_HISTORICO and\n// replays the whole timeline against the new one. Skips the replay when the snapshot has not\n// actually moved, so a stray click on a long timeline costs one cheap probe instead of ~8 queries\n// per etapa.\nasync function refreshSnapshot(){\n  if(SESS.stage!=='main' || SESS.snapBusy) return;\n  SESS.snapBusy=true;\n  const prevRaw=SESS.snapshotRaw;\n  const pill=$('#liveState'); if(pill){ pill.classList.add('busy'); pill.textContent='◌ atualizando snapshot…'; }\n  loader(true,'buscando o snapshot mais recente…');\n  let ok=false;\n  try{\n    const p=await resolveSnapshotPin();\n    if(!p.snapshotRaw) throw new Error('não foi possível resolver DT_HISTORICO');\n    const same=String(p.snapshotRaw)===String(prevRaw);\n    SESS.snapshotRaw=p.snapshotRaw; SESS.snapshotSyncedAt=p.syncedAt; SESS.snapLiteral=p.snapLiteral; SESS.eloMaxCycle=p.eloMaxCycle;\n    await loadSectors();   // the sector list follows the live max, so it can have moved too\n    if(same){\n      $('#ds').textContent=`snapshot já era o mais recente · atualizado em ${fmtSyncedAt(p.syncedAt)} — nada recalculado`;\n    } else if(SESS.timeline.length){\n      SESS.dirtyFrom=null;\n      SESS.sectorBaseCache={};   // baselines come from the snapshot, so every one of them changed\n      loader(true,'snapshot novo — recalculando a timeline…');\n      await recomputeFrom(0);\n      $('#ds').textContent=`snapshot atualizado em ${fmtSyncedAt(p.syncedAt)} · ${fmt(SESS.timeline.length)} etapa(s) recalculada(s)`;\n    } else {\n      $('#ds').textContent=`snapshot atualizado em ${fmtSyncedAt(p.syncedAt)}`;\n    }\n    $('#ds').classList.remove('err');\n    ok=true;\n  }catch(e){\n    const at=(SESS.dirtyFrom==null?null:SESS.dirtyFrom+1);\n    $('#ds').textContent='erro ao atualizar o snapshot'+(at?` na etapa ${at} — etapa ${at} em diante ficou desatualizada (⟳)`:'')+': '+e.message;\n    $('#ds').classList.add('err');\n  }finally{\n    SESS.snapBusy=false;\n    if(pill) pill.classList.remove('busy');\n    loader(false); renderAll();\n    if(ok) setTimeout(()=>{ const d=$('#ds'); if(d&&!d.classList.contains('err')) d.textContent=''; },7000);\n  }\n}\n\n// ---------- session options ----------\nfunction previewDest(mv,rule,minCbs){\n  if(!mv||!mv.allowedRaw||!mv.munis) return null;\n  const w={}; let relaxed=false;\n  const cbsOf=m=>(mv.cbsByMuni&&mv.cbsByMuni[m]!=null)?mv.cbsByMuni[m]:((mv.munis[m]&&mv.munis[m].cbs)||0);\n  const add=m=>Object.entries(mv.allowedRaw[m]||{}).forEach(([s,v])=>{ w[s]=(w[s]||0)+v; });\n  // allowedRaw only carries municipalities already inside the sector's dominance base, so there is\n  // nothing left to filter out here beyond the destination rule itself.\n  Object.keys(mv.allowedRaw).forEach(m=>{ const cbs=cbsOf(m);\n    if(rule==='dominant'&&cbs<minCbs) return; add(m); });\n  if(rule==='dominant'&&!Object.keys(w).length){ Object.keys(mv.allowedRaw).forEach(add); relaxed=true; }\n  return {dest:Object.keys(w).sort((a,b)=>w[b]-w[a]).slice(0,6),relaxed};\n}\nfunction readOptForm(){\n  const r=root.querySelector('input[name=\"destRule\"]:checked');\n  let n=Math.round(+$('#optMinCbs').value);\n  if(!(n>0)) n=DEFAULT_SETTINGS.destMinCbs;\n  return {destRule:(r&&r.value)||DEFAULT_SETTINGS.destRule, destMinCbs:n,\n          targetSpan:numOrNull($('#optTargetSpan').value),\n          maxSpan:numOrNull($('#optMaxSpan').value),\n          minSpan:numOrNull($('#optMinSpan').value)};\n}\nfunction spanFormError(f){\n  if(f.minSpan&&f.maxSpan&&f.minSpan>f.maxSpan) return `Span mínimo (${fmt(f.minSpan)}) não pode ser maior que o máximo (${fmt(f.maxSpan)}).`;\n  if(f.targetSpan&&f.maxSpan&&f.targetSpan>f.maxSpan) return `Span alvo (${fmt(f.targetSpan)}) não pode passar do máximo (${fmt(f.maxSpan)}).`;\n  if(f.targetSpan&&f.minSpan&&f.targetSpan<f.minSpan) return `Span alvo (${fmt(f.targetSpan)}) não pode ficar abaixo do mínimo (${fmt(f.minSpan)}).`;\n  return '';\n}\nfunction renderOptPreview(){\n  const el=$('#optPreview'); if(!el) return;\n  const f=readOptForm();\n  root.querySelectorAll('.opt-row').forEach(r=>{\n    if(r.dataset.r!=null) r.classList.toggle('on',r.dataset.r===f.destRule);\n  });\n  const wrap=$('#optMinCbsWrap'); if(wrap) wrap.classList.toggle('off',f.destRule!=='dominant');\n  const err=spanFormError(f), ee=$('#optSpanErr');\n  if(ee){ ee.textContent=err; ee.classList.toggle('hidden',!err); }\n  const ap=$('#optApply'); if(ap) ap.disabled=!!err;\n  const mv=SESS.timeline[SESS.selected];\n  if(!mv||!mv.munis){ el.innerHTML='Nenhuma etapa calculada ainda — as regras passam a valer no próximo fechamento.'; return; }\n  const cbsOf=m=>(mv.cbsByMuni&&mv.cbsByMuni[m]!=null)?mv.cbsByMuni[m]:((mv.munis[m]&&mv.munis[m].cbs)||0);\n  const ms=Object.keys(mv.geoFeat||{});\n  const ig=f.destRule==='dominant'?ms.filter(m=>cbsOf(m)<f.destMinCbs):[];\n  const pv=previewDest(mv,f.destRule,f.destMinCbs);\n  let h=`<b style=\"color:var(--ink)\">Prévia — etapa ${SESS.selected+1}, setor ${mv.sector}</b><br>`;\n  const exN=(mv.excludedMunis||[]).length;\n  if(exN) h+=`Base de dominantes: <b>${fmt(ms.length)}</b> município(s) no mapa · <span style=\"color:var(--closing)\">${fmt(exN)} fora da base — ${fmt(mv.excludedCbs||0)} CBs seguem suas líderes</span><br>`;\n  h+=`${fmt(ms.length-ig.length)} de ${fmt(ms.length)} município(s) valem como ponte`;\n  if(ig.length) h+=` · <span style=\"color:var(--closing)\">ignorados:</span> ${ig.slice(0,8).join(', ')}${ig.length>8?' +'+(ig.length-8):''}`;\n  if(pv){\n    h+=`<br>Setores destino com esta regra: <b style=\"color:var(--ink)\">${pv.dest.join(', ')||'–'}</b>`;\n    const cur=(mv.dest||[]).join(', ');\n    if(pv.dest.join(',')!==(mv.dest||[]).join(',')) h+=` <span style=\"color:var(--closing)\">(hoje: ${cur||'–'})</span>`;\n    if(pv.relaxed) h+=`<br><b style=\"color:var(--closing)\">⚠ nenhum município dominante nesta etapa — a regra relaxa pra \"qualquer município\" pra não deixar a etapa sem destino.</b>`;\n  }\n  if(f.targetSpan||f.maxSpan||f.minSpan){\n    const sp=spanMapFor(mv,mv.assign||{}), v=spanViolationsOf(sp,{target:f.targetSpan,max:f.maxSpan,min:f.minSpan});\n    h+=`<br><br>Span dos destinos como está hoje: ${mv.dest.map(s=>s+' <b>'+fmt(sp[s])+'</b>').join(' · ')||'–'}`;\n    if(v.over.length||v.under.length) h+=`<br><span style=\"color:var(--closing)\">Fora dos limites: ${[...v.over.map(s=>s+' ▲ acima do máx'),...v.under.map(s=>s+' ▼ abaixo do mín')].join(', ')}</span> — o modelo ÓTIMO tenta corrigir no recálculo.`;\n    else h+=` <span style=\"color:var(--ok)\">— já dentro dos limites</span>`;\n  }\n  if(SESS.timeline.length) h+=`<br><span style=\"margin-top:4px;display:inline-block\">Aplicar <b>recalcula as ${fmt(SESS.timeline.length)} etapa(s)</b> contra o snapshot congelado — os destinos ajustados à mão serão refeitos pelo modelo ÓTIMO.</span>`;\n  el.innerHTML=h;\n}\nfunction openOptions(){\n  const st={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  root.querySelectorAll('input[name=\"destRule\"]').forEach(r=>{ r.checked=(r.value===st.destRule); });\n  $('#optMinCbs').value=st.destMinCbs;\n  $('#optTargetSpan').value=st.targetSpan||'';\n  $('#optMaxSpan').value=st.maxSpan||'';\n  $('#optMinSpan').value=st.minSpan||'';\n  $('#optApply').disabled=false;\n  $('#optSessName').textContent=SESS.sessionLabel||'sessão não salva';\n  $('#optStatus').textContent='';\n  $('#optionsBg').classList.remove('hidden');\n  renderOptPreview();\n  const c=$('#optCancel'); if(c) c.focus();\n}\nfunction closeOptions(){ const b=$('#optionsBg'); if(b) b.classList.add('hidden'); }\nasync function applyOptions(){\n  const f=readOptForm();\n  if(spanFormError(f)) return;\n  const prev={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  const changed = prev.destRule!==f.destRule\n    || (f.destRule==='dominant' && prev.destMinCbs!==f.destMinCbs)\n    || numOrNull(prev.targetSpan)!==f.targetSpan\n    || numOrNull(prev.maxSpan)!==f.maxSpan\n    || numOrNull(prev.minSpan)!==f.minSpan;\n  SESS.settings={...prev,...f};\n  closeOptions();\n  if(!changed || !SESS.timeline.length){ renderAll(); return; }\n  SESS.dirtyFrom=null;\n  SESS.sectorBaseCache={};   // the recompute re-derives every baseline; keeping the old ones would\n                             // leave \"Impacto acumulado\" comparing against pre-change numbers\n  loader(true,'aplicando nova regra de destino — recalculando timeline…');\n  try{\n    await recomputeFrom(0);\n    $('#ds').textContent=''; $('#ds').classList.remove('err');\n  }catch(e){\n    const at=(SESS.dirtyFrom==null?0:SESS.dirtyFrom)+1;\n    $('#ds').textContent=`erro ao aplicar a regra na etapa ${at}: ${e.message} — etapa ${at} em diante ficou desatualizada (⟳)`;\n    $('#ds').classList.add('err');\n  }finally{ loader(false); renderAll(); }\n}\n\nconst SHARED_DS='timeline-shared-sessionsDataSet';\nconst AUTH_REF='timelineSimAuthFinal';   // single source of truth for the auth strategy reference\nlet SHARE={busy:false,url:null,last:null};\n// Serialise a PRESENTATION model, not the raw session, so the reader stays purely presentational\n// and never re-derives spans/desloc/orphans — no second copy of this engine to keep in sync.\n// NOTE: METRICS_DEF[2] is the money marker; read it as !!md[2] (a literal dollar sign in this\n// position trips the upsert's parse check).\nfunction buildShareStages(){\n  return SESS.timeline.map((mv,i)=>{\n    const c=computeDerived(mv);\n    const incBy={};\n    mv.dest.forEach(s=>{ incBy[s]=Object.fromEntries(METRICS_DEF.map(md=>[md[0],0])); });\n    Object.entries(mv.munis).forEach(([m,o])=>{\n      if(m==='__OUTROS__') return;\n      const s=mv.assign[m]; if(!s||!incBy[s]) return;\n      METRICS_DEF.forEach(md=>{ incBy[s][md[0]]+=o[md[0]]||0; });\n    });\n    const saiTot={};\n    METRICS_DEF.forEach(md=>{\n      const k=md[0];\n      saiTot[k]=Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,p)=>a+((p[1][k])||0),0);\n    });\n    // orphans follow their leader's home: they enter that sector AND leave the closing one\n    c.leaders.forEach(L=>{\n      const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n      if(!om||!L.home) return;\n      METRICS_DEF.forEach(md=>{ const k=md[0];\n        if(incBy[L.home]) incBy[L.home][k]+=om[k]||0;\n        saiTot[k]+=om[k]||0; });\n    });\n    const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n    const sv=mv.dest.map(s=>c.span[s]);\n    const baseOf=(s,k)=>{ const b=mv.baseMetrics[s]||{}; return +(b[k]||0); };\n    return {\n      i:i, sector:mv.sector, policy:mv.policy, domUF:mv.domUF,\n      optTier:(mv.optTier==null?0:mv.optTier),\n      destRuleFallback:!!mv.destRuleFallback,\n      dest:mv.dest.slice(), color:Object.assign({},mv.color), assign:Object.assign({},mv.assign),\n      kpis:{ cbsRealocadas:closingCbsOf(mv), desloc:c.desloc,\n             spread:(sv.length?Math.max.apply(null,sv)-Math.min.apply(null,sv):0),\n             municipios:ms.length, lideres:mv.leaders.length },\n      destinos:mv.dest.map(s=>({ s:s, base:baseOf(s,'cbs'), inc:c.inc[s]||0, orph:c.orph[s]||0, span:c.span[s]||0, gained:c.gained[s]||0 })),\n      municipios:ms.map(m=>({ nome:mName(m), uf:mUf(m), cbs:mv.munis[m].cbs, destino:mv.assign[m]||null })),\n      excluidos:(mv.excludedMunis||[]).map(x=>({ muni:x.muni, cbs:x.cbs })),\n      orphanTotal:c.orphanTotal||0, orphanStranded:c.orphanStranded||0, noShapeCbs:mv.noShapeCbs||0,\n      lideres:c.leaders.map(L=>({ lider:(L.lider||'').split(' ').slice(0,2).join(' '), grupo:L.grupo,\n        total:L.total, home:L.home, desloc:L.desloc, orphan:L.orphan||0 })),\n      metricas:METRICS_DEF.map(md=>{ const k=md[0];\n        return { key:k, label:md[1], money:!!md[2],\n          rows:mv.dest.map(s=>({ s:s, antes:baseOf(s,k), depois:baseOf(s,k)+((incBy[s]&&incBy[s][k])||0) })),\n          sai:saiTot[k]||0 }; }),\n      geoFeat:mv.geoFeat, nbrFeat:mv.nbrFeat, excludedFeat:[]\n    };\n  });\n}\nfunction buildShareSummary(stages){\n  const world=foldWorldState(SESS.timeline,SESS.timeline.length);\n  const receivers=[];\n  SESS.timeline.forEach((mv,i)=>{\n    const own=mv.pureOwnMetrics||{};\n    receivers.push({ s:mv.sector, situacao:'fechado (etapa '+(i+1)+')', antes:+(own.cbs||0), depois:0 });\n  });\n  const seen={};\n  Object.values(world.muniOverride).forEach(s=>{\n    if(seen[s]) return; seen[s]=1;\n    const base=SESS.sectorBaseCache[s]||{}, infl=world.sectorInflow[s]||{};\n    receivers.push({ s:s, situacao:'recebeu território', antes:+(base.cbs||0), depois:(+(base.cbs||0))+(infl.cbs||0) });\n  });\n  return { stages:stages.length, sectors:SESS.timeline.map(mv=>mv.sector),\n    totalCbs:stages.reduce((a,s)=>a+(s.kpis.cbsRealocadas||0),0),\n    totalDesloc:stages.reduce((a,s)=>a+(s.kpis.desloc||0),0), receivers:receivers };\n}\nfunction renderShareBody(){\n  const el=$('#shareBody'); if(!el) return;\n  const go=$('#shareGo'), cp=$('#shareCopy'), op=$('#shareOpen'), cc=$('#shareCancel');\n  if(SHARE.url){\n    el.innerHTML=`<div class=\"opt-note\" style=\"margin-top:0\"><b style=\"color:var(--ink)\">Sessão compartilhada ✓</b><br>\n      Link de leitura desta simulação congelada. Quem abrir precisa ter login no simulador.\n      <div class=\"shareurl\">${esc(SHARE.url)}</div></div>`;\n    if(go) go.classList.add('hidden');\n    if(cp) cp.classList.remove('hidden');\n    if(op) op.classList.remove('hidden');\n    if(cc) cc.textContent='Fechar';\n    return;\n  }\n  if(go) go.classList.remove('hidden');\n  if(cp) cp.classList.add('hidden');\n  if(op) op.classList.add('hidden');\n  if(cc) cc.textContent='Cancelar';\n  const st={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  const desloc=SESS.timeline.reduce((a,mv)=>a+(mv.munis?computeDerived(mv).desloc:0),0);\n  const destTxt=st.destRule==='dominant'?('dominantes ≥'+fmt(st.destMinCbs)+' CBs'):'qualquer município';\n  el.innerHTML=`<div class=\"opt-lbl\">Confirmar compartilhamento</div>\n    <div class=\"opt-sub\">Publica uma <b>cópia congelada</b> desta sessão, exatamente no estado atual.</div>\n    <div class=\"opt-note\" style=\"margin-top:0\">\n      · <b>${fmt(SESS.timeline.length)}</b> etapa(s) — ${esc(SESS.timeline.map(mv=>mv.sector).join(' → '))}<br>\n      · snapshot atualizado em <b>${esc(fmtSyncedAt(SESS.snapshotSyncedAt))}</b><br>\n      · base: <b>municípios dominantes (CSV)</b> · destino: <b>${esc(destTxt)}</b> · span: <b>${esc(spanShort(st))}</b><br>\n      · <b>${fmt(desloc)}</b> CBs deslocadas somando a cadeia\n    </div>\n    <div class=\"opt-note\">A cópia <b>não</b> acompanha edições posteriores desta sessão — pra atualizar, compartilhe de novo e use o link novo. O link exige login no simulador; não é público.</div>\n    ${SHARE.last?`<div class=\"opt-note\"><b style=\"color:var(--ink)\">Último link gerado nesta sessão</b> — aponta pro estado de <b>antes</b> das edições feitas depois dele.<div class=\"shareurl\">${esc(SHARE.last)}</div></div>`:''}`;\n}\nasync function openShare(){\n  if(!SESS.timeline.length){ $('#ds').textContent='nada pra compartilhar — feche pelo menos um setor primeiro.'; $('#ds').classList.add('err'); return; }\n  await flushDirty();\n  SHARE.url=null;\n  const s=$('#shareStatus'); if(s){ s.textContent=''; s.classList.remove('err'); }\n  renderShareBody();\n  $('#shareBg').classList.remove('hidden');\n  const b=$('#shareCancel'); if(b) b.focus();\n}\nfunction closeShare(){ const b=$('#shareBg'); if(b) b.classList.add('hidden'); }\nasync function doShare(){\n  if(SHARE.busy) return;\n  const go=$('#shareGo'), stx=$('#shareStatus');\n  SHARE.busy=true;\n  if(go){ go.disabled=true; go.textContent='Compartilhando…'; }\n  if(stx){ stx.textContent=''; stx.classList.remove('err'); }\n  try{\n    // Resolved as a GLOBAL PROCESS, not a declared param — which only works because the block\n    // instance on the `main` artboard binds `sharedDataset: @timeline-shared-sessionsDataSet`.\n    // That binding is load-bearing: a Process is only define()d once something on the rendering\n    // tree references it, so without it this lookup returns null and sharing dies on the guard\n    // below. Do NOT remove the instance param because this file never reads d2.params.sharedDataset.\n    const ds=d2.data(SHARED_DS);\n    if(!ds) throw new Error('dataset de sessões compartilhadas indisponível');\n    await flushDirty();\n    const stages=buildShareStages();\n    const summary=buildShareSummary(stages);\n    let who='';\n    try{ const a=d2.auth(AUTH_REF); who=(a&&a.user&&(a.user.email||a.user.nome))||''; }catch(e){}\n    const created=await ds.create({\n      label:SESS.sessionLabel||('Sessão '+new Date().toLocaleString('pt-BR')),\n      snapshotRaw:SESS.snapshotRaw, snapshotSyncedAt:SESS.snapshotSyncedAt,\n      settings:{...DEFAULT_SETTINGS,...(SESS.settings||{})},\n      stages:stages, summary:summary, sharedBy:who, sharedAt:new Date().toISOString()\n    });\n    const id=(created&&created._id)||(created&&created.data&&created.data._id)||(ds.selected&&ds.selected()&&ds.selected()._id)||null;\n    if(!id) throw new Error('registro gravado, mas sem _id na resposta — não foi possível montar o link');\n    SHARE.url=window.location.origin+'/sessions/'+id;\n    SHARE.last=SHARE.url;\n    renderShareBody();\n  }catch(e){\n    if(stx){ stx.textContent='erro ao compartilhar: '+e.message; stx.classList.add('err'); }\n  }finally{\n    SHARE.busy=false;\n    if(go){ go.disabled=false; go.textContent='Compartilhar'; }\n  }\n}\n\n// ---------- rendering ----------\nfunction renderTimeline(){\n  const el=$('#movementChips'); if(!el) return;\n  if(!SESS.timeline.length){ el.innerHTML='<span class=\"empty\">Nenhuma etapa ainda — use \"Fechar setor ▶\" à direita.</span>'; return; }\n  el.innerHTML=SESS.timeline.map((mv,i)=>{\n    const ready=mv.dest!==undefined;\n    const c=ready?computeDerived(mv):null;\n    const stale=SESS.dirtyFrom!=null && i>=SESS.dirtyFrom;\n    return `<div class=\"tchip ${i===SESS.selected?'on':''} ${stale?'stale':''}\" data-i=\"${i}\">\n      <span class=\"tnum\">${i+1}</span><span class=\"tsec\">Setor ${mv.sector}</span>\n      <span class=\"tstat\">${ready?fmt(c.desloc)+' desloc.':'…'}</span>\n      ${stale?'<span class=\"tstale\" title=\"recalcula ao selecionar outra etapa\">⟳</span>':''}\n      <span class=\"trm\" data-rm=\"${i}\" title=\"remover esta etapa e as seguintes\">✕</span>\n    </div>`;\n  }).join('');\n  el.querySelectorAll('.tchip').forEach(ch=>{\n    ch.addEventListener('click',async (e)=>{\n      if(e.target.dataset.rm!=null && e.target.dataset.rm!==''){ removeFrom(+e.target.dataset.rm); return; }\n      const i=+ch.dataset.i;\n      if(i!==SESS.selected) await flushDirty();\n      SESS.selected=i; renderAll();\n    });\n  });\n}\nfunction renderRuleNote(){\n  const el=$('#ruleNote'); if(!el) return;\n  const st={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  const mv=SESS.timeline[SESS.selected];\n  let h='';\n  h+=`<b style=\"color:var(--ink)\">Base de municípios dominantes:</b> só os municípios que a base (CSV) atribui ao setor entram no mapa e na alocação`;\n  if(mv&&mv.domCsvMunis) h+=` · <b>${fmt(mv.domCsvMunis)}</b> na base do setor ${mv.sector}`;\n  if(mv&&mv.excludedCbs) h+=` · <b style=\"color:var(--closing)\">${fmt(mv.excludedMunis.length)} município(s) / ${fmt(mv.excludedCbs)} CBs</b> fora da base nesta etapa — não aparecem no mapa e as CBs seguem suas líderes (quebra de vínculo)`;\n  if(DOM.error) h+=` · <b style=\"color:var(--closing)\">⚠ base indisponível: ${esc(DOM.error)}</b>`;\n  h+=`<br>`;\n  h+=`<b style=\"color:var(--ink)\">Regra de destino:</b> `;\n  h+= st.destRule==='dominant'\n    ? `só municípios com <b>≥${fmt(st.destMinCbs)} CBs</b> trazem setor pra lista de destinos`\n    : `qualquer município do setor fechado vale como ponte <span style=\"opacity:.7\">(padrão)</span>`;\n  if(mv&&mv.munis&&st.destRule==='dominant'){\n    const ig=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__'&&mv.munis[m].cbs<st.destMinCbs);\n    if(ig.length) h+=` · <b style=\"color:var(--closing)\">${fmt(ig.length)}</b> ignorado(s) como ponte (ainda redistribuído(s)): ${ig.slice(0,5).join(', ')}${ig.length>5?' +'+(ig.length-5):''}`;\n    if(mv.destRuleFallback) h+=`<br><b style=\"color:var(--closing)\">⚠ nenhum município dominante nesta etapa — regra relaxada pra \"qualquer município\".</b>`;\n  }\n  el.innerHTML=h;\n}\nfunction renderSpanRule(){\n  const el=$('#spanRule'); if(!el) return;\n  const mv=SESS.timeline[SESS.selected], lim=spanLimits();\n  if(!mv||!mv.munis||mv.view==='asis'||!spanLimitsOn(lim)){ el.innerHTML=''; return; }\n  const sp=spanMapFor(mv,mv.assign||{}), v=spanViolationsOf(sp,lim);\n  const bits=[];\n  if(lim.target) bits.push(`alvo <b>${fmt(lim.target)}</b>`);\n  if(lim.max) bits.push(`máx <b>${fmt(lim.max)}</b>`);\n  if(lim.min) bits.push(`mín <b>${fmt(lim.min)}</b>`);\n  let h=`Pré-requisito de span: ${bits.join(' · ')} CBs — `;\n  if(v.over.length||v.under.length){\n    h+=`<b style=\"color:var(--closing)\">⚠ `;\n    if(v.over.length) h+=`acima do máximo: ${v.over.map(s=>s+' ('+fmt(sp[s])+')').join(', ')}`;\n    if(v.over.length&&v.under.length) h+=' · ';\n    if(v.under.length) h+=`abaixo do mínimo: ${v.under.map(s=>s+' ('+fmt(sp[s])+')').join(', ')}`;\n    h+=`</b>`;\n    if(mv.optTier===1) h+=`<br><span style=\"opacity:.85\">Nenhuma alocação contígua respeita o span mínimo nesta etapa — o modelo relaxou essa exigência.</span>`;\n    else if(mv.optTier===2) h+=`<br><span style=\"opacity:.85\">Nenhuma alocação contígua respeita os limites nesta etapa — o modelo relaxou essa exigência.</span>`;\n    else if(mv.optTier===-1) h+=`<br><span style=\"opacity:.85\">Etapa resolvida por heurística — os limites não são garantidos.</span>`;\n  } else h+=`<b style=\"color:var(--ok)\">✓ todos os setores receptores dentro dos limites</b>`;\n  el.innerHTML=`<div class=\"note\" style=\"margin:-2px 0 6px\">${h}</div>`;\n}\nfunction renderContig(){const mv=SESS.timeline[SESS.selected];const el=$('#contig');if(!el||!mv)return;\n  if(mv.view==='asis'){el.innerHTML='';return;}\n  const bad=contigBadFor(mv,mv.assign);\n  el.innerHTML=bad.length\n    ?`<div class=\"note\" style=\"color:var(--closing);font-weight:600;margin:-2px 0 6px\">⚠ Contiguidade quebrada em ${bad.join(', ')} — município(s) desconectado(s) do território do setor. Use ★ ÓTIMO ou reassocie no mapa.</div>`\n    :`<div class=\"note\" style=\"color:var(--ok);margin:-2px 0 6px\">✓ Contíguo — cada setor forma uma região conectada.</div>`;\n}\nfunction closingCbsOf(mv){ return Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,[,o])=>a+o.cbs,0); }\nfunction sectorTotalCbs(mv){ return Object.values(mv.munis).reduce((a,o)=>a+o.cbs,0); }\nfunction renderKpis(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);\n  if(mv.view==='asis'){const mc=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').length;\n    $('#kpis').innerHTML=[\n      {n:fmt(sectorTotalCbs(mv)),l:`CBs no setor ${mv.sector} (antes desta etapa)`,t:'Total de CBs no setor hoje — já considerando o que etapas anteriores da timeline mandaram pra cá.'},\n      {n:mv.csvMunisTotal?`${fmt(mc)}/${fmt(mv.csvMunisTotal)}`:fmt(mc),l:'Municípios do setor',t:`Municípios desenhados nesta etapa${mv.csvMunisTotal?` de ${fmt(mv.csvMunisTotal)} que a base de dominantes atribui ao setor`:''}.${(mv.missingMunis||[]).length?' Fora do mapa: '+mv.missingMunis.map(x=>x.muni+' ('+x.reason+')').join(' · '):''}${Object.keys(mv.ufGuessed||{}).length?' UF inferida da UF dominante do setor (nome repetido em outra UF carregada): '+Object.entries(mv.ufGuessed).map(([nm,u])=>nm+'→'+u).join(', '):''}`},\n      {n:fmt(mv.leaders.length),l:'Líderes (grupos)',t:'Líderes presentes no setor nesta etapa.'},\n      {n:fmt(mv.dest.length),l:'Setores adjacentes',t:`Setores vizinhos (ainda abertos) da MESMA gerência de vendas (CD_GV ${mv.gv||'?'}) que poderiam receber os municípios.${(mv.gvRejected||[]).length?' Fora do mapa, por serem de outra gerência: '+mv.gvRejected.join(', ')+'.':''}`},\n    ].map(k=>`<div class=\"kpi\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n    return;}\n  const sv=mv.dest.map(s=>c.span[s]);const spread=sv.length?Math.max(...sv)-Math.min(...sv):0;\n  const zero=mv.dest.filter(s=>c.gained[s]===0);const totCbs=closingCbsOf(mv);\n  $('#kpis').innerHTML=[\n    {n:fmt(c.desloc),l:mv.policy==='fora'?'CBs fora do setor':'CBs c/ quebra de relação',cls:'warn',t:'CBs cuja líder acabou noutro setor nesta etapa.'},\n    {n:fmt(spread),l:'Desequilíbrio (máx−mín CBs)',cls:spread>800?'warn':'ok',t:'Diferença de tamanho entre o maior e o menor setor depois desta etapa.'},\n    {n:fmt(totCbs),l:'CBs realocadas',cls:'',t:`Total de CBs do setor ${mv.sector} sendo redistribuídas nesta etapa.`},\n    {n:zero.length?zero.join(', '):'nenhum',l:'Setor sem líder herdada',cls:zero.length?'warn':'ok',t:'Setor que recebe município(s) mas nenhuma líder nesta etapa.'},\n  ].concat(mv.excludedCbs?[{n:fmt(mv.excludedCbs),l:`CBs ignoradas · ${fmt(mv.excludedMunis.length)} município(s) fora da base`,cls:'warn',t:'CBs em municípios que a base de dominantes não atribui a este setor: ficam fora do mapa e da alocação geográfica e seguem a líder do seu grupo.'}]:[])\n   .concat((mv.missingMunis||[]).length?[{n:fmt(mv.missingMunis.length),l:`Município(s) da base fora do mapa · ${fmt(mv.csvMunisTotal)} na base`,cls:'warn',t:`A base de dominantes atribui ${fmt(mv.csvMunisTotal)} município(s) ao setor ${mv.sector} e ${fmt(mv.csvMunisTotal-mv.missingMunis.length)} chegaram ao mapa. Fora: ${mv.missingMunis.map(x=>x.muni+' — '+x.reason+(x.cbs?' ('+fmt(x.cbs)+' CBs)':'')).join(' · ')}`}]:[])\n   .map(k=>`<div class=\"kpi ${k.cls}\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n}\nfunction renderSpans(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);\n  if(mv.view==='asis'){const items=[{s:mv.sector,val:sectorTotalCbs(mv),color:CLOSE_COLOR}].concat(mv.dest.map(s=>({s,val:+(mv.baseMetrics[s]?.cbs||0),color:mv.color[s]})));\n    const max=Math.max(...items.map(x=>x.val),1);\n    $('#spans').innerHTML=items.map(x=>`<div class=\"bar-row\"><div class=\"tag\" style=\"color:${x.color}\">${x.s}</div><div class=\"bar-track\">\n      <div class=\"bar-inc\" style=\"width:${x.val/max*100}%;background:${x.color}\"></div></div>\n      <div class=\"bar-val\">${fmt(x.val)}</div></div>`).join('')\n      +`<div class=\"note\">Estrutura antes desta etapa — o setor <b>${mv.sector}</b> ainda existe. Alterne pra <b>TOBE</b> pra ver a redistribuição.</div>`;\n    return;}\n  const lim=spanLimits(),on=spanLimitsOn(lim);\n  const rawMax=Math.max(...mv.dest.map(s=>c.span[s]),lim.target||0,lim.max||0,1);\n  const max=on?rawMax*1.04:rawMax;   // headroom so a marker sitting at the scale top stays visible\n  const mk=(v,color,label)=>v?`<div class=\"bar-mark\" style=\"left:${Math.min(99.6,v/max*100).toFixed(1)}%;background:${color}\" title=\"${label}\"></div>`:'';\n  const marks=mk(lim.min,'#ff9500','span mínimo '+fmt(lim.min))+mk(lim.target,'#007aff','span alvo '+fmt(lim.target))+mk(lim.max,'#ff3b30','span máximo '+fmt(lim.max));\n  $('#spans').innerHTML=mv.dest.map(s=>{const base=+(mv.baseMetrics[s]?.cbs||0),i=c.inc[s];\n    const over=lim.max&&c.span[s]>lim.max,under=lim.min&&c.span[s]<lim.min;\n    return `<div class=\"bar-row\"><div class=\"tag\" style=\"color:${mv.color[s]}\">${s}</div><div class=\"bar-track\">\n      <div class=\"bar-base\" style=\"width:${base/max*100}%;background:${mv.color[s]}\"></div>\n      <div class=\"bar-inc\" style=\"width:${i/max*100}%;background:${mv.color[s]}\"></div>${marks}</div>\n      <div class=\"bar-val\" style=\"${over?'color:var(--closing);font-weight:700':(under?'color:#ff9500;font-weight:700':'')}\">${fmt(c.span[s])} <span style=\"opacity:.6\">(+${fmt(i)})</span>${over?' ▲':''}${under?' ▼':''}</div></div>`;}).join('')\n    +`<div class=\"note\">Claro = base (já incluindo etapas anteriores) · cheio = CBs entrantes do ${mv.sector} nesta etapa.${c.orphanTotal?` Inclui <b>${fmt(c.orphanTotal-c.orphanStranded)}</b> CB(s) de municípios fora da base de dominantes que seguiram suas líderes.`:''}${on?' Marcas: <b style=\"color:#007aff\">│</b> alvo · <b style=\"color:#ff3b30\">│</b> máx · <b style=\"color:#ff9500\">│</b> mín.':''}</div>`;\n}\n// Two very different reasons land rows in the __OUTROS__ bucket — no IBGE shape, or outside the\n// sector's dominance base. Same bucket computationally, but they must not read as the same thing.\nfunction outsideNote(mv){\n  const bits=[];\n  if(mv.noShapeCbs) bits.push(`<b>${fmt(mv.noShapeCbs)}</b> CBs em ${fmt(mv.noShapeMunis.length)} município(s) sem shape na UF`);\n  if(mv.excludedCbs) bits.push(`<b>${fmt(mv.excludedCbs)}</b> CBs em ${fmt(mv.excludedMunis.length)} município(s) <b>fora da base de dominantes</b> deste setor — <b style=\"color:var(--closing)\">quebra de vínculo</b>`);\n  if(!bits.length) return '';\n  let h=`<div class=\"note\">Fora do mapa e da alocação geográfica: ${bits.join(' · ')}.`;\n  if(mv.excludedCbs){\n    const c=computeDerived(mv);\n    h+=` As <b>${fmt(mv.excludedCbs)}</b> CBs em quebra de vínculo <b>seguem a líder do seu grupo</b>`;\n    const byS=mv.dest.filter(s=>c.orph[s]).map(s=>`<b style=\"color:${mv.color[s]}\">${s}</b> (${fmt(c.orph[s])})`);\n    h+=byS.length?`: ${byS.join(' · ')}.`:'.';\n    if(c.orphanStranded) h+=` <b style=\"color:var(--closing)\">${fmt(c.orphanStranded)} CB(s) sem destino</b> — a líder não ficou com nenhum município realocado, então não há setor pra seguir.`;\n  }\n  if(mv.noShapeCbs) h+=` As CBs sem shape na UF continuam fora de qualquer setor destino.`;\n  if(mv.excludedMunis&&mv.excludedMunis.length) h+=`<br><span style=\"opacity:.85\">${mv.excludedMunis.slice(0,8).map(x=>x.muni+' ('+fmt(x.cbs)+')').join(', ')}${mv.excludedMunis.length>8?' +'+(mv.excludedMunis.length-8):''}</span>`;\n  return h+`</div>`;\n}\nfunction renderMuni(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  if(mv.view==='asis'){$('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(mv.munis[m].cbs)} CBs</div><span class=\"badge\" style=\"background:${CLOSE_COLOR};color:#fff;border-color:${CLOSE_COLOR}\">setor ${mv.sector}</span></div>`).join('')\n      +`<div class=\"note\">Hoje todos pertencem ao setor <b>${mv.sector}</b>. No <b>TOBE</b> cada um vira botões de destino.</div>`+outsideNote(mv);return;}\n  $('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(mv.munis[m].cbs)} CBs</div>\n    <div class=\"seg\" data-m=\"${m}\">${mv.dest.map(s=>`<button data-s=\"${s}\" class=\"${mv.assign[m]===s?'on':''}\" style=\"${mv.assign[m]===s?'background:'+mv.color[s]:''}\">${s}</button>`).join('')}</div></div>`).join('')\n    +outsideNote(mv);\n  $('#muniList').querySelectorAll('.seg button').forEach(b=>b.onclick=()=>{const m=b.parentElement.dataset.m,s=b.dataset.s;editSelected(m2=>{m2.assign[m]=s;});});\n}\nfunction renderLeaders(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);\n  if(mv.view==='asis'){$('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>Grupo</th><th>CBs</th><th>Setor</th></tr></thead><tbody>`\n    +mv.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${L.grupo}</td><td>${fmt(L.total)}</td><td><span class=\"home\" style=\"background:${CLOSE_COLOR}\"></span>${mv.sector}</td></tr>`).join('')\n    +`<tr style=\"font-weight:700\"><td>TOTAL</td><td></td><td>${fmt(mv.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td></tr></tbody>`;return;}\n  const showOrph=c.orphanTotal>0;\n  $('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>CBs</th><th>Home</th>${mv.dest.map(s=>`<th>${s}</th>`).join('')}${showOrph?'<th title=\"CBs em municípios fora da base de dominantes que seguiram esta líder\">Órfãs</th>':''}<th>Desloc.</th></tr></thead><tbody>`\n   +c.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${fmt(L.total)}</td>\n     <td>${L.home?`<span class=\"home\" style=\"background:${mv.color[L.home]}\"></span>${L.home}`:'–'}</td>\n     ${mv.dest.map(s=>`<td class=\"cell\" style=\"${s===L.home?'color:'+mv.color[s]+';font-weight:700':''}\">${L.byS[s]||'·'}</td>`).join('')}\n     ${showOrph?`<td class=\"cell\" style=\"${L.orphan&&!L.orphanHome?'color:var(--closing);font-weight:700':''}\" title=\"${L.orphan?(L.orphanHome?'seguem a líder para o setor '+L.orphanHome:'sem home — CBs sem setor pra seguir'):''}\">${L.orphan?fmt(L.orphan)+(L.orphanHome?'':' ⚠'):'·'}</td>`:''}\n     <td style=\"color:${L.desloc?'var(--closing)':'var(--mut)'};font-weight:${L.desloc?700:400}\">${L.desloc||'0'}</td></tr>`).join('')\n   +`<tr style=\"font-weight:700\"><td>TOTAL</td><td>${fmt(c.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td>${mv.dest.map(s=>`<td>${fmt(c.leaders.reduce((a,L)=>a+L.byS[s],0))}</td>`).join('')}${showOrph?`<td>${fmt(c.orphanTotal)}</td>`:''}<td style=\"color:var(--closing)\">${fmt(c.desloc)}</td></tr></tbody>`;\n}\nfunction renderMatrix(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  // `_out` is a flag stamped on the ROW, and rows are shared between etapas (absorbed + inherited\n  // orphans), so the last etapa to aggregate wins and reading it here can describe the wrong etapa.\n  // Derive the bucket from THIS mv instead. Summing rather than .find() because several out-municípios\n  // of the same group all collapse into __OUTROS__, and only the first was being counted.\n  const outOf=x=>!mv.geoFeat[mkey(x.municipio,x.uf)];\n  const cell=(m,g)=>mv.matrix.reduce((a,x)=>a+((((outOf(x)?'__OUTROS__':mkey(x.municipio,x.uf))===m)&&String(x.grupo)===g)?+x.cbs:0),0);\n  const mx=Math.max(...ms.flatMap(m=>mv.leaders.map(L=>cell(m,L.grupo))),1);\n  // No destination column here. This is the AS-IS matrix: every município in it still belongs to the\n  // closing sector, so showing where it is HEADED mixed the two states of the world in one table and\n  // made the ASIS view read like a plan. The destination lives on the map and in \"Municípios → setor\n  // destino\", which are about the to-be.\n  $('#matrix').innerHTML=`<thead><tr><th>Município</th>${mv.leaders.map(L=>`<th title=\"${L.lider}\">${L.grupo}</th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +ms.map(m=>`<tr><td>${m}</td>`\n     +mv.leaders.map(L=>{const v=cell(m,L.grupo);return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v||'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(mv.munis[m].cbs)}</td></tr>`).join('')+`</tbody>`;\n}\n// TOBE counterpart of the AS-IS matrix: the same município × grupo grid plus the two columns the AS-IS\n// view deliberately refuses to carry — where the município ends up, and under which líder.\n// \"Líder resultante\" is the líder holding the most CBs in that município. A município is served by several\n// grupos at once and the data names no single owner, so the dominant one is the honest answer and its\n// share is printed beside it instead of being hidden behind a single name.\nfunction renderMatrixTobe(){const mv=SESS.timeline[SESS.selected]; const el=$('#matrixTobe'); const nt=$('#tobeNote');\n  if(!el) return;\n  if(!mv||!mv.munis){ el.innerHTML=''; if(nt) nt.innerHTML=''; return; }\n  const c=computeDerived(mv);\n  const homeOf={}; c.leaders.forEach(L=>{ homeOf[L.grupo]=L.home; });\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  // One pass over the matrix instead of a scan per cell: the AS-IS table can afford the naive version\n  // on six municípios, but this one asks for the dominant líder too, which multiplies the lookups.\n  const outOf=x=>!mv.geoFeat[mkey(x.municipio,x.uf)];\n  const cellBy={}; mv.matrix.forEach(x=>{ const k=(outOf(x)?'__OUTROS__':mkey(x.municipio,x.uf))+'|'+String(x.grupo);\n    cellBy[k]=(cellBy[k]||0)+ +x.cbs; });\n  const cell=(m,g)=>cellBy[m+'|'+g]||0;\n  const mx=Math.max(...ms.flatMap(m=>mv.leaders.map(L=>cell(m,L.grupo))),1);\n  const domLeader=m=>{ let best=null,bc=0;\n    mv.leaders.forEach(L=>{ const v=cell(m,L.grupo); if(v>bc){ bc=v; best=L; } });\n    return best?{L:best,cbs:bc}:null; };\n  const short=L=>(L.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+L.grupo);\n  // NOT named `rows` — that is the module-level Databricks response helper, and shadowing it inside a\n  // render function is a trap for whoever adds a query here later.\n  const recs=ms.map(m=>{ const d=mv.assign[m]||null, dl=domLeader(m);\n    const home=dl?(homeOf[dl.L.grupo]||null):null;\n    // The mismatch worth seeing: the territory goes to one setor while the líder serving it ends up in\n    // another. Under \"manter vínculo\" those CBs stay with her, which is exactly what \"CBs fora do setor\"\n    // counts — so the → Setor cell carries the warning; her identity lives in the líder-per-grupo\n    // footer below, not in a per-município column.\n    return {m:m,d:d,dl:dl,home:home,off:!!(dl&&home&&d&&home!==d)}; });\n  el.innerHTML=`<thead><tr><th>Município</th><th>→ Setor</th>${mv.leaders.map(L=>`<th title=\"${esc(L.lider||'')}\">${L.grupo}</th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +recs.map(r=>`<tr><td>${r.m}</td>`\n     +`<td${r.off?' style=\"color:var(--closing)\"':''} title=\"${r.off?'a líder dominante deste município termina noutro setor':''}\">${r.d?`<span class=\"home\" style=\"background:${mv.color[r.d]}\"></span>${r.d}`:'–'}${r.off?' ⚠':''}</td>`\n     +mv.leaders.map(L=>{const v=cell(r.m,L.grupo);return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v||'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(mv.munis[r.m].cbs)}</td></tr>`).join('')\n   // One line at the very bottom of the table, not one per município: each grupo COLUMN belongs to\n   // exactly one líder — that is fixed per column, never varies row to row — so her name sits once,\n   // under her own column, instead of being repeated on every município she happens to dominate.\n   +`<tr class=\"lider-foot\"><td colspan=\"2\">Líder do grupo</td>`\n     +mv.leaders.map(L=>`<td title=\"${esc(L.lider||'')}\">${esc(short(L))}</td>`).join('')\n     +`<td></td></tr>`\n   +`</tbody>`;\n  if(nt){\n    const offN=recs.filter(r=>r.off).length;\n    nt.innerHTML=(mv.policy!=='fora')\n      ? `Política <b>quebrar relação</b>: as CBs deixam a líder atual e passam à estrutura do setor destino — a líder de cada grupo (rodapé) mostra quem as atende <b>hoje</b>, não quem assume depois.`\n      : `<b>Líder do grupo</b> = a líder titular de cada grupo (coluna), mostrada uma vez no rodapé da tabela. `\n        +(offN?`<span style=\"color:var(--closing)\">${fmt(offN)} município(s) ⚠</span> têm a líder dominante indo para um setor diferente do dela — as CBs seguem a líder (é disso que o KPI “CBs fora do setor” é feito).`\n          :`Em todos os municípios a líder dominante termina no mesmo setor que recebe o território.`);\n  }\n}\nfunction renderPolicy(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);\n  $('#policyNote').innerHTML=mv.policy==='fora'\n    ?`<b style=\"color:var(--acc)\">Mantendo vínculo:</b> ${fmt(c.desloc)} CBs seguem com a líder mas ficam <b>fisicamente noutro setor</b>. Preserva relação; suja geografia.`\n    :`<b style=\"color:var(--acc)\">Quebrando vínculo:</b> ${fmt(c.desloc)} CBs <b>re-vinculam a uma líder do setor destino</b>. Geografia limpa; rompe relação.`;\n}\nfunction renderPlan(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>a.localeCompare(b));\n  mv._plan=ms.map(m=>({municipio:mName(m),uf:mUf(m),cbs:mv.munis[m].cbs,destino:mv.assign[m]}));\n  $('#plan').innerHTML=`Etapa ${SESS.selected+1} · Setor <b>${mv.sector}</b> → ${ms.map(m=>`${m}→<b style=\"color:${mv.color[mv.assign[m]]}\">${mv.assign[m]||'–'}</b>`).join(' · ')}<br>\n   Política: <b>${mv.policy==='fora'?'manter vínculo (CBs fora do setor)':'quebrar relação'}</b> · ${fmt(c.desloc)} CBs impactadas nesta etapa.`\n   +(c.orphanTotal?`<br>Fora da base de dominantes: ${fmt(c.orphanTotal)} CB(s) seguem suas líderes — ${mv.dest.filter(s=>c.orph[s]).map(s=>`${s} (${fmt(c.orph[s])})`).join(' · ')||'–'}${c.orphanStranded?` · <b style=\"color:var(--closing)\">${fmt(c.orphanStranded)} sem líder com destino</b>`:''}. Não entram no CSV, que é por município.`:'');\n}\nfunction renderReport(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);\n  mv.metric=mv.metric||'cbs';\n  const KEYS=METRICS_DEF.map(m=>m[0]);\n  const incBy={};mv.dest.forEach(s=>incBy[s]=Object.fromEntries(KEYS.map(k=>[k,0])));\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(!s||!incBy[s])return;KEYS.forEach(k=>incBy[s][k]+=o[k]||0);});\n  // orphan CBs land on their leader's home sector, not on a geographic destination\n  c.leaders.forEach(L=>{ const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n    if(om&&L.home&&incBy[L.home]) KEYS.forEach(k=>incBy[L.home][k]+=om[k]||0); });\n  const closeTot={};KEYS.forEach(k=>closeTot[k]=Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,[,o])=>a+(o[k]||0),0));\n  // they leave the closing sector too, so antes → depois still balances\n  c.leaders.forEach(L=>{ const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n    if(om&&L.home) KEYS.forEach(k=>closeTot[k]+=om[k]||0); });\n  const trend=mv.trend.slice(-6);\n  const chartHTML=mk=>{\n    const isM=['receita','rec_nat','rec_avon'].includes(mk); const fN=isM?money:fmt;\n    const data=mv.dest.map(s=>({label:s,color:mv.color[s],antes:+(mv.baseMetrics[s]?.[mk]||0),depois:+(mv.baseMetrics[s]?.[mk]||0)+(incBy[s]?.[mk]||0)}));\n    data.push({label:mv.sector+'✕',color:'#ff3b30',antes:closeTot[mk]||0,depois:0});\n    const max=Math.max(...data.flatMap(d=>[d.antes,d.depois]),1);\n    const W=720,H=290,base=238,top=42,maxH=base-top,n=data.length,gw=(W-16)/n;\n    let g='';\n    data.forEach((d,i)=>{const cx=8+gw*i+gw/2,bw=Math.min(30,gw*0.30);\n      const ah=d.antes/max*maxH,dh=d.depois/max*maxH,ax=cx-bw-2,dx=cx+2;\n      g+=`<rect x=\"${ax.toFixed(1)}\" y=\"${(base-ah).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${ah.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\" opacity=\"0.35\"/>`;\n      g+=`<rect x=\"${dx.toFixed(1)}\" y=\"${(base-dh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${dh.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\"/>`;\n      g+=`<text x=\"${(ax+bw/2).toFixed(1)}\" y=\"${(base-ah-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9\" fill=\"#9a9aa8\">${fN(d.antes)}</text>`;\n      g+=`<text x=\"${(dx+bw/2).toFixed(1)}\" y=\"${(base-dh-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9.5\" font-weight=\"700\" fill=\"${d.color}\">${fN(d.depois)}</text>`;\n      const dp=d.antes?((d.depois-d.antes)/d.antes*100):(d.depois?100:0);\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"16\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"${dp>=0?'#34c759':'#ff3b30'}\">${dp>0?'+':''}${dp.toFixed(0)}%</text>`;\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"${base+16}\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"#1c1c2b\">${d.label}</text>`;});\n    return `<svg viewBox=\"0 0 ${W} ${H}\" style=\"width:100%\"><line x1=\"8\" y1=\"${base}\" x2=\"${W-8}\" y2=\"${base}\" stroke=\"#e7e7ef\"/>${g}</svg>`;\n  };\n  let h=`<div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap\">\n      <h2 style=\"margin:0\">Impacto antes → depois desta etapa</h2>\n      <select id=\"metricSel\">${METRICS_DEF.map(m=>`<option value=\"${m[0]}\" ${m[0]===mv.metric?'selected':''}>${m[1]}</option>`).join('')}</select></div>\n    <div class=\"note\" style=\"margin:2px 0 4px\">Barra clara = <b>antes desta etapa</b> (já incluindo etapas anteriores) · barra cheia = <b>depois</b> · <b style=\"color:var(--acc)\">${mv.sector}✕</b> = setor fechado nesta etapa · % = variação</div>\n    ${chartHTML(mv.metric)}`;\n  h+=`<h2 style=\"margin-top:16px\">Antes → Depois por setor receptor (snapshot congelado)</h2>\n    <div class=\"scroll\"><table><thead><tr><th>Setor</th>${METRICS_DEF.map(m=>`<th>${m[1]}</th>`).join('')}</tr></thead><tbody>`;\n  mv.dest.forEach(s=>{const b=mv.baseMetrics[s]||{};const inc=incBy[s];\n    h+=`<tr><td><span class=\"home\" style=\"background:${mv.color[s]}\"></span>${s}</td>`+\n      METRICS_DEF.map(([k])=>{const bv=+b[k]||0,av=bv+(inc[k]||0);const d=bv?((av-bv)/bv*100):0;const f=METRICS_DEF.find(x=>x[0]===k)[2]==='$'?money:fmt;\n        return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;}).join('')+`</tr>`;});\n  h+=`<tr style=\"opacity:.8\"><td>${mv.sector} (sai)</td>`+METRICS_DEF.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(closeTot[k]||0)}</td>`).join('')+`</tr>`;\n  h+=`</tbody></table></div>`;\n  const incLvl={};mv.dest.forEach(s=>incLvl[s]={});\n  Object.keys(mv.munis).forEach(m=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(!s||!incLvl[s])return;const lv=mv.levelByMuni[m]||{};for(const nv in lv)incLvl[s][nv]=(incLvl[s][nv]||0)+lv[nv];});\n  c.leaders.forEach(L=>{ const ol=mv.orphanLevelByGroup&&mv.orphanLevelByGroup[L.grupo];\n    if(ol&&L.home&&incLvl[L.home]) Object.entries(ol).forEach(([nv,n])=>incLvl[L.home][nv]=(incLvl[L.home][nv]||0)+n); });\n  h+=`<h2 style=\"margin-top:18px\">Consultoras por nível — antes → depois por setor receptor</h2>\n    <div class=\"scroll\"><table><thead><tr><th>Setor</th>${NIVEIS.map(n=>`<th>${n}</th>`).join('')}<th>Total</th></tr></thead><tbody>`;\n  mv.dest.forEach(s=>{const base=mv.baseLevel[s]||{},inc=incLvl[s]||{};let tb=0,ta=0;\n    h+=`<tr><td><span class=\"home\" style=\"background:${mv.color[s]}\"></span>${s}</td>`+\n      NIVEIS.map(nv=>{const b=base[nv]||0,i=inc[nv]||0,a=b+i;tb+=b;ta+=a;\n        return `<td>${a?fmt(a):'·'}${i?`<div class=\"delta up\">+${fmt(i)}</div>`:''}</td>`;}).join('')+\n      `<td style=\"font-weight:700\">${fmt(ta)}${ta-tb?`<div class=\"delta up\">+${fmt(ta-tb)}</div>`:''}</td></tr>`;});\n  h+=`</tbody></table></div><div class=\"note\">Número = CBs por nível <b>depois</b> desta etapa · <span style=\"color:var(--ok)\">+N</span> = entrantes do ${mv.sector}.</div>`;\n  const spk=(key)=>{const vals=trend.map(t=>+t[key]||0);const mx=Math.max(...vals,1);\n    return `<div class=\"spark\">${vals.map(v=>`<div class=\"b\" style=\"height:${(v/mx*100).toFixed(0)}%\" title=\"${fmt(v)}\"></div>`).join('')}</div>\n      <div class=\"note\">${trend.map(t=>String(t.ciclo).slice(-2)).join(' · ')}</div>`;};\n  h+=`<h2 style=\"margin-top:18px\">Setor ${mv.sector} — últimos ${trend.length} ciclos até o snapshot congelado</h2>\n    <div class=\"note\" style=\"margin:-4px 0 8px\">Tendência histórica <b>do próprio setor</b> — não ajustada pela absorção hipotética de etapas anteriores (limitação conhecida: tendência é por setor, não decomponível por município).</div>\n    <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:10px\">\n    ${[['cbs','CBs'],['ativas','Ativas'],['receita','Receita'],['pontos','Pontos']].map(([k,l])=>`<div><div class=\"note\" style=\"margin:0 0 3px;font-weight:600;color:var(--ink)\">${l}</div>${spk(k)}</div>`).join('')}</div>`;\n  $('#tab-report').innerHTML=h;\n  const sel=$('#metricSel'); if(sel) sel.onchange=()=>{mv.metric=sel.value; renderReport();};\n}\nfunction renderCumulative(){\n  const el=$('#tab-cumulative'); if(!el) return;\n  if(!SESS.timeline.length){ el.innerHTML='<div class=\"note\">Nenhuma etapa na timeline ainda.</div>'; return; }\n  const ready=SESS.timeline.every(mv=>mv.munis);\n  if(!ready){ el.innerHTML='<div class=\"note\">Aguardando etapas terminarem de calcular…</div>'; return; }\n  const finalWorld=foldWorldState(SESS.timeline,SESS.timeline.length);\n  const openSectors=[...new Set(Object.values(finalWorld.muniOverride))];\n  let h=`<h2 style=\"margin:0 0 8px\">Linha do tempo — ${SESS.timeline.length} fechamento(s)</h2>\n    <div class=\"note\" style=\"margin:-2px 0 10px\">Setores fechados, em ordem: ${SESS.timeline.map(mv=>mv.sector).join(' → ')}. Cada linha abaixo soma o efeito de <b>toda a cadeia</b>, não apenas da última etapa.</div>`;\n  h+=`<div class=\"scroll\"><table><thead><tr><th>Setor</th><th>Situação</th>${METRICS_DEF.map(([,l])=>`<th>${l}</th>`).join('')}</tr></thead><tbody>`;\n  SESS.timeline.forEach((mv,i)=>{\n    h+=`<tr style=\"opacity:.75\"><td>${mv.sector}</td><td>fechado (etapa ${i+1})</td>`+METRICS_DEF.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(mv.pureOwnMetrics?.[k]||0)}</td>`).join('')+`</tr>`;\n  });\n  openSectors.forEach(s=>{\n    const base=SESS.sectorBaseCache[s]||{};\n    const infl=finalWorld.sectorInflow[s]||{};\n    h+=`<tr><td>${s}</td><td>recebeu território</td>`+METRICS_DEF.map(([k,,t])=>{\n      const bv=+base[k]||0, av=bv+(infl[k]||0); const d=bv?((av-bv)/bv*100):0; const f=(t==='$'?money:fmt);\n      return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;\n    }).join('')+`</tr>`;\n  });\n  h+=`</tbody></table></div><div class=\"note\">\"Antes\" de um setor receptor é o snapshot congelado (sem nenhum fechamento aplicado); \"depois\" soma tudo que a cadeia inteira jogou pra ele.</div>`;\n  el.innerHTML=h;\n}\nfunction project(feats){let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  const each=cb=>feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(p=>p.forEach(r=>r.forEach(cb)));});\n  each(p=>{mnx=Math.min(mnx,p[0]);mxx=Math.max(mxx,p[0]);mny=Math.min(mny,p[1]);mxy=Math.max(mxy,p[1]);});\n  const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=700,H=500,pad=22;\n  const gw=(mxx-mnx)*kx,gh=(mxy-mny),sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n  const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n  return p=>[ox+(p[0]-mnx)*kx*sc, oy+(mxy-p[1])*sc];\n}\nfunction pathFor(f,pr){const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  return ps.map(poly=>poly.map(r=>'M'+r.map(p=>{const q=pr(p);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');}\nfunction cxy(f,pr){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(poly=>poly[0].forEach(p=>{const q=pr(p);x+=q[0];y+=q[1];n++;}));return[x/n,y/n];}\n// ---------- label placement ----------\n// Pole of inaccessibility: the interior point farthest from ANY boundary of the dissolved area. A\n// centroid is only a mean — on a concave sector, one split across a river, or a member whose vertex\n// average falls in a bay, it lands outside the very territory it is supposed to name, which is what\n// pushed the sector numbers off their own shapes. Even-odd across every ring of every member is a\n// correct union test here because municipalities tile rather than overlap.\n// The search box is clipped to the viewBox, so the result is inside the territory AND on screen.\n// Rings are decimated first — this is a label, not a measurement — which keeps a 20-município sector\n// to a few million ops, and the result is cached per stage: geometry never changes between renders,\n// only the fill does.\nfunction labelPointFor(fs,pr){\n  const rings=[];\n  (fs||[]).forEach(f=>{ if(!f||!f.geometry) return;\n    const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n    ps.forEach(poly=>poly.forEach(r=>{\n      if(!r||r.length<4) return;\n      const step=Math.max(1,Math.floor(r.length/120)),pts=[];\n      for(let i=0;i<r.length;i+=step) pts.push(pr(r[i]));\n      if(pts.length>3) rings.push(pts);\n    }));});\n  if(!rings.length) return null;\n  let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  mnx=Math.max(mnx,2); mny=Math.max(mny,2); mxx=Math.min(mxx,698); mxy=Math.min(mxy,498);\n  if(!(mxx>mnx&&mxy>mny)) return null;\n  const inside=(x,y)=>{ let c=false;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      if(((a[1]>y)!==(b[1]>y)) && (x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])) c=!c; }\n    return c; };\n  const edgeDist=(x,y)=>{ let m=Infinity;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      const dx=b[0]-a[0],dy=b[1]-a[1],L=dx*dx+dy*dy;\n      let t=L?(((x-a[0])*dx+(y-a[1])*dy)/L):0; t=t<0?0:(t>1?1:t);\n      const ex=a[0]+t*dx-x,ey=a[1]+t*dy-y,d=ex*ex+ey*ey; if(d<m)m=d; }\n    return Math.sqrt(m); };\n  const W=mxx-mnx,H=mxy-mny,N=26;\n  let best=null,bd=-1;\n  for(let i=0;i<=N;i++) for(let j=0;j<=N;j++){\n    const x=mnx+W*i/N,y=mny+H*j/N;\n    if(!inside(x,y)) continue;\n    const d=edgeDist(x,y); if(d>bd){ bd=d; best=[x,y]; }\n  }\n  if(!best) return null;\n  // Hill-climb off the coarse grid: the grid only has to land somewhere in the right lobe, the\n  // refinement finds the deep point inside it — halving the step whenever no neighbour improves.\n  let step=Math.max(W,H)/N/2;\n  for(let k=0;k<7;k++){\n    let moved=false;\n    for(const o of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]){\n      const x=best[0]+o[0]*step,y=best[1]+o[1]*step;\n      if(x<mnx||x>mxx||y<mny||y>mxy||!inside(x,y)) continue;\n      const d=edgeDist(x,y); if(d>bd){ bd=d; best=[x,y]; moved=true; }\n    }\n    if(!moved) step/=2;\n  }\n  // The clearance travels with the point: it is the radius of the largest circle that fits inside the\n  // territory there, which is exactly the budget a caller needs to decide how much text will fit.\n  return {p:best,r:bd};\n}\nfunction labelPointOf(mv,key,fs,pr){\n  if(!mv._lbl) mv._lbl={};\n  if(!(key in mv._lbl)) mv._lbl[key]=labelPointFor(fs,pr);\n  return mv._lbl[key];\n}\nfunction drawMap(){\n  const mv=SESS.timeline[SESS.selected]; const svg=$('#map'); const ignNote=$('#mapIgnoredNote');\n  if(!mv){ svg.innerHTML=''; if(ignNote) ignNote.innerHTML=''; return; }\n  const c=computeDerived(mv);const NS='http://www.w3.org/2000/svg'; svg.innerHTML='';\n  const feats=[...mv.nbrFeat,...Object.values(mv.geoFeat)]; if(!feats.length) return;\n  const pr=project(feats);\n  const world=foldWorldState(SESS.timeline,SESS.selected);\n  // The context layer is ONE area per SECTOR, not one shape per município. The unit of this decision\n  // is the destination sector; a scatter of individually outlined municipalities carrying their own\n  // names invited exactly the wrong reading of the map. Members of a sector are filled AND stroked in\n  // the same colour, so the shared borders vanish and the group reads as a single dissolved\n  // territory — the IBGE shapes do not share vertices precisely enough to extract a clean union\n  // outline, and carrying a real polygon-clipping union for a context layer is not worth it.\n  // Labels are COLLECTED here and drawn last, after every path. Drawn inline they were buried by the\n  // next sector's fill, and placement cannot avoid a collision it has not seen yet.\n  const labels=[],placed=[];\n  const textW=(s,fs)=>String(s).length*fs*0.56;\n  // WCAG relative luminance of the fill a label sits on, so ink and halo can invert per label. \"Would\n  // white be better?\" has no single answer on this map: white reads on #5856d6 (L≈0.14) and disappears\n  // on #ff9500 and #34c759 (both L≈0.43, where black wins ~9:1 against white's ~2.2:1) and on every\n  // context fill, which is .32 opacity over white. Decided per label, never globally.\n  const lum=col=>{let h=String(col||'').replace('#','');\n    if(h.length===3) h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];\n    if(h.length!==6) return 1;\n    const c=[0,2,4].map(i=>parseInt(h.slice(i,i+2),16)/255)\n      .map(v=>v<=0.03928?v/12.92:Math.pow((v+0.055)/1.055,2.4));\n    return 0.2126*c[0]+0.7152*c[1]+0.0722*c[2];};\n  // Centred on (x,y) in BOTH axes. The old box hung off the first line's baseline, so every two-line\n  // label sat half its own height below the point it was supposed to mark.\n  const rectOf=(x,y,lines)=>{const w=Math.max.apply(null,lines.map(l=>textW(l.t,l.size)));\n    const h=lines.reduce((a,l)=>a+l.size*1.2,0);\n    return {x1:x-w/2-2.5,x2:x+w/2+2.5,y1:y-h/2-1.5,y2:y+h/2+1.5};};\n  const nbrBySec={};\n  mv.nbrFeat.forEach(f=>{\n    const nm=mkey(f.properties.name||f.properties.NOME||f.properties.nome||'',f.__uf);\n    const s=String((mv.nbrCurrentSector&&mv.nbrCurrentSector[nm])||'');\n    (nbrBySec[s]=nbrBySec[s]||[]).push(f);\n  });\n  Object.keys(nbrBySec).forEach(s=>{\n    const fs=nbrBySec[s];\n    const isDest=!!s&&mv.dest.includes(s);\n    const closedCtx=!!s&&world.closedSectors.has(s);\n    const col=isDest?(mv.color[s]||null):null;\n    fs.forEach(f=>{\n      const p=document.createElementNS(NS,'path');\n      p.setAttribute('d',pathFor(f,pr));\n      p.setAttribute('class','sect'+(isDest?'':(closedCtx?' closed-ctx':' plain')));\n      if(col){ p.setAttribute('fill',col); p.setAttribute('stroke',col); }\n      svg.appendChild(p);\n    });\n    if(!s) return;\n    // One number per sector, at the pole of inaccessibility of its DISSOLVED area — the whole group\n    // of members at once, not a representative member, so the number sits in the visual middle of the\n    // territory the operator actually sees.\n    const lp=labelPointOf(mv,'s:'+s,fs,pr);\n    // Context areas always read LIGHT — .sect paints dest colours at fill-opacity .32 over white, and\n    // plain/closed-ctx are near-white greys — so a sector number never wants white ink, whatever colour\n    // the sector itself was assigned.\n    if(lp&&lp.p) labels.push({anchor:lp.p,big:true,dark:false,lines:[{t:s,size:11.5,weight:700}]});\n  });\n  const asis=mv.view==='asis';\n  // The UF prefix is information only when the stage actually straddles a border. On a single-UF setor\n  // it is the same three characters repeated on every label — a large share of the clutter and none of\n  // the meaning. The hover tooltip carries the UF either way.\n  const showUf=new Set(Object.keys(mv.geoFeat).map(k=>mUf(k)).filter(Boolean)).size>1;\n  Object.entries(mv.geoFeat).forEach(([m,f])=>{const p=document.createElementNS(NS,'path');p.setAttribute('d',pathFor(f,pr));\n    const fillCol=asis?CLOSE_COLOR:(mv.color[mv.assign[m]]||'#444');\n    p.setAttribute('class','muni');p.setAttribute('fill',fillCol);\n    if(!asis) p.onclick=()=>{editSelected(m2=>{const i=m2.dest.indexOf(m2.assign[m]);if(m2.dest.length)m2.assign[m]=m2.dest[(i+1)%m2.dest.length];m2.selMuni=m;});};\n    p.style.cursor=asis?'default':'pointer';\n    p.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';\n      t.innerHTML=asis?`<b>${mDisp(m)}</b><br>${fmt(mv.munis[m]?.cbs||0)} CBs · setor ${mv.sector}`:`<b>${mDisp(m)}</b><br>${fmt(mv.munis[m]?.cbs||0)} CBs → ${mv.assign[m]||'–'}<br><span style=\"color:var(--mut)\">clique p/ trocar</span>`;};\n    p.onmouseleave=()=>$('#tip').style.opacity=0;svg.appendChild(p);\n    // The name is never cut and never dropped. The pole of inaccessibility is still where the label\n    // WANTS to sit — the visual middle of the territory rather than a centroid that can fall outside a\n    // concave shape — but its clearance no longer gates anything: cutting BA-SAO SEBASTIAO DO PASSE\n    // down to BA-SAO SEBAST saved space by destroying the one thing the label existed to convey, and\n    // measuring the name against the inscribed circle rejected the centre for nearly every município.\n    const lp=labelPointOf(mv,'m:'+m,[f],pr);\n    const at=(lp&&lp.p)||cxy(f,pr);\n    const nmFull=(showUf&&mUf(m)?mUf(m)+'-':'')+mName(m);\n    const num=fmt(mv.munis[m]?.cbs||0)+(asis?'':'·'+(mv.assign[m]||'–'));\n    labels.push({anchor:at,cbs:+(mv.munis[m]?.cbs||0),dark:lum(fillCol)<0.18,\n      lines:[{t:nmFull,size:8.2,weight:700},{t:num,size:7,weight:500,mut:true}]});\n  });\n  // Placement, not de-cluttering. Every label IS drawn: it starts at its anchor and, if the text does\n  // not fit the territory or the slot is already taken, walks outward ring by ring until it finds\n  // clear canvas, trailing a hairline leader back to the shape it names. The previous pass dropped the\n  // loser of every collision outright, which is why municípios silently had no name at all.\n  // Order still matters — sector numbers first, then municípios by CB weight — because the earlier a\n  // label is placed the closer to home it lands.\n  const CW=700,CH=500,CPAD=2;\n  const inBounds=r=>r.x1>=CPAD&&r.x2<=CW-CPAD&&r.y1>=CPAD&&r.y2<=CH-CPAD;\n  const hits=r=>placed.reduce((a,q)=>a+((r.x2<q.x1||r.x1>q.x2||r.y2<q.y1||r.y1>q.y2)?0:1),0);\n  // Ring 0 is the anchor itself. Rings step 9u out over 16 directions, stretched horizontally because\n  // a label is wider than it is tall and sideways room is the room it actually needs.\n  const CAND=[[0,0]];\n  for(let ring=1;ring<=7;ring++){ const d=ring*9;\n    for(let a=0;a<16;a++){ const th=a*Math.PI/8; CAND.push([Math.cos(th)*d*1.35,Math.sin(th)*d]); } }\n  labels.sort((a,b)=>(b.big?1:0)-(a.big?1:0)||((b.cbs||0)-(a.cbs||0)));\n  labels.forEach(L=>{\n    let chosen=null,best=null,bestN=Infinity;\n    // Centred on the anchor is ALWAYS the first choice. An earlier cut here refused the anchor whenever\n    // the text was wider than the município's inscribed circle — true of almost every real name at this\n    // scale (BA-SAO SEBASTIAO DO PASSE is ~140u against ~80u of clearance), so every single label got\n    // nudged outside and nothing ended up centred on anything. Spilling past a border is normal on a\n    // choropleth and the halo keeps it readable; only a genuine COLLISION earns a move.\n    for(const off of CAND){\n      const x=L.anchor[0]+off[0], y=L.anchor[1]+off[1];\n      const r=rectOf(x,y,L.lines);\n      if(!inBounds(r)) continue;\n      const n=hits(r);\n      if(!n){ chosen={x,y,r,off}; break; }\n      // No clear slot yet — remember the LEAST crowded one. Falling back to the first in-bounds\n      // candidate meant falling back to the anchor every time, so a crowded map piled every loser onto\n      // the same point. CAND is ordered nearest-first and the compare is strict, so ties keep the\n      // closest slot.\n      if(n<bestN){ bestN=n; best={x,y,r,off}; }\n    }\n    // Canvas genuinely full: take the nearest in-bounds slot and accept the overlap. A label sitting\n    // on top of another is recoverable by eye; a name that was never drawn is not.\n    if(!chosen) chosen=best||{x:L.anchor[0],y:L.anchor[1],r:rectOf(L.anchor[0],L.anchor[1],L.lines),off:[0,0]};\n    placed.push(chosen.r);\n    const g=document.createElementNS(NS,'g'); g.setAttribute('class','lbl'+(L.big?' big':''));\n    if(Math.abs(chosen.off[0])>0.5||Math.abs(chosen.off[1])>0.5){\n      // The leader stops at the label's edge rather than its centre, so it never runs under the glyphs.\n      const cx=(chosen.r.x1+chosen.r.x2)/2, cy=(chosen.r.y1+chosen.r.y2)/2;\n      const hw=(chosen.r.x2-chosen.r.x1)/2, hh=(chosen.r.y2-chosen.r.y1)/2;\n      const dx=L.anchor[0]-cx, dy=L.anchor[1]-cy;\n      const tt=Math.min(dx?hw/Math.abs(dx):Infinity, dy?hh/Math.abs(dy):Infinity);\n      const ln=document.createElementNS(NS,'line'); ln.setAttribute('class','lead');\n      ln.style.stroke=L.dark?'#fff':'#1c1c1e';\n      ln.setAttribute('x1',L.anchor[0].toFixed(1)); ln.setAttribute('y1',L.anchor[1].toFixed(1));\n      ln.setAttribute('x2',(cx+dx*tt).toFixed(1)); ln.setAttribute('y2',(cy+dy*tt).toFixed(1));\n      g.appendChild(ln);\n    }\n    const hTot=L.lines.reduce((a,l)=>a+l.size*1.2,0);\n    let ty=chosen.y-hTot/2+L.lines[0].size*0.86;\n    // Inline style rather than a class: a stylesheet rule would outrank a presentation attribute and\n    // undo the per-label contrast decision made above.\n    const ink=L.dark?'#fff':'#1c1c1e', inkMut=L.dark?'rgba(255,255,255,.85)':'#48484d';\n    const halo=L.dark?'rgba(0,0,0,.6)':'#fff';\n    L.lines.forEach(l=>{ const t=document.createElementNS(NS,'text');\n      t.setAttribute('x',chosen.x.toFixed(1)); t.setAttribute('y',ty.toFixed(1));\n      t.setAttribute('font-size',l.size); t.setAttribute('font-weight',l.weight||600);\n      t.style.fill=l.mut?inkMut:ink; t.style.stroke=halo;\n      t.textContent=l.t; g.appendChild(t); ty+=l.size*1.2; });\n    svg.appendChild(g);\n  });\n  $('#legend').innerHTML=asis\n    ?`<span><b style=\"background:${CLOSE_COLOR}\"></b>Setor ${mv.sector} (estrutura antes desta etapa — ${Object.keys(mv.geoFeat).length} municípios)</span>`\n    // No \"vizinhos (contexto)\" or \"setor já fechado antes\" entry any more: every feature still drawn\n    // belongs to a destination sector, so .sect.plain and .sect.closed-ctx can no longer be produced\n    // and a legend key for them would point at nothing on the map.\n    :mv.dest.map(s=>`<span><b style=\"background:${mv.color[s]}\"></b>Setor ${s} — ${c.gained[s]} líder(es)</span>`).join('');\n  // Municipalities outside the dominance base are deliberately NOT drawn — no shape, no hover, no\n  // legend entry. They are reported in the panel instead, so the map only ever shows territory that\n  // is actually being reallocated.\n  if(ignNote) ignNote.innerHTML=(mv.excludedMunis&&mv.excludedMunis.length)\n    ? `<b style=\"color:var(--closing)\">${fmt(mv.excludedMunis.length)} município(s) / ${fmt(mv.excludedCbs||0)} CBs</b> fora da base de dominantes deste setor não são desenhados no mapa — as CBs seguem a líder do seu grupo (detalhe em “Municípios → setor destino”).`\n    : '';\n}\nasync function runSql(){const sql=$('#sqlIn').value.trim();$('#sqlStatus').textContent='rodando…';$('#sqlStatus').classList.remove('err');\n  try{ await ensurePinned(); const resp=await q(sql);const rs=rows(resp);const cols=(resp.manifest?.schema?.columns||[]).map(c=>c.name);\n    $('#sqlOut').innerHTML=`<thead><tr>${cols.map(c=>`<th>${c}</th>`).join('')}</tr></thead><tbody>`+rs.slice(0,200).map(r=>`<tr>${cols.map(c=>`<td>${r[c]??'·'}</td>`).join('')}</tr>`).join('')+`</tbody>`;\n    $('#sqlStatus').textContent=rs.length+' linha(s)';}\n  catch(e){$('#sqlStatus').textContent='bloqueado/erro: '+e.message;$('#sqlStatus').classList.add('err');}\n}\nfunction renderAll(){\n  renderStage();\n  renderTimeline(); renderSectorPicker();\n  const shb=$('#openShare'); if(shb) shb.disabled=!SESS.timeline.length;\n  const dpb=$('#dupSession'); if(dpb) dpb.disabled=!SESS.timeline.length;\n  const mv=SESS.timeline[SESS.selected];\n  // resolveSnapshotPin falls back to a live sub-select when it cannot pin a literal; saying\n  // \"congelado\" in that case would be a lie, so the pill reports what is actually in the SQL.\n  const pinned=SESS.snapLiteral && !/^\\(SELECT/i.test(SESS.snapLiteral);\n  $('#liveState').textContent = SESS.snapshotRaw\n    ? (pinned?'● snapshot congelado · ':'◍ snapshot ao vivo (não fixado) · ')+'atualizado em '+fmtSyncedAt(SESS.snapshotSyncedAt)+' · ↻'\n    : '● iniciando';\n  const mp=$('#basePill');\n  if(mp){\n    const warn=[];\n    if(DOM.bySetor&&!DOM.hasUf) warn.push('⚠ sem coluna UF');\n    if(DOM.badUf) warn.push('⚠ '+fmt(DOM.badUf)+' UF não reconhecida(s)');\n    if(DOM.clashes) warn.push('⚠ '+fmt(DOM.clashes)+' homônimo(s) no mesmo setor');\n    mp.textContent=DOM.bySetor?('◇ base dominantes: '+fmt(DOM.setores)+' setores · '+fmt(DOM.rows)+' municípios'+(warn.length?' · '+warn.join(' · '):'')):(DOM.error?'◇ base dominantes: indisponível':'◇ base dominantes: carregando…');\n    mp.title=DOM.error||(DOM.bySetor?((DOM.hasUf\n        ?('Coluna de UF usada: \"'+DOM.ufCol+'\".')\n        :('SEM coluna de UF. Colunas lidas: '+((DOM.headers||[]).join(' | ')||'?')+'. A UF de cada município vem desta base — sem ela, homônimos voltam a ser casados só pelo nome.'))\n      +(DOM.badUf?('\\n'+DOM.badUf+' linha(s) com UF irreconhecível — tratadas como desconhecida.'):'')\n      +(DOM.clashes?('\\n'+DOM.clashes+' município(s) aparecem no mesmo setor com UFs diferentes. O SQL agrupa só por DS_MUNICIPIO, então esses chegam somados numa linha única e não há como separá-los.'):'')\n      ):'')||DOM.url||''; }\n  const rp=$('#rulePill'); if(rp) rp.textContent='◈ destino: '+ruleShort(SESS.settings);\n  const sp=$('#spanPill'); if(sp) sp.textContent='◎ span: '+spanShort(SESS.settings);\n  renderRuleNote(); renderSpanRule();\n  if(!mv){ ['#map','#matrix','#matrixTobe','#tobeNote','#leaders'].forEach(s=>{const el=$(s);if(el)el.innerHTML='';}); $('#kpis').innerHTML=''; $('#spans').innerHTML=''; $('#muniList').innerHTML=''; $('#plan').innerHTML=''; return; }\n  $('#mapTitle').textContent=`Etapa ${SESS.selected+1}/${SESS.timeline.length} — fechando setor ${mv.sector}`;\n  $('#viewToggle').querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.v===mv.view));\n  $('#policy').querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.p===mv.policy));\n  $('#optBadge').textContent=mv.optBadge||'';\n  drawMap(); renderContig(); renderSpanRule(); renderKpis(); renderSpans(); renderMuni(); renderLeaders(); renderMatrix(); renderMatrixTobe(); renderPolicy(); renderPlan(); renderReport(); renderCumulative();\n}\n\n// ---------- event wiring ----------\n$('#go').onclick=()=>addMovement($('#sectorPick').value);\n$('#confirmSnap').onclick=()=>{ SESS.stage='main'; renderAll(); };\n$('#rand').onclick=()=>{\n  const closed=new Set(SESS.timeline.map(mv=>mv.sector));\n  const pool=SESS.sectorsList.filter(s=>!closed.has(s.setor));\n  const cand=pool.filter(s=>s.munis>=3&&s.munis<=10&&s.cbs>300);\n  const list=cand.length?cand:pool; if(!list.length) return;\n  const pick=list[Math.floor(Math.random()*list.length)];\n  $('#sectorPick').value=pick.setor;\n  addMovement(pick.setor);\n};\n$('#sectorPick').addEventListener('keydown',e=>{ if(e.key==='Enter') addMovement($('#sectorPick').value); });\n$('#confirmSessionPick').onchange=()=>loadSession($('#confirmSessionPick').value);\n$('#optim').onclick=()=>editSelected(mv=>{ mv.assign=optimizeFor(mv); });\n$('#autoTobe').onclick=()=>editSelected(mv=>{ mv.assign=autoAssignFor(mv); mv.optBadge='TOBE por adjacência'; });\n$('#viewToggle').querySelectorAll('button').forEach(b=>b.onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;mv.view=b.dataset.v;renderAll();});\n$('#policy').querySelectorAll('button').forEach(b=>b.onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;mv.policy=b.dataset.p;renderKpis();renderPolicy();renderPlan();});\nroot.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('on',x===b));\n  ['matrix','tobe','report','cumulative','sql'].forEach(t=>$('#tab-'+t).classList.toggle('hidden',t!==b.dataset.tab));});\n$('#runSql').onclick=runSql;\n$('#copyPlan').onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;navigator.clipboard.writeText(JSON.stringify({setor:mv.sector,politica:mv.policy,plano:mv._plan},null,2));$('#copyPlan').textContent='copiado ✓';setTimeout(()=>$('#copyPlan').textContent='Copiar',1500);};\n$('#csvPlan').onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;const csv='municipio,uf,cbs,setor_destino\\n'+mv._plan.map(p=>`${p.municipio},${p.uf||''},${p.cbs},${p.destino}`).join('\\n');\n  const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));a.download=`plano_migracao_setor_${mv.sector}.csv`;a.click();};\n$('#saveSession').onclick=saveSession;\n$('#dupSession').onclick=duplicateSession;\n$('#sessionPick').onchange=()=>loadSession($('#sessionPick').value);\n$('#liveState').onclick=refreshSnapshot;\n$('#liveState').addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); refreshSnapshot(); } });\n$('#openOptions').onclick=openOptions;\n$('#optClose').onclick=closeOptions;\n$('#optCancel').onclick=closeOptions;\n$('#optApply').onclick=applyOptions;\n$('#optMinCbs').addEventListener('input',renderOptPreview);\n['#optTargetSpan','#optMaxSpan','#optMinSpan'].forEach(id=>$(id).addEventListener('input',renderOptPreview));\nroot.querySelectorAll('input[name=\"destRule\"]').forEach(r=>r.addEventListener('change',renderOptPreview));\n$('#optionsBg').addEventListener('click',e=>{ if(e.target.id==='optionsBg') closeOptions(); });\nroot.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeOptions(); closeShare(); } });\n\n$('#openShare').onclick=openShare;\n$('#shareX').onclick=closeShare;\n$('#shareCancel').onclick=closeShare;\n$('#shareGo').onclick=doShare;\n$('#shareCopy').onclick=()=>{ if(!SHARE.url) return; navigator.clipboard.writeText(SHARE.url);\n  const b=$('#shareCopy'); if(b){ b.textContent='copiado ✓'; setTimeout(()=>{ const x=$('#shareCopy'); if(x) x.textContent='Copiar link'; },1500); } };\n$('#shareOpen').onclick=()=>{ if(SHARE.url) window.open(SHARE.url,'_blank'); };\n$('#shareBg').addEventListener('click',e=>{ if(e.target.id==='shareBg') closeShare(); });\n\n// ---------- init ----------\n(async()=>{\n  loader(true,'congelando snapshot da sessão…');\n  try{\n    await Promise.all([ensurePinned(), loadSectors(), refreshSessionPicker()]);\n    SESS.stage='confirm';\n  }catch(e){ setError('erro init: '+e.message); }\n  finally{ loader(false); renderAll(); const rd=$('#ready'); if(rd) rd.setAttribute('data-loaded','1'); }\n})();\n\n  }\n}\n"}},"sessionsDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"sessionsDataset"}}}},"dominantesCsvUrl":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"dominantesCsvUrl"}}}}}}}},"extends":"data2-core-primitives@Block"}},{"_id":"6a7d08050c6a9916bf509fb5","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"xoLKR_Mb_n2vkwfmdExLH","package":"natura-geomarketing","extends":"data2-core-primitives@Block","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const T='product.perfil_cb.crm_perfil_cb_diaria_historico';\nconst ELO='product.elo_modelo_combinado.commercial_consultant_index';\nconst PALETTE=['#5856d6','#ff9500','#34c759','#af52de','#ff2d55','#a2845e'];\nconst CLOSE_COLOR='#8e8e93';\nconst UF_CODE={RO:'11',AC:'12',AM:'13',RR:'14',PA:'15',AP:'16',TO:'17',MA:'21',PI:'22',CE:'23',RN:'24',PB:'25',PE:'26',AL:'27',SE:'28',BA:'29',MG:'31',ES:'32',RJ:'33',SP:'35',PR:'41',SC:'42',RS:'43',MS:'50',MT:'51',GO:'52',DF:'53'};\nconst UF_BORDERS={AC:['AM','RO'],AL:['BA','PE','SE'],AM:['AC','RO','RR','PA','MT'],AP:['PA'],\n BA:['AL','SE','PE','PI','TO','GO','MG','ES'],CE:['PI','PB','PE','RN'],DF:['GO','MG'],\n ES:['BA','MG','RJ'],GO:['DF','MT','MS','MG','BA','TO'],MA:['PA','TO','PI'],\n MG:['BA','GO','DF','MS','SP','RJ','ES'],MS:['MT','GO','MG','SP','PR'],MT:['RO','AM','PA','TO','GO','MS'],\n PA:['AP','AM','MT','TO','MA','RR'],PB:['RN','CE','PE'],PE:['PB','CE','PI','BA','AL'],\n PI:['MA','TO','BA','PE','CE'],PR:['SP','MS','SC'],RJ:['ES','MG','SP'],RN:['CE','PB'],\n RO:['AC','AM','MT'],RR:['AM','PA'],RS:['SC'],SC:['PR','RS'],SE:['BA','AL'],\n SP:['MG','RJ','PR','MS'],TO:['PA','MA','PI','BA','GO','MT']};\nconst NIVEIS=['Bronze','Cristal','Prata','Ouro','Diamante','Diamante+'];\nconst METRICS_DEF=[['cbs','CBs'],['ativas','Ativas'],['pedidos','Pedidos'],['pontos','Pontos'],['receita','Receita','$'],['rec_nat','Rec. Natura','$'],['rec_avon','Rec. Avon','$']];\nconst OPT_K=1.5;\nconst SESSIONS_DS='timeline-shared-sessionsDataSet';   // same table as SHARED_DS — see notes at listSessions()\n// Span defaults are the real operating band for a setor receptor, not placeholders: 2200 ≤ span ≤ 3500\n// with 2700 as the target. null meant \"sem limite\", so every new session started with the optimiser\n// balancing against nothing. Existing sessions are NOT touched — their persisted settings carry explicit\n// values that spread OVER these in openOptions/applyOptions, which is deliberate: their etapas were\n// computed under the old band and changing it silently would leave the stored result and the rule that\n// produced it out of sync.\nconst DEFAULT_SETTINGS={destRule:'any',destMinCbs:9,targetSpan:2700,maxSpan:3500,minSpan:2200};\nconst DOM_MEDIA_BASE='https://natura-geomarketing.data2.media/file/';\nconst DOM_CSV_FALLBACK=DOM_MEDIA_BASE+'yw_TQdp0QXEk6JsrliAqP'; \nconst numOrNull=v=>{const n=Math.round(+v);return (isFinite(n)&&n>0)?n:null;};\nconst spanShort=st=>{const p=[];if(st&&st.targetSpan)p.push('alvo '+st.targetSpan);if(st&&st.maxSpan)p.push('máx '+st.maxSpan);if(st&&st.minSpan)p.push('mín '+st.minSpan);return p.length?p.join(' · '):'livre';};\nconst ruleShort=st=>((st&&st.destRule)==='dominant')?('dominantes ≥'+((st&&st.destMinCbs)||DEFAULT_SETTINGS.destMinCbs)+' CBs'):'qualquer municipio';\n\nexport default {\n  css: `\n  :host{display:block;height:100%;--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--acc2:#0060df;--closing:#ff3b30;--ok:#34c759}\n  *{box-sizing:border-box}\n  .simroot{position:relative;height:100%;overflow:auto;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}\n  header{padding:10px 16px;background:rgba(246,246,248,.85);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-wrap:wrap}\n  header h1{font-size:14.5px;margin:0;font-weight:600;letter-spacing:-.1px;color:var(--ink)}\n  .mac-spacer{flex:1}\n  .howto{padding:8px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--mut);align-items:center}\n  .howto b{color:var(--ink)} .howto span{max-width:340px}\n  .pill{font-size:10.5px;padding:2.5px 9px;border-radius:99px;border:1px solid var(--line);color:var(--mut);background:var(--panel2)}\n  .pill.live{color:var(--ink);cursor:pointer;user-select:none;transition:border-color .1s,color .1s,background .1s}\n  .pill.live:hover{border-color:var(--acc);color:var(--acc);background:#eaf3ff}\n  .pill.live:focus-visible{outline:2px solid rgba(0,122,255,.35);outline-offset:1px}\n  .pill.live.busy{border-color:var(--acc);color:var(--acc);cursor:progress}\n  .status{font-size:11px;color:var(--mut)}.status.err{color:var(--closing)}\n  select,input,textarea,button{font:inherit}\n  select,input{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:7px;padding:5.5px 9px}\n  select:focus,input:focus{outline:2px solid rgba(0,122,255,.35);outline-offset:0;border-color:var(--acc)}\n  button.act{background:var(--acc);color:#fff;border:1px solid transparent;border-radius:7px;padding:6px 13px;font-weight:600;cursor:pointer;box-shadow:0 1px 1px rgba(0,0,0,.08)}\n  button.act:hover{background:var(--acc2)}\n  button.ghost{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5.5px 12px;cursor:pointer}\n  button.ghost:hover{background:var(--panel2)}\n  button.ghost.on{border-color:var(--acc);color:var(--acc);background:#eaf3ff}\n  button.ghost:disabled,button.act:disabled{opacity:.4;cursor:not-allowed}\n  .sessbar{padding:9px 20px;background:var(--panel2);border-bottom:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap;align-items:center}\n  .rail{padding:9px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:8px;align-items:center;overflow-x:auto}\n  #movementChips{display:flex;gap:8px;flex:0 0 auto}\n  #addMovementGroup{display:flex;gap:8px;align-items:center;flex:0 0 auto;margin-left:auto;padding-left:12px;border-left:1px solid var(--line)}\n  #addMovementGroup input{min-width:150px}\n  .tchip{display:flex;align-items:center;gap:7px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;cursor:pointer;font-size:12px;flex:0 0 auto}\n  .tchip.on{border-color:var(--acc);background:#eaf3ff}\n  .tchip.stale{border-style:dashed;opacity:.85}\n  .tchip .tstale{color:#ff9500;font-weight:700}\n  .tchip .tnum{background:var(--ink);color:#fff;border-radius:99px;width:16px;height:16px;font-size:9.5px;display:flex;align-items:center;justify-content:center;font-weight:700}\n  .tchip .tsec{font-weight:600}\n  .tchip .tstat{color:var(--mut)}\n  .tchip .trm{color:var(--mut);font-weight:700;padding:0 3px}\n  .tchip .trm:hover{color:var(--closing)}\n  .rail .empty{color:var(--mut);font-size:12px}\n  .wrap{display:grid;grid-template-columns:minmax(360px,1fr) 430px;gap:14px;padding:14px 16px;align-items:start}\n  @media(max-width:1150px){.wrap{grid-template-columns:1fr}}\n  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;margin-bottom:14px;box-shadow:0 1px 2px rgba(0,0,0,.04)}\n  .card h2{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);margin:0 0 9px;font-weight:600}\n  /* user-select, not pointer-events. .lbl already sets pointer-events:none, which stops the text from\n     RECEIVING clicks but does not exclude it from the browser's selection range — so cycling a\n     município's destination with repeated clicks kept dragging a double-click selection across the\n     labels. Nothing drawn in an svg here is text anyone needs to copy. */\n  svg{width:100%;height:auto;display:block;user-select:none;-webkit-user-select:none}\n  .map-wrap{position:relative}\n  #map{cursor:grab;touch-action:none}\n  #map.dragging{cursor:grabbing}\n  .zoom-pill{position:absolute;left:10px;bottom:10px;display:flex;align-items:stretch;background:var(--panel);border:1px solid var(--line);border-radius:99px;box-shadow:0 2px 8px rgba(0,0,0,.14);overflow:hidden;z-index:3}\n  .zoom-pill button{width:30px;height:30px;border:0;background:transparent;color:var(--ink);font:16px/1 -apple-system,BlinkMacSystemFont,sans-serif;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center}\n  .zoom-pill button:hover{background:var(--panel2)}\n  .zoom-pill button:active{background:#e5e5ea}\n  .zoom-pill .zoom-div{width:1px;background:var(--line)}\n  /* White hairlines inside the closing sector: the municipios read as one territory cut into parts\n     rather than as separate outlined units, and the saturated fills carry the grouping instead of the\n     ink. Neighbour sectors keep their own coloured strokes (.sect) — that contrast is what separates\n     what is being reallocated from what is only context. Selection no longer draws an outline; the\n     white border was the click affordance AND the default is now white, so it had nothing left to say. */\n  .muni{stroke:#fff;stroke-width:1.1;cursor:pointer;transition:filter .1s}.muni:hover{filter:brightness(1.15)}.muni.munihl{filter:brightness(1.15)}\n  .sect{stroke-width:1;fill-opacity:.32;stroke-opacity:.6}\n  .sect.plain{fill:#e8e8ec;stroke:#d4d4d9;fill-opacity:1;stroke-opacity:1}\n  .sect.closed-ctx{fill:#e2e2e7;stroke:#aeaeb2;stroke-dasharray:3 2;fill-opacity:1;stroke-opacity:1}\n  .lbl{pointer-events:none}\n  /* No plate behind the text: a halo painted UNDER the glyphs (paint-order) keeps a name legible on any\n     fill without stamping an opaque box over the territory it names. Ink and halo are chosen per label\n     from the luminance of the fill underneath, so they are deliberately NOT declared here — a\n     stylesheet rule outranks a presentation attribute and would silently override that choice. */\n  .lbl text{text-anchor:middle;paint-order:stroke fill;stroke-width:2.2px;stroke-linejoin:round;stroke-linecap:round}\n  .lbl.big text{stroke-width:2.9px}\n  .lbl line.lead{stroke-opacity:.5;stroke-width:.8}\n  .legend{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;font-size:12px}\n  .legend b{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:5px;vertical-align:-1px}\n  .kpis{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-bottom:12px}\n  .kpi{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:9px 11px}\n  .kpi .n{font-size:19px;font-weight:700}.kpi .l{font-size:10.5px;color:var(--mut);margin-top:2px}\n  .kpi.warn{border-color:#ffd1cc}.kpi.warn .n{color:var(--closing)}.kpi.ok{border-color:#bdeecb}.kpi.ok .n{color:var(--ok)}\n  .bar-row{display:flex;align-items:center;gap:8px;margin:6px 0}\n  .bar-row .tag{width:42px;font-weight:600;font-size:12px}\n  .bar-track{flex:1;height:18px;background:var(--panel2);border-radius:5px;overflow:hidden;display:flex;position:relative}\n  .bar-mark{position:absolute;top:0;bottom:0;width:2px;opacity:.9;z-index:2}\n  .bar-base{height:100%;opacity:.4}.bar-inc{height:100%}\n  .bar-val{width:132px;text-align:right;font-size:11px;color:var(--mut);font-variant-numeric:tabular-nums}\n  table{border-collapse:collapse;width:100%;font-size:11.5px}\n  th,td{padding:5px 6px;text-align:right;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}\n  th:first-child,td:first-child{text-align:left}\n  thead th{color:var(--mut);font-weight:600;font-size:10.5px;position:sticky;top:0;background:var(--panel)}\n  .home{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:4px}\n  .muni-item{display:flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:5px 8px;margin-bottom:6px}\n  .muni-item .nm{flex:1;font-weight:600;font-size:12px}.muni-item .ct{color:var(--mut);font-size:11px}\n  .seg{display:flex;border:1px solid var(--line);border-radius:7px;overflow:hidden}\n  .seg button{border:0;background:transparent;color:var(--mut);padding:4px 8px;font-size:11px;font-weight:600;cursor:pointer}\n  .seg button.on{color:#fff}\n  .toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}\n  .toggle{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;font-size:12px}\n  .toggle button{background:transparent;border:0;color:var(--mut);padding:6px 10px;cursor:pointer;font-weight:600}\n  .toggle button.on{background:var(--panel2);color:var(--ink)}\n  .note{color:var(--mut);font-size:10.5px;margin-top:7px;line-height:1.5}\n  .cell{font-variant-numeric:tabular-nums}\n  .lider-foot td{background:var(--panel2);font-weight:600;font-size:11px;color:var(--ink);border-bottom:2px solid var(--line);padding:5px 6px;text-align:left}\n  .scroll{max-height:240px;overflow:auto}\n  /* Tables meant to be READ in full rather than sampled. Only the vertical cap is dropped — overflow\n     stays auto so the wide grupo grid can still scroll sideways inside its card, which is the one axis\n     that genuinely cannot fit. */\n  .scroll.grow{max-height:none}\n  .tip{position:fixed;pointer-events:none;background:#1c1c1e;color:#fff;border-radius:8px;padding:7px 9px;font-size:11px;opacity:0;transition:opacity .1s;z-index:9;max-width:230px}\n  .spark{display:flex;align-items:flex-end;gap:3px;height:44px}\n  .spark .b{flex:1;background:var(--acc);border-radius:2px 2px 0 0;min-height:2px;opacity:.85}\n  .delta{font-size:10px}.delta.up{color:var(--ok)}.delta.dn{color:var(--closing)}\n  .tabs{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}\n  .tabs button{background:transparent;border:1px solid var(--line);border-radius:8px;color:var(--mut);padding:6px 12px;cursor:pointer;font-weight:600}\n  .tabs button.on{background:var(--panel2);color:var(--ink);border-color:var(--acc)}\n  .hidden{display:none}\n  .sqlbox{width:100%;height:60px;font-family:ui-monospace,Menlo,monospace;font-size:11px;resize:vertical}\n  .load{position:absolute;inset:0;background:rgba(245,245,247,.85);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:20;font-size:13px;color:var(--acc);font-weight:600;text-align:center;padding:20px}\n  .load.hidden{display:none}\n  .badge{font-size:10px;padding:1px 6px;border-radius:5px;background:var(--panel2);border:1px solid var(--line);color:var(--mut)}\n  .sheet-bg{position:fixed;inset:0;background:rgba(28,28,30,.34);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;z-index:40;padding:24px}\n  .sheet-bg.hidden{display:none}\n  .sheet{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:0 24px 60px rgba(0,0,0,.22);width:min(580px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden}\n  .sheet-hd{display:flex;align-items:center;gap:9px;padding:12px 16px;border-bottom:1px solid var(--line)}\n  .sheet-hd h3{margin:0;font-size:14px;font-weight:600;letter-spacing:-.1px}\n  .sheet-x{background:transparent;border:0;color:var(--mut);font-size:13px;cursor:pointer;padding:3px 7px;border-radius:6px}\n  .sheet-x:hover{background:var(--panel2);color:var(--ink)}\n  .sheet-bd{padding:14px 16px;overflow:auto}\n  .sheet-ft{display:flex;align-items:center;gap:8px;padding:11px 16px;border-top:1px solid var(--line);background:var(--panel2)}\n  .opt-lbl{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600}\n  .opt-sub{font-size:11.5px;color:var(--mut);margin:4px 0 10px;line-height:1.5}\n  .opt-row{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-bottom:8px;cursor:pointer;background:var(--panel)}\n  .opt-row:hover{background:var(--panel2)}\n  .opt-row.on{border-color:var(--acc);background:#eaf3ff}\n  .opt-row:has(input:disabled){opacity:.5;cursor:not-allowed}\n  .opt-row input[type=radio]{margin:2px 0 0;accent-color:var(--acc);flex:0 0 auto}\n  .opt-t{font-size:12.5px;font-weight:600;display:flex;align-items:center;gap:6px;flex-wrap:wrap}\n  .opt-d{font-size:11.5px;color:var(--mut);margin-top:3px;line-height:1.55}\n  .opt-inline{font-size:11.5px;color:var(--mut);margin-top:9px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}\n  .opt-inline.off{opacity:.4;pointer-events:none}\n  .opt-note{border:1px solid var(--line);background:var(--panel2);border-radius:10px;padding:9px 11px;font-size:11.5px;color:var(--mut);line-height:1.6;margin-top:12px}\n  .opt-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:2px}\n  .opt-fld{display:flex;flex-direction:column;gap:5px;border:1px solid var(--line);border-radius:9px;padding:8px 10px;background:var(--panel2)}\n  .opt-fld span{font-size:11px;color:var(--mut);font-weight:600;line-height:1.35}\n  .opt-fld i{font-style:normal;font-weight:400;opacity:.72}\n  .opt-fld input{width:100%;background:var(--panel)}\n  .opt-err{margin-top:9px;font-size:11.5px;color:var(--closing);font-weight:600}\n  .opt-hr{height:1px;background:var(--line);margin:17px 0 0}\n  .shareurl{margin-top:9px;padding:9px 10px;border-radius:8px;background:var(--panel);border:1px solid var(--acc);\n    font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--ink);word-break:break-all;user-select:all}\n`,\n  render(d2){\n    return `<div class=\"simroot\"><header id=\"topHeader\" class=\"hidden\">\n  <h1>Simulador de Timeline de Fechamentos</h1>\n  <span class=\"pill live\" id=\"liveState\" role=\"button\" tabindex=\"0\" title=\"Clique pra buscar o snapshot mais recente e recalcular a timeline inteira\">● iniciando</span>\n  <span class=\"pill ro\">read-only · nunca escreve</span>\n  <span class=\"pill\" id=\"basePill\"></span>\n  <span class=\"pill\" id=\"rulePill\"></span>\n  <span class=\"pill\" id=\"spanPill\"></span>\n  <span class=\"status\" id=\"ds\"></span>\n  <div class=\"mac-spacer\"></div>\n  <select id=\"sessionPick\" style=\"min-width:200px\"></select>\n  <button class=\"ghost\" id=\"openOptions\" title=\"Configurações desta sessão\">⚙ Opções</button>\n  <button class=\"act\" id=\"saveSession\">Salvar sessão</button>\n  <button class=\"ghost\" id=\"dupSession\" title=\"Cria uma cópia independente desta sessão — a original fica intacta e a cópia passa a ser a sessão ativa\">⧉ Duplicar</button>\n  <button class=\"ghost\" id=\"openShare\" title=\"Publicar uma cópia congelada desta sessão em modo leitura\">⇪ Compartilhar</button>\n  <span class=\"status\" id=\"sessStatus\"></span>\n</header>\n\n<div class=\"rail hidden\" id=\"timelineRail\">\n  <div id=\"movementChips\"></div>\n  <div id=\"addMovementGroup\">\n    <button class=\"act\" id=\"openMove\">Adicionar movimentação</button>\n  </div>\n</div>\n\n<div class=\"card\" id=\"confirmCard\" style=\"margin:14px 20px\">\n  <h2 style=\"margin:0 0 9px\">Confirmar snapshot antes de simular</h2>\n  <div class=\"note\" id=\"confirmBody\">congelando snapshot…</div>\n  <button class=\"act\" id=\"confirmSnap\" style=\"margin-top:12px\">Confirmar snapshot ▶ ir pra simulação</button>\n  <div class=\"note\" style=\"margin-top:16px\">ou continue uma sessão salva:</div>\n  <select id=\"confirmSessionPick\" style=\"min-width:260px;margin-top:4px\"></select>\n  <div class=\"status\" id=\"confirmStatus\" style=\"margin-top:8px\"></div>\n</div>\n\n<div class=\"wrap hidden\" id=\"mainWrap\">\n  <div>\n    <div class=\"card\">\n      <div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:9px\">\n        <h2 id=\"mapTitle\" style=\"margin:0\">Mapa — clique num município p/ trocar o setor destino</h2>\n        <div class=\"toggle\" id=\"viewToggle\" title=\"ASIS = estrutura do setor antes desta etapa (já considerando etapas anteriores) · TOBE = após a redistribuição desta etapa\">\n          <button data-v=\"asis\">◧ ASIS (antes desta etapa)</button>\n          <button data-v=\"tobe\" class=\"on\">◨ TOBE (simulação)</button>\n        </div>\n      </div>\n      <div class=\"map-wrap\">\n        <svg id=\"map\" viewBox=\"0 0 700 500\" preserveAspectRatio=\"xMidYMid meet\"></svg>\n        <div class=\"zoom-pill\" id=\"mapZoom\">\n          <button type=\"button\" id=\"mapZoomOut\" title=\"Diminuir zoom\">−</button>\n          <span class=\"zoom-div\"></span>\n          <button type=\"button\" id=\"mapZoomIn\" title=\"Aumentar zoom\">+</button>\n        </div>\n      </div>\n      <div class=\"legend\" id=\"legend\"></div>\n      <div class=\"note\">Coloridos = municípios do setor fechado nesta etapa (cor = destino). Ao redor, cada área é um <b>setor vizinho inteiro</b> com o código no meio — não municípios soltos: cor = setor destino, cinza = contexto, cinza tracejado = setor já fechado numa etapa anterior. Shapes reais IBGE/tbrugz.</div>\n      <div class=\"note\" id=\"mapIgnoredNote\"></div>\n    </div>\n\n    <div class=\"card\">\n      <h2>Líderes — destino e CBs deslocadas (nesta etapa)</h2>\n      <div class=\"note\" style=\"margin:-4px 0 9px\"><b>Home</b> = setor que ficou com a maioria das CBs da líder. <b style=\"color:var(--closing)\">Deslocadas</b> = CBs dela que caíram noutro setor.</div>\n      <div class=\"scroll grow\"><table id=\"leaders\"></table></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"tabs\">\n        <button data-tab=\"matrix\" class=\"on\">Matriz ASIS</button>\n        <button data-tab=\"tobe\">Matriz TOBE</button>\n        <button data-tab=\"report\">Relatório de impacto (etapa)</button>\n        <button data-tab=\"cumulative\">Impacto acumulado (timeline)</button>\n        <button data-tab=\"sql\">Console SQL</button>\n      </div>\n      <div id=\"tab-matrix\"><div class=\"scroll grow\"><table id=\"matrix\"></table></div></div>\n      <div id=\"tab-tobe\" class=\"hidden\"><div class=\"scroll grow\"><table id=\"matrixTobe\"></table></div>\n        <div class=\"note\" id=\"tobeNote\"></div></div>\n      <div id=\"tab-report\" class=\"hidden\"></div>\n      <div id=\"tab-cumulative\" class=\"hidden\"></div>\n      <div id=\"tab-sql\" class=\"hidden\">\n        <textarea class=\"sqlbox\" id=\"sqlIn\">SELECT DS_MUNICIPIO, COUNT(DISTINCT CD_CONSULTORA) qtd FROM product.perfil_cb.crm_perfil_cb_diaria_historico WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=3504 GROUP BY DS_MUNICIPIO ORDER BY qtd DESC</textarea>\n        <div class=\"toolbar\" style=\"margin:8px 0\"><button class=\"act\" id=\"runSql\">▶ Rodar</button><span class=\"status\" id=\"sqlStatus\"></span></div>\n        <div class=\"note\" style=\"margin:-4px 0 8px\">Roda contra o snapshot congelado desta sessão (não \"ao vivo\") — mesma coerência da timeline.</div>\n        <div class=\"scroll\"><table id=\"sqlOut\"></table></div>\n      </div>\n    </div>\n  </div>\n\n  <div>\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"margin-bottom:11px\">\n        <button class=\"ghost\" id=\"optim\" title=\"testa alocações que mantêm cada setor conectado e escolhe a que equilibra a carga entre setores + minimiza a quebra (evita empilhar tudo num setor)\">★ Modelo ÓTIMO (equilíbrio + quebra)</button>\n        <button class=\"ghost\" id=\"autoTobe\" title=\"preenche cada município pelo setor vizinho mais próximo (geografia)\">TOBE por adjacência</button>\n        <span class=\"badge\" id=\"optBadge\"></span>\n      </div>\n      <div class=\"note\" style=\"margin:-4px 0 10px\"><b style=\"color:var(--ink)\">★ ÓTIMO</b> = equilibra a carga entre os setores <b>e</b> minimiza a quebra (mantém o território conectado; não empilha tudo num setor). <b style=\"color:var(--ink)\">TOBE</b> = sugestão por vizinhança. Editar esta etapa recalcula automaticamente as etapas seguintes da timeline.</div>\n      <div class=\"note\" id=\"ruleNote\" style=\"margin:-6px 0 11px;padding:7px 9px;background:var(--panel2);border:1px solid var(--line);border-radius:8px\"></div>\n      <div class=\"kpis\" id=\"kpis\"></div>\n      <div id=\"contig\"></div>\n      <div id=\"spanRule\"></div>\n      <h2>Span por setor (CBs) — base + entrantes</h2>\n      <div id=\"spans\"></div>\n      <div id=\"policySection\">\n      <div style=\"display:flex;justify-content:space-between;align-items:center;margin:14px 0 7px\">\n        <h2 style=\"margin:0\">CBs deslocadas</h2>\n        <div class=\"toggle\" id=\"policy\">\n          <button data-p=\"fora\" class=\"on\">= fora do setor</button>\n          <button data-p=\"quebra\">= quebra de relação</button>\n        </div>\n      </div>\n      <div class=\"note\" id=\"policyNote\"></div>\n      </div>\n    </div>\n\n    <div class=\"card\">\n      <h2 id=\"muniListTitle\">Municípios → setor destino</h2>\n      <div class=\"note\" id=\"muniListNote\" style=\"margin:-4px 0 9px\">Clique no <b>código do setor</b> pra mandar o município <b>inteiro</b> pra lá (regra de negócio). A cor acompanha o mapa.</div>\n      <div id=\"muniList\"></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"justify-content:space-between\">\n        <h2 style=\"margin:0\">Plano de migração (etapa selecionada)</h2>\n        <div><button class=\"ghost\" id=\"copyPlan\">Copiar</button> <button class=\"ghost\" id=\"csvPlan\">CSV</button></div>\n      </div>\n      <div id=\"plan\" class=\"note\"></div>\n    </div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"shareBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"shareTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"shareTitle\">Compartilhar sessão</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"shareX\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\" id=\"shareBody\"></div>\n    <div class=\"sheet-ft\">\n      <span class=\"status\" id=\"shareStatus\"></span>\n      <div class=\"mac-spacer\"></div>\n      <button class=\"ghost hidden\" id=\"shareCopy\">Copiar link</button>\n      <button class=\"ghost hidden\" id=\"shareOpen\">Abrir</button>\n      <button class=\"ghost\" id=\"shareCancel\">Cancelar</button>\n      <button class=\"act\" id=\"shareGo\">Compartilhar</button>\n    </div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"optionsBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"optTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"optTitle\">Opções da sessão</h3><span class=\"badge\" id=\"optSessName\"></span><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"optClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-lbl\">Regra rígida — seleção dos setores destino</div>\n      <div class=\"opt-sub\">Define quais setores vizinhos podem entrar na lista de destinos de cada etapa. Vale pra toda a timeline desta sessão.</div>\n      <label class=\"opt-row\" data-r=\"any\">\n        <input type=\"radio\" name=\"destRule\" value=\"any\">\n        <div>\n          <div class=\"opt-t\">Adjacente a qualquer município <span class=\"badge\">padrão</span></div>\n          <div class=\"opt-d\">Um setor entra na lista de destinos se tiver município vizinho a <b>qualquer</b> município do setor fechado — inclusive os menores. É o comportamento de hoje.</div>\n        </div>\n      </label>\n      <label class=\"opt-row\" data-r=\"dominant\">\n        <input type=\"radio\" name=\"destRule\" value=\"dominant\">\n        <div>\n          <div class=\"opt-t\">Adjacente a municípios dominantes</div>\n          <div class=\"opt-d\">Só os municípios do setor fechado com <b>CBs suficientes</b> valem como ponte. Os pequenos continuam sendo redistribuídos normalmente — só não trazem setor novo pra lista de destinos.</div>\n          <div class=\"opt-inline\" id=\"optMinCbsWrap\">Ignorar municípios com menos de <input type=\"number\" id=\"optMinCbs\" min=\"1\" max=\"9999\" step=\"1\" style=\"width:74px\"> CBs</div>\n        </div>\n      </label>\n      <div class=\"opt-hr\"></div>\n      <div class=\"opt-lbl\" style=\"margin-top:15px\">Pré-requisitos do setor destino — tamanho em CBs</div>\n      <div class=\"opt-sub\">Vale pro tamanho de cada setor receptor <b>depois</b> da redistribuição (base + entrantes). Em branco = desligado.</div>\n      <div class=\"opt-grid\">\n        <label class=\"opt-fld\"><span>Span alvo <i>(tamanho ideal)</i></span><input type=\"number\" id=\"optTargetSpan\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"livre\"></label>\n        <label class=\"opt-fld\"><span>Span máximo <i>(nenhum setor acima)</i></span><input type=\"number\" id=\"optMaxSpan\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n        <label class=\"opt-fld\"><span>Span mínimo <i>(nenhum setor abaixo)</i></span><input type=\"number\" id=\"optMinSpan\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n      </div>\n      <div class=\"opt-err hidden\" id=\"optSpanErr\"></div>\n      <div class=\"opt-note\" id=\"optPreview\"></div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"optStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"optCancel\">Cancelar</button><button class=\"act\" id=\"optApply\">Aplicar</button></div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"moveBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"moveTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"moveTitle\">Adicionar movimentação</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"moveClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-lbl\">Tipo de movimentação</div>\n      <div class=\"opt-sub\" id=\"moveLockNote\">Escolha o nível a fechar. Você pode fechar setores à vontade até fechar uma GV; aí só GV (ou mercado) daqui pra frente. Fechar um mercado trava a sessão em mercado.</div>\n      <label class=\"opt-row\" data-lvl=\"setor\"><input type=\"radio\" name=\"moveLevel\" value=\"setor\"><div><div class=\"opt-t\">Fechar Setor</div><div class=\"opt-d\">Redistribui os municípios do setor entre setores vizinhos da mesma GV.</div></div></label>\n      <label class=\"opt-row\" data-lvl=\"gv\"><input type=\"radio\" name=\"moveLevel\" value=\"gv\"><div><div class=\"opt-t\">Fechar GV</div><div class=\"opt-d\">Redistribui os setores (com municípios e CBs) da GV entre GVs vizinhas do mesmo mercado.</div></div></label>\n      <label class=\"opt-row\" data-lvl=\"mercado\"><input type=\"radio\" name=\"moveLevel\" value=\"mercado\"><div><div class=\"opt-t\">Fechar Mercado</div><div class=\"opt-d\">Redistribui as GVs (com setores, municípios e CBs) do mercado entre mercados vizinhos.</div></div></label>\n      <div class=\"opt-hr\"></div>\n      <div class=\"opt-lbl\" style=\"margin-top:15px\" id=\"moveCodeLbl\">Setor a fechar</div>\n      <div class=\"opt-inline\" id=\"moveCodeRow\">\n        <input id=\"moveCode\" list=\"moveCodeList\" placeholder=\"código\" style=\"min-width:150px\">\n        <datalist id=\"moveCodeList\"></datalist>\n        <button class=\"ghost\" id=\"moveRand\" title=\"Aleatório\">🎲</button>\n      </div>\n      <div class=\"opt-err hidden\" id=\"moveErr\"></div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"moveStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"moveCancel\">Cancelar</button><button class=\"act\" id=\"moveGo\">Fechar ▶ (nova etapa)</button></div>\n  </div>\n</div>\n\n<div class=\"tip\" id=\"tip\"></div>\n<div class=\"load hidden\" id=\"loader\">carregando…</div>\n<span id=\"ready\" style=\"display:none\"></span></div>`;\n  },\n  mounted(d2, root){\n\nconst $=s=>root.querySelector(s);\nconst fmt=n=>n==null?'–':Math.round(+n).toLocaleString('pt-BR');\nconst money=n=>{n=+n||0;return n>=1e6?'R$'+(n/1e6).toFixed(1)+'M':n>=1e3?'R$'+(n/1e3).toFixed(0)+'k':'R$'+Math.round(n)};\nconst norm=s=>(s||'').normalize('NFKD').replace(/[̀-ͯ]/g,'').toUpperCase().replace(/[^A-Z0-9]+/g,' ').trim();\nconst esc=s=>(s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\nconst blankM=()=>({cbs:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0,ativas:0});\n// ---------- município identity ----------\n// Brazil has many municipalities sharing a name (Bom Jesus alone exists in PI, RN, RS, SC, PB, GO).\n// A stage loads the dominant UF PLUS every bordering UF, so those collisions land in the SAME\n// feature set routinely: keyed by name alone the last shape loaded won, the map could draw a\n// município from the wrong state, buildAdj merged two unrelated municipalities into one node, and\n// GROUP BY DS_MUNICIPIO summed two real municipalities into one row. The key is NAME/UF everywhere\n// — SQL rows, GeoJSON features, the dominantes base, assignments, persisted sessions. norm() maps\n// every non-alphanumeric to a space, so '/' can never occur inside a name and the split is exact.\n// A missing UF degrades to a bare NAME key rather than inventing one.\nconst mkey=(name,uf)=>{const n=norm(name),u=String(uf==null?'':uf).trim().toUpperCase();return u?n+'/'+u:n;};\nconst mName=k=>{const s=String(k==null?'':k),i=s.lastIndexOf('/');return i<0?s:s.slice(0,i);};\nconst mUf=k=>{const s=String(k==null?'':k),i=s.lastIndexOf('/');return i<0?'':s.slice(i+1);};\nconst mDisp=k=>{const u=mUf(k);return u?mName(k)+' ('+u+')':mName(k);};\n// The base CSV is the ONLY source of a município's UF now, so its column is accepted in whatever\n// shape the base is maintained in: sigla, IBGE numeric code, or full state name. Anything that does\n// not resolve to a real sigla is treated as UNKNOWN rather than passed through — a bogus UF would key\n// a município to a shape that does not exist and drop it off the map without a word.\nconst UF_OF_CODE={}; Object.keys(UF_CODE).forEach(k=>{ UF_OF_CODE[UF_CODE[k]]=k; });\nconst UF_OF_NAME={ACRE:'AC',ALAGOAS:'AL',AMAPA:'AP',AMAZONAS:'AM',BAHIA:'BA',CEARA:'CE','DISTRITO FEDERAL':'DF','ESPIRITO SANTO':'ES',GOIAS:'GO',MARANHAO:'MA','MATO GROSSO':'MT','MATO GROSSO DO SUL':'MS','MINAS GERAIS':'MG',PARA:'PA',PARAIBA:'PB',PARANA:'PR',PERNAMBUCO:'PE',PIAUI:'PI','RIO DE JANEIRO':'RJ','RIO GRANDE DO NORTE':'RN','RIO GRANDE DO SUL':'RS',RONDONIA:'RO',RORAIMA:'RR','SANTA CATARINA':'SC','SAO PAULO':'SP',SERGIPE:'SE',TOCANTINS:'TO'};\nconst ufNorm=v=>{const s=norm(v); if(!s) return '';\n  if(UF_CODE[s]) return s;\n  if(UF_OF_CODE[s]) return UF_OF_CODE[s];\n  return UF_OF_NAME[s]||'';};\nconst loader=(on,t)=>{const l=$('#loader');l.classList.toggle('hidden',!on);if(t)l.textContent=t;};\nconst setError=(msg)=>{ const t=$('#ds'); if(t){t.textContent=msg;t.classList.add('err');} const c=$('#confirmStatus'); if(c){c.textContent=msg;c.classList.add('err');} };\n\n// ---------- base de municípios dominantes (CSV) ----------\n// WHICH municipalities belong to a sector is a business fact, not something to infer from CB volume:\n// it comes from a maintained CSV (one row per setor x município) linked through the `dominantesCsvUrl`\n// param. Anything the closing sector has CBs in but that the base does NOT assign to it is spillover:\n// not realocated, not drawn on the map, and its CBs follow their leader. Loaded once per URL and\n// cached for the whole session; a failure is thrown, never swallowed — a silent fallback here would\n// quietly change every number on the screen.\nconst DOM={url:null,promise:null,bySetor:null,ufBySetorMuni:null,rows:0,setores:0,hasUf:false,ufCol:null,badUf:0,clashes:0,headers:null,error:null};\nfunction parseCsv(text){\n  const out=[]; let row=[],cur='',q=false;\n  const push=()=>{ row.push(cur); cur=''; };\n  const endRow=()=>{ push(); if(row.length>1||row[0]!=='') out.push(row); row=[]; };\n  for(let i=0;i<text.length;i++){ const ch=text[i];\n    if(q){ if(ch==='\"'){ if(text[i+1]==='\"'){ cur+='\"'; i++; } else q=false; } else cur+=ch; }\n    else if(ch==='\"') q=true;\n    else if(ch===',') push();\n    else if(ch==='\\n') endRow();\n    else if(ch!=='\\r') cur+=ch;\n  }\n  if(cur!==''||row.length) endRow();\n  return out;\n}\n// `dominantesCsvUrl` is declared as a FileComponentUrlReference, so the instance points at the Drive\n// FILE, not at a URL literal — the Drive is policy-gated behind the app's auth strategy and a baked\n// string would be both stale and unauthorised. What the resolver hands back varies by where it is\n// resolved (URL string / file record / bare reference), so accept every shape instead of silently\n// reading undefined and falling through to the fallback.\n// The Drive is gated by the app's AuthenticationStrategy, so this read has to travel as the LOGGED-IN\n// USER — an anonymous CDN GET is exactly what the policy is there to refuse. `<system>.data2.media` is\n// only a cache in front of the SSP: on a cache miss it 302s to `<system>.data2apis.com`, which is where\n// the policy is actually evaluated.\n// COOKIES CANNOT CARRY THAT SESSION, which is why the previous `credentials:'include'` attempt could\n// never have worked: the app runs on <app>.<system>.data2.app and the SSP on <system>.data2apis.com —\n// cross-site — and the SSP answers `Access-Control-Allow-Origin: *` with no `Allow-Credentials`, so the\n// browser rejects a credentialed CORS read before it ever leaves the page. What the SSP actually reads\n// is `Authorization: Bearer <token>`: the runtime keeps that token in localStorage under\n// `__data2-session-<systemDomain>` (`__data2-session-data2` inside the builder preview) and replays it\n// on every dataset and rush call — same token, same header, same session, so the Drive policy sees the\n// logged-in user. Preflight passes because the SSP echoes whatever header the request asks for.\nfunction d2SessionToken(sysDomain){\n  const read=k=>{ try{ return (k&&window.localStorage.getItem(k))||''; }catch(e){ return ''; } };\n  return read(sysDomain?('__data2-session-'+sysDomain):'')||read('__data2-session-data2')||'';\n}\nfunction domCsvSystemDomain(url){\n  const m=/^https?:\\/\\/([^.\\/]+)\\.data2\\.(?:media|app)\\//i.exec(url)||/^https?:\\/\\/([^.\\/]+)\\.data2apis\\.com\\//i.exec(url);\n  return m?m[1]:'';\n}\nfunction domCsvCandidates(url){\n  const out=[];\n  const isMedia=/^https?:\\/\\/[^.\\/]+\\.data2\\.media\\//i.test(url);\n  const api=isMedia?url.replace(/^(https?:\\/\\/[^.\\/]+)\\.data2\\.media\\//i,'$1.data2apis.com/'):null;\n  const tok=d2SessionToken(domCsvSystemDomain(url));\n  const auth=tok?{authorization:'Bearer '+tok}:null;\n  // Authenticated origin read first — the only candidate a GATED Drive can actually answer.\n  if(api&&auth) out.push({url:api,headers:auth,label:'api+sessão'});\n  if(!api&&auth) out.push({url:url,headers:auth,label:'origem+sessão'});\n  // Anonymous last: only ever succeeds on an UNGATED Drive — or on a stale CDN cache hit, which is NOT\n  // proof the file is public (the origin keeps answering 401 once the edge entry expires).\n  if(api) out.push({url:api,headers:null,label:'api anônimo'});\n  out.push({url:url,headers:null,label:'origem anônima'});\n  return out;\n}\nfunction domCsvUrl(){\n  const pick=v=>{\n    if(!v) return '';\n    if(typeof v==='string') return v;\n    const q=v.params||{};\n    const ref=q.reference||v.reference;\n    return q.fileUrl||q.url||v.fileUrl||v.url||(ref?DOM_MEDIA_BASE+ref:'');\n  };\n  return String(pick(d2.param('dominantesCsvUrl'))||DOM_CSV_FALLBACK||'').trim();\n}\nasync function ensureDominantes(){\n  const url=domCsvUrl();\n  if(!url) throw new Error('base de municípios dominantes não configurada — ligue o arquivo CSV no parâmetro dominantesCsvUrl da instância do componente');\n  if(DOM.url===url && DOM.bySetor) return DOM.bySetor;\n  if(DOM.url===url && DOM.promise) return DOM.promise;\n  DOM.url=url; DOM.error=null;\n  DOM.promise=(async()=>{\n    // Self-diagnosing on purpose: every candidate reports its own outcome and the message names the\n    // session it was carrying. \"Unauthorized\" alone cannot distinguish \"not logged into THIS strategy\"\n    // from \"cookie not sent cross-site\" from \"param never bound, so it tried the fallback URL\" — and\n    // guessing between those from the outside costs a round trip each time.\n    let r=null; const tried=[];\n    for(const c of domCsvCandidates(url)){\n      let res=null;\n      try{ res=await fetch(c.url,{cache:'no-store',credentials:'omit',headers:c.headers||undefined}); }\n      catch(e){ tried.push(c.label+' → rede/CORS'); continue; }\n      if(res.ok){ r=res; break; }\n      tried.push(c.label+' → '+res.status);\n    }\n    if(!r){\n      let sess='sessão desconhecida';\n      try{\n        const a=d2.auth(AUTH_REF);\n        sess=(a&&a.isAuthed)?('logado como '+((a.user&&(a.user.email||a.user.nome))||'?')):('NÃO logado na estratégia '+AUTH_REF);\n      }catch(e){ sess='estratégia '+AUTH_REF+' não resolvida'; }\n      const tk=d2SessionToken(domCsvSystemDomain(url));\n      throw new Error('base de municípios dominantes: não foi possível ler o arquivo — '+sess+' · token de sessão '+(tk?'presente':'AUSENTE no localStorage')+'. Tentativas: '+tried.join(' · '));\n    }\n    const table=parseCsv(await r.text());\n    if(table.length<2) throw new Error('base de municípios dominantes vazia');\n    const head=table[0].map(h=>norm(h));\n    const iS=head.indexOf('CD SETOR'), iM=head.indexOf('MUNICIPIO');\n    if(iS<0||iM<0) throw new Error('base de municípios dominantes sem as colunas CD_SETOR / MUNICIPIO');\n    // CD_GV is the gerência de vendas the setor answers to, and it is the authority for the hard rule\n    // that a closing sector only ever hands territory to sectors of its OWN gerência. Its absence is\n    // fatal rather than a warning: with no GV there is no way to PROVE two sectors share one, and\n    // falling through unchecked is exactly the leak this column exists to close.\n    const iG=['CD GV','CDGV','GV','CD GERENCIA','CD GERENCIA VENDAS','CD GERENCIA DE VENDAS','GERENCIA DE VENDAS','GERENCIA'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    if(iG==null) throw new Error('base de municípios dominantes sem a coluna CD_GV — a regra de gerência de vendas não pode ser aplicada. Colunas lidas: '+head.slice(0,40).join(', '));\n    // MERCADO fica ACIMA de GV na hierarquia (mercado > GV > setor) — fechamento de Mercado e a regra\n    // \"GV só fecha pra GV do mesmo mercado\" dependem dela do mesmo jeito que a regra de GV depende\n    // de CD_GV, então a ausência é fatal pelo mesmo motivo.\n    const iK=['MERCADO','MERCADO REGIONAL','MKT','MARKET'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    if(iK==null) throw new Error('base de municípios dominantes sem a coluna MERCADO — fechamento de GV/Mercado precisa dela. Colunas lidas: '+head.slice(0,40).join(', '));\n    // Nomes de exibição — opcionais: a hierarquia funciona só com os códigos, mas o código sozinho\n    // não diz nada pro operador escolhendo o que fechar.\n    const iDS=['DC SETOR','NOME SETOR','DESCRICAO SETOR','NM SETOR'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    const iDG=['DC GV','NOME GV','DESCRICAO GV','NM GV','GV NOME'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    // The UF column is what the whole keying now rests on — SQL no longer carries DS_ESTADO — so its\n    // absence is a first-class finding, not a footnote. The header actually matched is reported on the\n    // base pill, and when nothing matches the pill lists every column read, because guessing a header\n    // name from the outside costs a round trip each time.\n    const iU=['UF','SIGLA UF','SG UF','CD UF','ESTADO','DS ESTADO','SIGLA','ESTADO SIGLA','UF MUNICIPIO'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    const by={},ufBy={},gvBy={},mkBySetor={},setorLabel={},gvLabel={}; let n=0,bad=0; const clash={},gvClash={},mkClash={};\n    for(let i=1;i<table.length;i++){\n      const line=table[i]; if(!line) continue;\n      const s=String(line[iS]==null?'':line[iS]).trim(), m=norm(line[iM]);\n      if(!s||!m) continue;\n      // One setor spans many rows (one per município) and every one of them repeats its CD_GV. Two\n      // DIFFERENT values for the same setor means the base itself disagrees about which gerência owns\n      // it — counted and reported, never keyed to whichever row happened to be read last.\n      const gvv=String(line[iG]==null?'':line[iG]).trim();\n      if(gvv){ if(gvBy[s]!==undefined && gvBy[s]!==gvv) gvClash[s]=true; else gvBy[s]=gvv; }\n      const mkv=String(line[iK]==null?'':line[iK]).trim();\n      if(mkv){ if(mkBySetor[s]!==undefined && mkBySetor[s]!==mkv) mkClash[s]=true; else mkBySetor[s]=mkv; }\n      if(iDS!=null){ const lbl=String(line[iDS]==null?'':line[iDS]).trim(); if(lbl && !setorLabel[s]) setorLabel[s]=lbl; }\n      if(iDG!=null && gvv){ const lbl=String(line[iDG]==null?'':line[iDG]).trim(); if(lbl && !gvLabel[gvv]) gvLabel[gvv]=lbl; }\n      const raw=(iU==null)?'':line[iU];\n      const u=(iU==null)?'':ufNorm(raw);\n      if(iU!=null && String(raw==null?'':raw).trim() && !u) bad++;\n      // The same name twice in ONE setor under two UFs is the single case the reverted SQL cannot\n      // survive: mSQL groups by DS_MUNICIPIO alone, so both municipalities arrive summed into one row\n      // and no lookup can split them again. Counted and reported — never quietly keyed to whichever\n      // UF happened to be read last.\n      const kk=s+'|'+m;\n      // An UNRESOLVED uf must never outrank a resolved one. Reading a blank/bogus UF row first wrote ''\n      // here, and the next row carrying the real sigla was then rejected as a clash — so the key stayed\n      // falsy for good, ufFor() fell through, and the município degraded to a bare NAME key that matches\n      // no shape and no dominance entry. With DISTRITO/ZONA in the base a município spans several rows,\n      // so which row lands first is pure luck. A clash is only two RESOLVED siglas that disagree.\n      if(!u){ if(ufBy[kk]===undefined) ufBy[kk]=''; }\n      else if(!ufBy[kk]) ufBy[kk]=u;\n      else if(ufBy[kk]!==u) clash[kk]=true;\n      (by[s]=by[s]||new Set()).add(u?m+'/'+u:m); n++;\n    }\n    if(!n) throw new Error('base de municípios dominantes sem linhas válidas');\n    DOM.bySetor=by; DOM.ufBySetorMuni=ufBy; DOM.rows=n; DOM.setores=Object.keys(by).length;\n    DOM.gvBySetor=gvBy; DOM.gvClashes=Object.keys(gvClash).length; DOM.gvCol=String(table[0][iG]||'').trim();\n    DOM.gvSetores=Object.keys(gvBy).length;\n    DOM.hasUf=(iU!=null); DOM.ufCol=(iU==null?null:String(table[0][iU]||'').trim());\n    DOM.badUf=bad; DOM.clashes=Object.keys(clash).length; DOM.headers=head.slice(0,40); DOM.error=null;\n    // ---- hierarquia: setor ⊂ GV ⊂ mercado, tudo derivado do MESMO csv que a regra de setor já\n    // confia — fechamento de GV/Mercado lê a mesma fonte, nunca uma segunda base paralela.\n    DOM.mercadoBySetor=mkBySetor; DOM.mercadoClashes=Object.keys(mkClash).length; DOM.mercadoCol=String(table[0][iK]||'').trim();\n    DOM.setorLabel=setorLabel; DOM.gvLabel=gvLabel;\n    const sectorsByGv={},sectorsByMercado={},gvsByMercado={},mercadoByGv={},gvMercClash={};\n    Object.keys(by).forEach(s=>{\n      const gv=gvBy[s], mk=mkBySetor[s];\n      if(gv) (sectorsByGv[gv]=sectorsByGv[gv]||new Set()).add(s);\n      if(mk) (sectorsByMercado[mk]=sectorsByMercado[mk]||new Set()).add(s);\n      if(gv&&mk){ if(mercadoByGv[gv]!==undefined && mercadoByGv[gv]!==mk) gvMercClash[gv]=true; else mercadoByGv[gv]=mk; }\n    });\n    Object.entries(mercadoByGv).forEach(([gv,mk])=>(gvsByMercado[mk]=gvsByMercado[mk]||new Set()).add(gv));\n    DOM.sectorsByGv=sectorsByGv; DOM.sectorsByMercado=sectorsByMercado; DOM.gvsByMercado=gvsByMercado;\n    DOM.mercadoByGv=mercadoByGv; DOM.gvMercadoClashes=Object.keys(gvMercClash).length;\n    return by;\n  })();\n  DOM.promise.catch(e=>{ DOM.promise=null; DOM.bySetor=null; DOM.error=e.message; renderAll(); });\n  return DOM.promise;\n}\n\nlet SESS={ snapshotRaw:null, snapshotSyncedAt:null, snapLiteral:null, eloMaxCycle:null, timeline:[], selected:0,\n           sessionId:null, sessionLabel:null, sessionsList:[], sectorsList:[], sectorBaseCache:{}, stage:'loading', dirtyFrom:null,\n           snapBusy:false, settings:{...DEFAULT_SETTINGS} };\n\nasync function q(sql){\n  let dbx,res;\n  try{\n    res=await d2.rush('AwW_11Nkg-dKksP7ic8V8').invoke({sql});\n  }catch(e){\n    throw new Error('Databricks indisponível (rush AwW_11Nkg-dKksP7ic8V8): '+e.message);\n  }\n  // The listener answers 200 with {sql} and NO `result` when its upstream probe times out: the\n  // handler builds `result: $result.data`, so an empty upstream simply drops the key rather than\n  // failing. Reading straight through to res.data.result.data reported \"Cannot read properties of\n  // undefined (reading 'data')\" — which reads like a bug in THIS component and sent a debugging\n  // session after the wrong thing. Name the layer that actually failed instead.\n  const payload=res&&res.data;\n  if(!payload) throw new Error('rush AwW_11Nkg-dKksP7ic8V8 não devolveu corpo — listener fora do ar?');\n  if(!payload.result) throw new Error('a sonda Databricks não respondeu: o listener AwW_11Nkg-dKksP7ic8V8 devolveu 200 sem `result`, o que normalmente é timeout do upstream (natura-x-app). O SQL não chegou a rodar.');\n  dbx=payload.result.data;\n  if(!dbx) throw new Error('sonda Databricks respondeu sem dados (result.data vazio).');\n  if(dbx.state && dbx.state!=='SUCCEEDED') throw new Error('Databricks '+dbx.state+': '+(dbx.error?.message||JSON.stringify(dbx.error)||'erro desconhecido'));\n  return dbx;\n}\nfunction rows(resp){const c=(resp.columns||[]).map(x=>x.name);\n  return (resp.rows||[]).map(a=>Object.fromEntries(c.map((k,i)=>[k,a[i]])));}\n// Stamps the UF onto every feature at load: the fetch is per-UF, so this is the ONLY point where a\n// shape's state is known for certain. Downstream the features are flattened into one array and the\n// origin would be unrecoverable. Persisted with the feature in saved sessions.\nasync function geoUF(uf){const code=UF_CODE[uf]||uf;const r=await fetch('https://raw.githubusercontent.com/tbrugz/geodata-br/master/geojson/geojs-'+code+'-mun.json');if(!r.ok)throw new Error('geo '+uf);\n  const g=await r.json(); const U=String(uf).toUpperCase();\n  (g.features||[]).forEach(f=>{ f.__uf=U; });\n  return g;}\n\nfunction buildAdj(features){\n  const pts=f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates],s=new Set();\n    ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>s.add(Math.round(p[0]*1000)/1000+','+Math.round(p[1]*1000)/1000))));return s;};\n  const feat={},idx={};\n  features.forEach(f=>{const nm=mkey(f.properties.name||f.properties.NOME||f.properties.nome,f.__uf);feat[nm]={f,pts:pts(f)};});\n  for(const nm in feat) feat[nm].pts.forEach(p=>(idx[p]=idx[p]||[]).push(nm));\n  const nbrs=nm=>{const c={};feat[nm].pts.forEach(p=>idx[p].forEach(o=>{if(o!==nm)c[o]=(c[o]||0)+1;}));\n    return Object.entries(c).filter(([,n])=>n>=2).map(([o,n])=>({o,w:n}));};\n  return {feat,nbrs};\n}\nfunction centroidLL(f){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  ps.forEach(poly=>poly[0].forEach(p=>{x+=p[0];y+=p[1];n++;}));return[x/n,y/n];}\n\n// ---------- snapshot pinning ----------\nasync function resolveSnapshotPin(){\n  const [rawR,liveCountR,eloR]=await Promise.all([\n    q(`SELECT MAX(DT_HISTORICO) snap FROM ${T} WHERE CD_PAIS=1`).then(rows),\n    q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)`).then(rows),\n    q(`SELECT MAX(operational_cycle) mx FROM ${ELO} WHERE country_code=1`).then(rows).catch(()=>[]),\n  ]);\n  const raw=rawR[0]&&rawR[0].snap;\n  const liveCount=+((liveCountR[0]||{}).n||0);\n  const eloMax=+((eloR[0]||{}).mx||0);\n  const LIVE_SUBQ=`(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)`;\n  const dbTs=(raw||'').replace('T',' ').replace(/Z$/,'');\n  const candidates=[`TIMESTAMP'${dbTs}'`,`'${dbTs}'`,`DATE'${dbTs}'`];\n  const tests=await Promise.all(candidates.map(c=>\n    q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=${c}`).then(rows).then(r=>+((r[0]||{}).n||0)).catch(()=>-1)\n  ));\n  let chosen=LIVE_SUBQ;\n  for(let i=0;i<candidates.length;i++){ if(liveCount>0 && tests[i]===liveCount){ chosen=candidates[i]; break; } }\n  return {snapshotRaw:raw, snapLiteral:chosen, eloMaxCycle:eloMax, syncedAt:new Date().toISOString()};\n}\n// DISPLAY CONTRACT: the UI never prints the snapshot's own DT_HISTORICO (snapshotRaw). What the user\n// sees is `snapshotSyncedAt` — the date AND hour at which THIS instance last pulled/pinned the\n// snapshot it is querying. snapshotRaw is still resolved, still drives every SQL literal, and is\n// still persisted by saveSession/doShare; it is only kept off the screen.\nfunction fmtSyncedAt(iso){\n  if(!iso) return '—';\n  const d=new Date(iso);\n  if(isNaN(d.getTime())) return String(iso);\n  return d.toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});\n}\nasync function ensurePinned(){\n  if(SESS.snapLiteral) return;\n  const p=await resolveSnapshotPin();\n  SESS.snapshotRaw=p.snapshotRaw; SESS.snapLiteral=p.snapLiteral; SESS.eloMaxCycle=p.eloMaxCycle;\n  SESS.snapshotSyncedAt=p.syncedAt;\n}\n\n// ---------- world-state fold ----------\nfunction accMetrics(base,add){\n  const out=base?{...base}:blankM();\n  Object.keys(blankM()).forEach(k=>{ out[k]=(out[k]||0)+((add&&add[k])||0); });\n  return out;\n}\nfunction foldWorldState(timeline,uptoIdx){\n  const closedSectors=new Set(),muniOverride={},muniGeo={},muniRow={},muniLevel={},sectorInflow={},sectorInflowLevel={},sectorOrphanRows={};\n  // GV/mercado: setores e GVs não se dissolvem quando o dono deles fecha — eles só passam a\n  // responder a um destino novo. Por isso o rastro do cascade mora numa camada PRÓPRIA\n  // (secOverride/gvOverride), separada da camada de município do fechamento de setor — a\n  // hierarquia trava a sessão num nível só, então nunca as duas camadas têm conteúdo ao mesmo tempo.\n  const closedGvs=new Set(),secOverride={},gvInflow={};\n  const closedMercados=new Set(),gvOverride={},mercadoInflow={};\n  for(let k=0;k<uptoIdx;k++){\n    const mv=timeline[k]; if(!mv||!mv.sector||!mv.munis) continue;\n    const level=mv.level||'setor';\n    if(level==='gv'){\n      closedGvs.add(String(mv.sector));\n      Object.keys(mv.munis).forEach(u=>{\n        if(u==='__OUTROS__') return;\n        const dest=mv.assign[u]; if(!dest) return;\n        secOverride[u]=String(dest);\n        gvInflow[dest]=accMetrics(gvInflow[dest],mv.munis[u]);\n      });\n      continue;\n    }\n    if(level==='mercado'){\n      closedMercados.add(String(mv.sector));\n      Object.keys(mv.munis).forEach(u=>{\n        if(u==='__OUTROS__') return;\n        const dest=mv.assign[u]; if(!dest) return;\n        gvOverride[u]=String(dest);\n        mercadoInflow[dest]=accMetrics(mercadoInflow[dest],mv.munis[u]);\n      });\n      continue;\n    }\n    closedSectors.add(String(mv.sector));\n    Object.keys(mv.munis).forEach(m=>{\n      if(m==='__OUTROS__') return;\n      const dest=mv.assign[m]; if(!dest) return;\n      muniOverride[m]=String(dest);\n      if(mv.geoFeat&&mv.geoFeat[m]) muniGeo[m]=mv.geoFeat[m];\n      const rws=(mv.matrix||[]).filter(r=>mkey(r.municipio,r.uf)===m);\n      muniRow[m]=(muniRow[m]||[]).concat(rws);\n      if(mv.levelByMuni&&mv.levelByMuni[m]) muniLevel[m]={...(muniLevel[m]||{}),...mv.levelByMuni[m]};\n      sectorInflow[dest]=accMetrics(sectorInflow[dest],mv.munis[m]);\n      const lv=(mv.levelByMuni&&mv.levelByMuni[m])||{};\n      sectorInflowLevel[dest]=sectorInflowLevel[dest]||{};\n      Object.entries(lv).forEach(([nv,c])=>{ sectorInflowLevel[dest][nv]=(sectorInflowLevel[dest][nv]||0)+c; });\n    });\n    // Orphan CBs follow their leader's home sector, so later etapas must inherit them the same way\n    // the allocated municipalities are inherited — otherwise a stage's span would not match the\n    // base carried into the next one.\n    if(mv.orphanMetricsByGroup && Object.keys(mv.orphanMetricsByGroup).length){\n      const d=computeDerived(mv);\n      d.leaders.forEach(L=>{\n        if(!L.home) return;\n        const om=mv.orphanMetricsByGroup[L.grupo];\n        if(om) sectorInflow[L.home]=accMetrics(sectorInflow[L.home],om);\n        // sectorInflow only reaches a sector while it is a DESTINATION (it lands in baseMetrics).\n        // Keep the rows as well, so the sector still carries these CBs on the day it is the one\n        // being closed — that read goes through mv.matrix, which baseMetrics never touches.\n        const orws=mv.orphanRowsByGroup&&mv.orphanRowsByGroup[L.grupo];\n        if(orws&&orws.length) sectorOrphanRows[L.home]=(sectorOrphanRows[L.home]||[]).concat(orws);\n        const ol=mv.orphanLevelByGroup&&mv.orphanLevelByGroup[L.grupo];\n        if(ol){ sectorInflowLevel[L.home]=sectorInflowLevel[L.home]||{};\n          Object.entries(ol).forEach(([nv,c])=>{ sectorInflowLevel[L.home][nv]=(sectorInflowLevel[L.home][nv]||0)+c; }); }\n      });\n    }\n  }\n  return {closedSectors,muniOverride,muniGeo,muniRow,muniLevel,sectorInflow,sectorInflowLevel,sectorOrphanRows,\n          closedGvs,secOverride,gvInflow,closedMercados,gvOverride,mercadoInflow};\n}\n\n// ---------- per-movement compute ----------\nfunction aggregateMovement(mv){\n  const level=mv.level||'setor';\n  const munis={},leaders={};\n  const exSet=new Set((mv.excludedMunis||[]).map(x=>x.muni));\n  const acc=(o,r)=>{o.cbs+=+r.cbs;o.receita+=+r.receita||0;o.rec_nat+=+r.rec_nat||0;o.rec_avon+=+r.rec_avon||0;o.pontos+=+r.pontos||0;o.pedidos+=+r.pedidos||0;o.ativas+=+r.ativas||0;};\n  // Orphans = CBs in municipalities outside the sector's dominance base. They are NOT allocated\n  // geographically; they follow their own leader's home sector. Kept per leader group so every\n  // consumer (span, cascade, impact report) can route them the same way. Só existe no nível setor —\n  // GV/mercado não têm spillover (todo setor pertence a exatamente uma GV/mercado na base).\n  mv.orphanByGroup={}; mv.orphanMetricsByGroup={}; mv.orphanRowsByGroup={};\n  // unidade que este nível redistribui: a própria município (setor fecha), o setor da linha (GV\n  // fecha), a GV do setor da linha (mercado fecha).\n  const unitOf=r=>level==='setor'?null:(level==='gv'?String(r.setor):(DOM.gvBySetor[String(r.setor)]||''));\n  mv.matrix.forEach(r=>{\n    const m=mkey(r.municipio,r.uf);\n    r._out=!mv.geoFeat[m];   // assign, never just set: absorbed rows are reused across etapas\n    const mm=level==='setor'?(r._out?'__OUTROS__':m):(unitOf(r)||'__OUTROS__');\n    munis[mm]=munis[mm]||{...blankM(),name:mm}; acc(munis[mm],r);\n    const g=String(r.grupo); leaders[g]=leaders[g]||{grupo:g,lider:r.lider,total:0,byMuni:{}};\n    leaders[g].total+=+r.cbs; leaders[g].byMuni[mm]=(leaders[g].byMuni[mm]||0)+ +r.cbs;\n    if(level==='setor' && r._out && exSet.has(m)){\n      mv.orphanByGroup[g]=(mv.orphanByGroup[g]||0)+ +r.cbs;\n      // The ROW too, not just the totals: when the leader's home sector is itself closed in a later\n      // etapa, that etapa rebuilds its matrix from SQL + absorbed municípios, and these CBs are in\n      // neither (their município never moved). Without the row they silently vanish from the sector\n      // they were just credited to — see sectorOrphanRows in foldWorldState.\n      (mv.orphanRowsByGroup[g]=mv.orphanRowsByGroup[g]||[]).push(r);\n      mv.orphanMetricsByGroup[g]=accMetrics(mv.orphanMetricsByGroup[g],\n        {cbs:+r.cbs,receita:+r.receita||0,rec_nat:+r.rec_nat||0,rec_avon:+r.rec_avon||0,pontos:+r.pontos||0,pedidos:+r.pedidos||0,ativas:+r.ativas||0});\n    }\n  });\n  // Level counts only exist per municipality, never per leader, so an excluded municipality's level\n  // mix is credited to the group holding most of its CBs (sub-threshold by definition, so usually one).\n  mv.orphanLevelByGroup={};\n  (mv.excludedMunis||[]).forEach(({muni})=>{\n    const lv=(mv.levelByMuni&&mv.levelByMuni[muni])||{}; if(!Object.keys(lv).length) return;\n    let bg=null,bc=-1;\n    mv.matrix.forEach(r=>{ if(mkey(r.municipio,r.uf)!==muni) return; const cc=+r.cbs; if(cc>bc){bc=cc;bg=String(r.grupo);} });\n    if(!bg) return;\n    mv.orphanLevelByGroup[bg]=mv.orphanLevelByGroup[bg]||{};\n    Object.entries(lv).forEach(([nv,cc])=>{ mv.orphanLevelByGroup[bg][nv]=(mv.orphanLevelByGroup[bg][nv]||0)+cc; });\n  });\n  mv.munis=munis; mv.leaders=Object.values(leaders).sort((a,b)=>b.total-a.total);\n}\nfunction autoAssignFor(mv){\n  const a={};\n  Object.keys(mv.munis).forEach(m=>{ if(m==='__OUTROS__')return;\n    const v=mv.allowed[m]||{}; const votes=Object.keys(v);\n    if(votes.length){ let best=votes[0],bw=-1; for(const s of votes) if(v[s]>bw){bw=v[s];best=s;} a[m]=best; }\n    else if(mv.dest.length){ const c=mv.muniCentroid[m]; let best=mv.dest[0],bd=Infinity;\n      for(const s of mv.dest){ const sc=mv.secCentroid[s]; if(!sc||!c)continue; const d=(c[0]-sc[0])**2+(c[1]-sc[1])**2; if(d<bd){bd=d;best=s;} }\n      a[m]=best; }\n  });\n  return a;\n}\nfunction deslocFor(mv,assign){\n  let d=0;\n  mv.leaders.forEach(L=>{const byS={};let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=assign[m];if(s){byS[s]=(byS[s]||0)+c;tot+=c;}}\n    const home=mv.dest.reduce((a,s)=>(byS[s]||0)>(byS[a]||0)?s:a,mv.dest[0]);\n    d+=tot-(byS[home]||0);});\n  return d;\n}\nfunction spanMapFor(mv,a){const inc={};mv.dest.forEach(s=>inc[s]=0);\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=a[m];if(s&&inc[s]!=null)inc[s]+=o.cbs;});\n  // Orphan CBs follow their leader's home sector, and that home depends on THIS candidate\n  // assignment — so they have to be counted here, or the optimizer would score and constrain a\n  // span that differs from the one the UI shows. Skipped entirely when there are no orphans.\n  if(mv.orphanByGroup&&Object.keys(mv.orphanByGroup).length){\n    mv.leaders.forEach(L=>{\n      const orphan=mv.orphanByGroup[L.grupo]||0; if(!orphan) return;\n      const byS={}; let tot=0;\n      for(const [m,c] of Object.entries(L.byMuni)){ if(m==='__OUTROS__') continue; const s=a[m]; if(s&&inc[s]!=null){ byS[s]=(byS[s]||0)+c; tot+=c; } }\n      if(!tot) return;   // no home to follow -> stranded, counted nowhere\n      const home=mv.dest.reduce((x,s)=>(byS[s]||0)>(byS[x]||0)?s:x,mv.dest[0]);\n      if(inc[home]!=null) inc[home]+=orphan;\n    });\n  }\n  const sp={};mv.dest.forEach(s=>sp[s]=(+(mv.baseMetrics[s]?.cbs||0))+inc[s]);return sp;}\nfunction maxSpanOfFor(mv,a){return Math.max(...Object.values(spanMapFor(mv,a)),0);}\nfunction spanLimits(mv){\n  // Span alvo/máximo/mínimo é uma regra de fechamento de SETOR (tamanho ideal de território) —\n  // GV/mercado não têm essa noção e ignoram qualquer valor configurado nas opções da sessão.\n  if(mv && (mv.level||'setor')!=='setor') return {target:null,max:null,min:null};\n  const st={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  return {target:numOrNull(st.targetSpan),max:numOrNull(st.maxSpan),min:numOrNull(st.minSpan)};}\nfunction spanLimitsOn(lim){return !!(lim.target||lim.max||lim.min);}\nfunction spanViolationsOf(sp,lim){const over=[],under=[];\n  Object.entries(sp).forEach(([s,v])=>{ if(lim.max&&v>lim.max)over.push(s); if(lim.min&&v<lim.min)under.push(s); });\n  return {over,under};}\nfunction spanViolationsFor(mv,a){return spanViolationsOf(spanMapFor(mv,a),spanLimits(mv));}\n// With a target span the objective becomes RMS distance from the ideal size (punishes too big AND\n// too small). With no target we keep the legacy objective: shave the biggest sector.\nfunction spanCostFor(mv,a){const lim=spanLimits(mv),vals=Object.values(spanMapFor(mv,a));\n  if(!vals.length) return 0;\n  if(lim.target) return Math.sqrt(vals.reduce((s,v)=>s+(v-lim.target)**2,0)/vals.length);\n  return Math.max(...vals);}\n// ---------- ÓTIMO: objetivo multi-critério ----------\n// Three normalised objectives, each 0..1, so the weights below are directly comparable. The old\n// score added a span cost measured in CBs to a desloc measured in CBs, so on a large sector the\n// balance term was numerically ~100x the desloc term and silently decided everything on its own.\n//   balance — RMS spread of the resulting spans (against the target span when one is set,\n//             otherwise against their own mean), over that reference. 0 = every destination\n//             sector ends up the same size.\n//   dist    — how far each município travels, CB-weighted: half from the share of its outer\n//             border that faces the sector it was given to, half from centroid distance. 0 =\n//             every município went to a sector it physically touches. Adjacency used to be a\n//             pure yes/no constraint (contiguity); this is what makes it a preference too.\n//   orphan  — stranded orphan CBs (leader left without a home sector, so they land nowhere at\n//             all) plus CBs split away from their leader's home, over the CBs at stake.\n//             Stranded ones count OPT_STRAND_K times: a stranded CB leaves the model entirely,\n//             a deslocated one merely changes hands.\nconst OPT_W={balance:1, dist:0.85, orphan:1};\nconst OPT_STRAND_K=3;\nconst OPT_SIMS=24;             // simultaneous simulations per run, scaled down on big stages\nconst OPT_PASSES=14;           // local-search sweeps inside one simulation\nconst OPT_EVAL_BUDGET=120000;  // total candidate evaluations across the whole portfolio\nconst OPT_EXHAUSTIVE_MAX=60000;\nfunction optDistPrep(mv){\n  let mx=0;\n  Object.keys(mv.munis).forEach(m=>{ if(m==='__OUTROS__') return; const c=mv.muniCentroid[m]; if(!c) return;\n    mv.dest.forEach(s=>{ const sc=mv.secCentroid[s]; if(!sc) return;\n      const d=Math.hypot(c[0]-sc[0],c[1]-sc[1]); if(d>mx) mx=d; }); });\n  return mx||1;\n}\nfunction optDistCost(mv,m,s,dmax){\n  const v=mv.allowed[m]||{}; let tot=0; for(const k in v) tot+=v[k];\n  const share=tot?((v[s]||0)/tot):0;\n  const c=mv.muniCentroid[m], sc=mv.secCentroid[s];\n  // Missing centroid on either side is neutral (0.5), never 0 — an unmeasurable move must not\n  // score as the cheapest one on the board.\n  const geo=(c&&sc)?Math.min(1,Math.hypot(c[0]-sc[0],c[1]-sc[1])/dmax):0.5;\n  return 0.5*(1-share)+0.5*geo;\n}\nfunction optEval(mv,a,dmax){\n  const lim=spanLimits(mv);\n  const sp=spanMapFor(mv,a), vals=Object.values(sp);\n  let balance=0;\n  if(vals.length){\n    const ref=lim.target||(vals.reduce((x,y)=>x+y,0)/vals.length);\n    if(ref) balance=Math.min(1,Math.sqrt(vals.reduce((s,v)=>s+(v-ref)**2,0)/vals.length)/ref);\n  }\n  let cbs=0,dist=0;\n  for(const m in a){ const o=mv.munis[m]; if(!o) continue; const w=+o.cbs||0; cbs+=w; dist+=w*optDistCost(mv,m,a[m],dmax); }\n  dist=cbs?(dist/cbs):0;\n  let stranded=0,desloc=0,tot=0;\n  mv.leaders.forEach(L=>{\n    const byS={}; let t=0;\n    for(const [m,c] of Object.entries(L.byMuni)){ if(m==='__OUTROS__') continue; const s=a[m]; if(s){ byS[s]=(byS[s]||0)+c; t+=c; } }\n    const orphan=(mv.orphanByGroup&&mv.orphanByGroup[L.grupo])||0;\n    tot+=t+orphan;\n    if(!t){ stranded+=orphan; return; }   // no allocated CBs -> no home -> these orphans land nowhere\n    const home=mv.dest.reduce((x,s)=>(byS[s]||0)>(byS[x]||0)?s:x,mv.dest[0]);\n    desloc+=t-(byS[home]||0);\n  });\n  const orphanTerm=tot?Math.min(1,(desloc+OPT_STRAND_K*stranded)/tot):0;\n  return { cost:OPT_W.balance*balance+OPT_W.dist*dist+OPT_W.orphan*orphanTerm,\n           balance, dist, desloc, stranded, sp, viol:spanViolationsOf(sp,lim) };\n}\nfunction optScoreFor(mv,a){ return optEval(mv,a,mv.__optDmax||optDistPrep(mv)).cost; }\n// Seeded PRNG on purpose. computeMovement runs the optimizer for EVERY stage and recomputeFrom\n// re-runs every stage downstream of an edit, so a Math.random() portfolio would hand back a\n// different allocation on each pass and a saved timeline would never reproduce.\nfunction optRng(seed){ let a=seed>>>0;\n  return ()=>{ a=(a+0x6D2B79F5)>>>0; let t=Math.imul(a^(a>>>15),1|a);\n    t=(t+Math.imul(t^(t>>>7),61|t))^t; return ((t^(t>>>14))>>>0)/4294967296; }; }\nfunction optSeed(mv,ms){ let h=2166136261>>>0;\n  const s=String(mv.sector)+'|'+ms.join(',')+'|'+mv.dest.join(',');\n  for(let i=0;i<s.length;i++) h=Math.imul(h^s.charCodeAt(i),16777619);\n  return h>>>0; }\nfunction contigBadFor(mv,assign){\n  // Contiguidade geográfica só faz sentido no grão de município (setor fechando): setores e GVs\n  // não precisam formar uma mancha contínua quando GV/mercado fecha — cada setor/GV vai pro destino\n  // que faz sentido pra ele, sem exigir que o conjunto redistribuído continue \"grudado\".\n  if((mv.level||'setor')!=='setor') return [];\n  const bySec={}; for(const m in assign){(bySec[assign[m]]=bySec[assign[m]]||[]).push(m);}\n  const bad=[];\n  for(const s in bySec){const grp=bySec[s],inGrp=new Set(grp);\n    const seeds=grp.filter(m=>(mv.allowed[m]||{})[s]);\n    if(!seeds.length){bad.push(s);continue;}\n    const seen=new Set(seeds),st=[...seeds];\n    while(st.length){const m=st.pop();(mv.closeAdj[m]||[]).forEach(o=>{if(inGrp.has(o)&&!seen.has(o)){seen.add(o);st.push(o);}});}\n    if(seen.size!==grp.length)bad.push(s);}\n  return bad;\n}\nfunction greedyFor(mv,ms,opts){const lim=spanLimits(mv);const a={};\n  ms.forEach((m,i)=>{let best=opts[i][0],bd=Infinity;\n    for(const s of opts[i]){a[m]=s;\n      // over-max only grows as we keep assigning, so it is safe to penalise mid-build;\n      // under-min shrinks, so penalising it here would just chase noise.\n      const over=lim.max?spanViolationsOf(spanMapFor(mv,a),{max:lim.max,min:null}).over.length:0;\n      const d=optScoreFor(mv,a)+(contigBadFor(mv,a).length?1e9:0)+over*1e6;\n      if(d<bd){bd=d;best=s;}}\n    a[m]=best;});\n  return a;}\nfunction optimizeFor(mv){\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__');\n  if(!ms.length||!mv.dest.length){ mv.optBadge=mv.dest.length?'':'sem setor destino identificado'; return {}; }\n  const opts=ms.map(m=>{const al=(mv.reachable[m]&&mv.reachable[m].length)?mv.reachable[m]:mv.dest.slice();return al.length?al:mv.dest.slice();});\n  const combos=opts.reduce((a,o)=>a*o.length,1);\n  const lim=spanLimits(mv);\n  const dmax=optDistPrep(mv); mv.__optDmax=dmax;\n\n  // Feasibility ladder — same meaning as before, because renderSpanRule and the saved sessions\n  // both read mv.optTier: 0 = contíguo + max + min span, 1 = contíguo + max span, 2 = contíguo\n  // only. Every candidate any simulation produces is offered to all three tiers; we ship the\n  // strictest tier that got a solution, so an unreachable min/max relaxes loudly instead of\n  // stranding the stage with nothing.\n  const tier=[null,null,null],tSc=[Infinity,Infinity,Infinity],tEv=[null,null,null];\n  let feasible=0,evals=0;\n  const offer=(a,ev)=>{\n    if(contigBadFor(mv,a).length) return false;\n    feasible++;\n    const put=t=>{ if(ev.cost<tSc[t]){ tSc[t]=ev.cost; tier[t]={...a}; tEv[t]=ev; } };\n    put(2);\n    if(!ev.viol.over.length){ put(1); if(!ev.viol.under.length) put(0); }\n    return true;\n  };\n  const evalOf=a=>{ evals++; return optEval(mv,a,dmax); };\n\n  // Small enough to settle the question outright: enumerate every contiguous allocation and the\n  // winner is provably the best one under this objective. No search needed, so none is run.\n  const exhaustive=combos<=OPT_EXHAUSTIVE_MAX;\n  let sims=0;\n  if(exhaustive){\n    const cur={};\n    const rec=i=>{ if(i===ms.length){ offer(cur,evalOf(cur)); return; }\n      for(const s of opts[i]){ cur[ms[i]]=s; rec(i+1); } };\n    rec(0);\n  } else {\n    // Too big to enumerate, so run a portfolio of independent simulations instead. Each one is a\n    // seed assignment, then hill-climbing on single-município reassignments, then one perturbation\n    // and another climb to escape whatever local optimum it settled into. Different seeds land in\n    // different basins; the ladder above keeps the best across all of them.\n    const improve=a=>{\n      let ev=evalOf(a); offer(a,ev);\n      for(let pass=0;pass<OPT_PASSES;pass++){\n        let moved=false;\n        for(let i=0;i<ms.length;i++){\n          const m=ms[i],cur=a[m]; let best=cur,bev=ev;\n          for(const s of opts[i]){\n            if(s===cur) continue;\n            a[m]=s;\n            const e=evalOf(a);\n            // contiguity is a hard constraint, and the cheaper test runs first on purpose\n            if(e.cost<bev.cost-1e-12 && !contigBadFor(mv,a).length){ best=s; bev=e; }\n          }\n          a[m]=best;\n          if(best!==cur){ ev=bev; moved=true; offer(a,ev); }\n        }\n        if(!moved) break;\n      }\n    };\n    const rng=optRng(optSeed(mv,ms));\n    const perSim=Math.max(1,ms.length*Math.max(1,mv.dest.length)*OPT_PASSES);\n    const budget=Math.max(4,Math.min(OPT_SIMS,Math.floor(OPT_EVAL_BUDGET/perSim)||4));\n    const seeds=[];\n    if(mv.assign&&ms.every(m=>mv.assign[m])) seeds.push({...mv.assign});   // never regress on what is already on screen\n    seeds.push(autoAssignFor(mv));                                        // adjacency-first seed\n    seeds.push(greedyFor(mv,ms,opts));                                    // balance-first seed\n    while(seeds.length<budget){\n      // randomised construction biased toward nearby sectors — the bias keeps each seed sane, the\n      // randomness is what sends the runs into different basins\n      const a={};\n      ms.forEach((m,i)=>{ let bs=opts[i][0],bw=-Infinity;\n        for(const s of opts[i]){ const w=(1-optDistCost(mv,m,s,dmax))+rng()*0.9; if(w>bw){bw=w;bs=s;} }\n        a[m]=bs; });\n      seeds.push(a);\n    }\n    seeds.forEach(seed=>{\n      const a={...seed};\n      // a restored or legacy assign can still name a sector this stage no longer offers\n      ms.forEach((m,i)=>{ if(!opts[i].includes(a[m])) a[m]=opts[i][0]; });\n      improve(a);\n      const kick=Math.max(1,Math.round(ms.length*0.25));\n      for(let k=0;k<kick;k++){ const i=Math.floor(rng()*ms.length); a[ms[i]]=opts[i][Math.floor(rng()*opts[i].length)]; }\n      improve(a);\n    });\n    sims=seeds.length;\n  }\n\n  const t=tier.findIndex(x=>x);\n  if(t<0){ mv.optTier=-1; mv.optBadge='sem solução conectada — heurística'; return greedyFor(mv,ms,opts); }\n  const best=tier[t], ev=tEv[t]; mv.optTier=t;\n  const relax=[];\n  if(t>0 && lim.min) relax.push('span mínimo '+fmt(lim.min));\n  if(t>1 && lim.max) relax.push('span máximo '+fmt(lim.max));\n  mv.optStats={balance:ev.balance,dist:ev.dist,desloc:ev.desloc,stranded:ev.stranded,sims,evals,feasible,combos,exhaustive};\n  mv.optBadge=`ótimo · ${exhaustive?`espaço completo (${fmt(combos)})`:`${fmt(sims)} simulações`}`\n    +` · equilíbrio ±${Math.round(ev.balance*100)}% · adjacência ${Math.round((1-ev.dist)*100)}%`\n    +` · quebra ${fmt(ev.desloc)} CBs · ${fmt(ev.stranded)} fora do setor sem destino`\n    +` · pico ${fmt(maxSpanOfFor(mv,best))} CBs`\n    +(lim.target?` · alvo ${fmt(lim.target)}`:'')\n    +(relax.length?` · ⚠ inatingível: ${relax.join(' + ')}`:(spanLimitsOn(lim)?' · limites ok':''));\n  return best;\n}\nfunction computeDerived(mv){\n  const inc={},gained={},orph={};mv.dest.forEach(s=>{inc[s]=0;gained[s]=0;orph[s]=0;});\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(s&&inc[s]!=null)inc[s]+=o.cbs;});\n  let desloc=0,orphanTotal=0,orphanStranded=0;\n  const L2=mv.leaders.map(L=>{const byS={};mv.dest.forEach(s=>byS[s]=0);let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=mv.assign[m];if(s&&byS[s]!=null){byS[s]+=c;tot+=c;}}\n    // no allocated CBs => no home. Picking dest[0] by default would silently dump this leader's\n    // orphan CBs into an arbitrary sector, so we leave it null and report them as stranded.\n    const home=(tot>0&&mv.dest.length)?mv.dest.reduce((a,s)=>byS[s]>byS[a]?s:a,mv.dest[0]):null;\n    const dl=tot-(home?byS[home]:0);desloc+=dl;if(home)gained[home]++;\n    const orphan=(mv.orphanByGroup&&mv.orphanByGroup[L.grupo])||0;\n    orphanTotal+=orphan;\n    if(orphan){ if(home&&orph[home]!=null) orph[home]+=orphan; else orphanStranded+=orphan; }\n    return {...L,byS,home,desloc:dl,assignedTotal:tot,orphan,orphanHome:(orphan&&home)?home:null};});\n  const span={};mv.dest.forEach(s=>span[s]=(+(mv.baseMetrics[s]?.cbs||0))+inc[s]+orph[s]);\n  return {inc,gained,desloc,leaders:L2,span,orph,orphanTotal,orphanStranded};\n}\n\nasync function computeMovement(code,level,world){\n  level=level||'setor';\n  code=String(code);\n  const mv={sector:code,level,policy:'fora',view:'tobe',selMuni:null,optBadge:''};\n  const SNAP=SESS.snapLiteral;\n  // GV/mercado precisam da base ANTES de montar a query (pra saber quais CD_SETOR entram no IN);\n  // setor não precisa — mantém o timing original (a query dispara sem esperar o CSV).\n  let domBase=level==='setor'?null:await ensureDominantes();\n  const memberSectors=level==='setor'?[code]:memberSectorsOf(level,code,world);\n  if(!memberSectors.length) throw new Error(`${LEVELS[level].label} ${code} não existe na base de municípios dominantes (CSV) — atualize a base antes de fechar`);\n  const secList=memberSectors.join(',');\n\n  const mSQL=`SELECT DS_MUNICIPIO municipio, CD_SETOR setor, CD_GRUPO grupo, MAX(DS_LEADER_NAME) lider,\n     COUNT(DISTINCT CD_CONSULTORA) cbs, ROUND(SUM(VL_PEDIDOS_CICLO),0) receita,\n     ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat, ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon,\n     ROUND(SUM(QT_PONTOS_CICLO),0) pontos, ROUND(SUM(QT_PEDIDOS_CICLO),0) pedidos,\n     SUM(CASE WHEN ST_FEZ_PED_CICLO_ATUAL THEN 1 ELSE 0 END) ativas\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${secList}) AND DT_HISTORICO=${SNAP}\n    GROUP BY DS_MUNICIPIO, CD_SETOR, CD_GRUPO`;\n  const ufSQL=`SELECT DS_ESTADO uf, COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${secList}) AND DT_HISTORICO=${SNAP} GROUP BY DS_ESTADO ORDER BY n DESC`;\n  const trendSQL=`SELECT operational_cycle ciclo, COUNT(DISTINCT person_code) cbs,\n     COUNT(DISTINCT CASE WHEN registered_bc_ind=1 THEN person_code END) ativas,\n     ROUND(SUM(revenue_nat_ss_amt),0) receita, ROUND(SUM(points_nat_ss_amt),0) pontos\n    FROM ${ELO} WHERE country_code=1 AND sector_code IN (${secList}) AND operational_cycle<=${SESS.eloMaxCycle||999999} GROUP BY operational_cycle ORDER BY operational_cycle DESC LIMIT 8`;\n  const lvlSQL=`SELECT DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs,\n     ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat, ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${secList}) AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY DS_NVL_PLAN_CRESC ORDER BY cbs DESC`;\n  const lvlMuniSQL=`SELECT DS_MUNICIPIO municipio, CD_SETOR setor, DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${secList}) AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY DS_MUNICIPIO, CD_SETOR, DS_NVL_PLAN_CRESC`;\n  const [mR,ufR,tR,lR,lmR]=await Promise.all([q(mSQL),q(ufSQL),q(trendSQL),q(lvlSQL),q(lvlMuniSQL)]);\n\n  const ownMatrix=rows(mR).filter(r=>+r.cbs>0);\n  mv.pureOwnMetrics=blankM();\n  ownMatrix.forEach(r=>{ mv.pureOwnMetrics.cbs+=+r.cbs; mv.pureOwnMetrics.receita+=+r.receita||0; mv.pureOwnMetrics.rec_nat+=+r.rec_nat||0; mv.pureOwnMetrics.rec_avon+=+r.rec_avon||0; mv.pureOwnMetrics.pontos+=+r.pontos||0; mv.pureOwnMetrics.pedidos+=+r.pedidos||0; mv.pureOwnMetrics.ativas+=+r.ativas||0; });\n\n  mv.levelByMuni={};\n  if(level==='setor') Object.entries(world.muniLevel||{}).forEach(([m,lv])=>{ if(world.muniOverride[m]===code) mv.levelByMuni[m]={...lv}; });\n\n  mv.trend=rows(tR).map(r=>({...r,cbs:+r.cbs,ativas:+r.ativas,receita:+r.receita,pontos:+r.pontos})).filter(r=>r.cbs>10).reverse();\n  mv.levels=rows(lR);\n\n  let absorbedRows=[],inheritedOrphanRows=[];\n  if(level==='setor'){\n    Object.entries(world.muniOverride).forEach(([m,destSec])=>{ if(destSec===code && world.muniRow[m]) absorbedRows.push(...world.muniRow[m]); });\n    inheritedOrphanRows=(world.sectorOrphanRows&&world.sectorOrphanRows[code])||[];\n  }\n  mv.matrix=ownMatrix.concat(absorbedRows,inheritedOrphanRows);\n\n  const ufList=rows(ufR).map(r=>r.uf).filter(Boolean);\n  const domUF=ufList[0]||'PR';\n  mv.domUF=domUF;\n  domBase=domBase||await ensureDominantes();\n  // Uma GV ou mercado pode espalhar por mais UFs do que \"a UF dominante + suas vizinhas\" cobre —\n  // toda UF que aparece na base de dominantes de QUALQUER setor-membro entra na janela de shapes.\n  const memberUFs=new Set([domUF]);\n  memberSectors.forEach(s=>{ (domBase[String(s)]||new Set()).forEach(entry=>{ const u=mUf(entry); if(u) memberUFs.add(u); }); });\n  const ufSet=new Set(memberUFs);\n  memberUFs.forEach(u=>(UF_BORDERS[u]||[]).forEach(b=>ufSet.add(b)));\n  const geos=await Promise.all([...ufSet].map(u=>geoUF(u).catch(()=>null)));\n  const allFeat=geos.filter(Boolean).flatMap(g=>g.features);\n  const adj=buildAdj(allFeat);\n\n  // A UF só vem da base de dominantes (setor real da linha × município), nunca de \"esse nome só\n  // apareceu numa UF carregada\" ou de um chute pra UF dominante do fechamento inteiro. Os dois\n  // eram seguros quando só 1-3 UFs entravam na janela (fechamento de setor); GV e principalmente\n  // mercado carregam dezenas de UFs de uma vez, então \"único entre as UFs carregadas\" deixa de\n  // significar \"único no Brasil\" — era exatamente esse chute que colocava um homônimo de outra UF\n  // espalhado longe do resto do mapa. Sem entrada na base, o município fica sem UF (mesmo\n  // tratamento de \"sem shape\" que já existe) em vez de arriscar aparecer no lugar errado.\n  const ufGuessed={};\n  const ufFor=(setorOf,muni)=>{const nm=norm(muni);\n    return (DOM.ufBySetorMuni&&DOM.ufBySetorMuni[String(setorOf)+'|'+nm])||'';};\n  mv.matrix.forEach(r=>{ if(!r.uf) r.uf=ufFor(r.setor,r.municipio); });\n  rows(lmR).forEach(r=>{ const k=mkey(r.municipio,ufFor(r.setor,r.municipio)); (mv.levelByMuni[k]=mv.levelByMuni[k]||{})[r.nivel]=+r.cbs; });\n\n  const totBy={}; mv.matrix.forEach(r=>{const n=mkey(r.municipio,r.uf); totBy[n]=(totBy[n]||0)+ +r.cbs;});\n  const footprint=new Set(Object.entries(totBy).filter(([,c])=>c>0).map(([m])=>m));\n  const withShape=[...footprint].filter(n=>adj.feat[n]||world.muniGeo[n]);\n  mv.cbsByMuni={...totBy}; mv.shapeMunis=withShape.slice();\n  mv.noShapeMunis=[...footprint].filter(n=>!(adj.feat[n]||world.muniGeo[n])).map(n=>({muni:n,cbs:totBy[n]||0})).sort((a,b)=>b.cbs-a.cbs);\n  mv.noShapeCbs=mv.noShapeMunis.reduce((a,x)=>a+x.cbs,0);\n\n  // ---- RULE: quais municípios pertencem a este fechamento ----\n  // setor: exatamente a regra de hoje. GV/mercado: união da base de TODOS os setores-membro — a\n  // base já garante que um setor pertence a uma única GV/mercado, então não existe aqui o spillover\n  // fuzzy que município tem contra setor (por isso o orphan-handling abaixo é pulado nesses níveis).\n  let domOwn;\n  if(level==='setor'){\n    domOwn=domBase[code]||new Set();\n  } else {\n    domOwn=new Set();\n    memberSectors.forEach(s=>(domBase[String(s)]||new Set()).forEach(n=>domOwn.add(n)));\n  }\n  const inherited=level==='setor'?new Set(Object.keys(world.muniOverride||{}).filter(m=>world.muniOverride[m]===code)):new Set();\n  if(!domOwn.size && !inherited.size) throw new Error(`${LEVELS[level].label} ${code} não existe na base de municípios dominantes (CSV) — atualize a base antes de fechar`);\n  const belongs=n=>domOwn.has(n)||(!DOM.hasUf&&domOwn.has(mName(n)))||inherited.has(n);\n  mv.domCsvMunis=domOwn.size;\n  const dropped=level==='setor'?withShape.filter(n=>!belongs(n)):[];\n  if(withShape.length && dropped.length===withShape.length) throw new Error(`${LEVELS[level].label} ${code}: nenhum dos municípios com CBs está na base de dominantes — nada pra realocar`);\n  const droppedSet=new Set(dropped);\n  mv.excludedMunis=dropped.map(n=>({muni:n,cbs:totBy[n]||0})).sort((a,b)=>b.cbs-a.cbs);\n  mv.excludedCbs=dropped.reduce((a,n)=>a+(totBy[n]||0),0);\n\n  const closingNames=withShape.filter(n=>!droppedSet.has(n));\n  const closingSet=new Set(closingNames);\n  mv.geoFeat={}; closingNames.forEach(n=>{ mv.geoFeat[n]=(adj.feat[n]&&adj.feat[n].f)||world.muniGeo[n]; });\n\n  // ---- unidade de realocação: a geometria é sempre município; a UNIDADE que troca de dono é o\n  // próprio município (setor fecha), o setor inteiro (GV fecha) ou a GV inteira (mercado fecha).\n  const municipioSetor={}; mv.matrix.forEach(r=>{ const n=mkey(r.municipio,r.uf); if(!municipioSetor[n]) municipioSetor[n]=String(r.setor); });\n  const resolveUnit=n=>{\n    if(level==='setor') return n;\n    const sOf=municipioSetor[n]; if(!sOf) return '';\n    return level==='gv'?sOf:(DOM.gvBySetor[sOf]||'');\n  };\n  mv.unitOfMuni={}; closingNames.forEach(n=>{ mv.unitOfMuni[n]=resolveUnit(n); });\n  mv.unitMunis={}; closingNames.forEach(n=>{ const u=mv.unitOfMuni[n]; if(!u) return; (mv.unitMunis[u]=mv.unitMunis[u]||[]).push(n); });\n  const units=level==='setor'?closingNames:Object.keys(mv.unitMunis);\n\n  const drawnCsv=new Set(closingNames.map(n=>DOM.hasUf?n:mName(n)));\n  const footKeyOf=k=>DOM.hasUf?(footprint.has(k)?k:null):([...footprint].find(x=>mName(x)===k)||null);\n  mv.csvMunisTotal=domOwn.size;\n  mv.ufGuessed={...ufGuessed};\n  if(level==='setor'){\n    mv.missingMunis=[...domOwn].filter(k=>!drawnCsv.has(k)).map(k=>{\n      const fk=footKeyOf(k);\n      const hasShape=!!(adj.feat[fk||k]||world.muniGeo[fk||k]);\n      const alias=[...footprint].filter(x=>x!==k&&mName(x)===mName(k));\n      let reason;\n      if(!fk&&alias.length) reason=`UF divergente — as CBs entraram na chave ${alias.join(' / ')} e a base diz ${k}`;\n      else if(!fk) reason=hasShape?'sem CBs neste setor no snapshot — desenhado como vizinho':'sem CBs neste setor no snapshot';\n      else if(!hasShape) reason='sem shape nas UFs carregadas';\n      else reason='chave não casou com a base de dominantes';\n      return {muni:k,cbs:(fk?totBy[fk]:alias.reduce((a,x)=>a+(totBy[x]||0),0))||0,reason:reason};\n    });\n  } else {\n    mv.missingMunis=[];\n  }\n\n  // ---- HARD RULE: destino tem de compartilhar a fronteira certa acima do que está fechando ----\n  // setor → mesma GV; GV → mesmo mercado; mercado → nada acima, qualquer vizinho vale.\n  const ownParent=parentOf(level,code);\n  if(level!=='mercado' && !ownParent) throw new Error(`${LEVELS[level].label} ${code} não tem ${level==='setor'?'CD_GV':'MERCADO'} na base de municípios dominantes — sem essa fronteira não há como garantir que os destinos ficam do lado certo da hierarquia`);\n  const sameParent=destCode=>!ownParent||parentOf(level,destCode)===ownParent;\n  mv.gv=ownParent;\n  const alreadyClosed=destCode=>level==='setor'?world.closedSectors.has(destCode):(level==='gv'?(world.closedGvs||new Set()).has(destCode):(world.closedMercados||new Set()).has(destCode));\n\n  const destRule=(SESS.settings&&SESS.settings.destRule)||DEFAULT_SETTINGS.destRule;\n  const destMinCbs=Math.max(1,Math.round(+((SESS.settings||{}).destMinCbs)||DEFAULT_SETTINGS.destMinCbs));\n  mv.destRule=destRule; mv.destMinCbs=destMinCbs; mv.destRuleFallback=false;\n  mv.allowed={}; mv.allowedRaw={};\n  units.forEach(u=>{ mv.allowed[u]={}; mv.allowedRaw[u]={}; });\n\n  const extVotes={}; const nbrNames=new Set(); let domSecOf={};\n  if(level==='setor'){\n    // setor: destino descoberto por ADJACÊNCIA GEOGRÁFICA de município — uma query ao vivo por\n    // vizinho, mas o perímetro de UM setor é pequeno o bastante pra valer a pena.\n    closingNames.forEach(n=>{\n      if(!adj.feat[n]) return;\n      adj.nbrs(n).forEach(({o,w})=>{ if(!closingSet.has(o) && !droppedSet.has(o)){ nbrNames.add(o); (extVotes[n]=extVotes[n]||[]).push({o,w}); } });\n    });\n    let neigh=[];\n    if(nbrNames.size){\n      const inList=[...new Set([...nbrNames].map(n=>mName(n)))].map(n=>`'${n.replace(/'/g,\"''\")}'`).join(',');\n      const nSQL=`SELECT DS_MUNICIPIO municipio, CD_SETOR setor, COUNT(DISTINCT CD_CONSULTORA) cbs\n        FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR NOT IN (${secList}) AND CD_SETOR>0 AND DT_HISTORICO=${SNAP} AND DS_MUNICIPIO IN (${inList}) GROUP BY DS_MUNICIPIO, CD_SETOR`;\n      neigh=rows(await q(nSQL));\n    }\n    neigh.forEach(r=>{ if(!r.uf) r.uf=ufFor(r.setor,r.municipio); });\n    const byM={}; neigh.forEach(r=>{const m=mkey(r.municipio,r.uf); if(!nbrNames.has(m)) return; (byM[m]=byM[m]||[]).push(r);});\n    Object.entries(byM).forEach(([m,rs])=>{ rs.sort((a,b)=>+b.cbs-+a.cbs); domSecOf[m]=String(rs[0].setor); });\n    nbrNames.forEach(n=>{ if(world.muniOverride && world.muniOverride[n]) domSecOf[n]=world.muniOverride[n]; });\n\n    mv.nbrCurrentSector={};\n    mv.gvRejected=[];\n    const gvHidden=new Set();\n    nbrNames.forEach(n=>{\n      const destCode=destUnitOfSector(level,domSecOf[n],world);\n      if(destCode && !sameParent(destCode)){ gvHidden.add(n); if(mv.gvRejected.indexOf(destCode)<0) mv.gvRejected.push(destCode); return; }\n      if(domSecOf[n]) mv.nbrCurrentSector[n]=destCode;\n    });\n    gvHidden.forEach(n=>{ nbrNames.delete(n); delete domSecOf[n]; delete mv.nbrCurrentSector[n]; });\n    Object.keys(extVotes).forEach(n=>{ extVotes[n]=extVotes[n].filter(v=>!gvHidden.has(v.o)); });\n\n    const isBridge=n=>destRule!=='dominant'||(totBy[n]||0)>=destMinCbs;\n    mv.bridgeMunis=closingNames.filter(isBridge);\n    mv.ignoredBridges=closingNames.filter(n=>!isBridge(n));\n\n    const wAny={}, wDom={};\n    closingNames.forEach(n=>{\n      const u=n;\n      if(!mv.allowed[u]) return;\n      const bridge=isBridge(n);\n      (extVotes[n]||[]).forEach(({o,w})=>{\n        const destCode=mv.nbrCurrentSector[o];\n        if(!destCode || destCode===code || alreadyClosed(destCode)) return;\n        mv.allowed[u][destCode]=(mv.allowed[u][destCode]||0)+w;\n        mv.allowedRaw[u][destCode]=(mv.allowedRaw[u][destCode]||0)+w;\n        wAny[destCode]=(wAny[destCode]||0)+w;\n        if(bridge) wDom[destCode]=(wDom[destCode]||0)+w;\n      });\n    });\n    let secWeight=wAny;\n    if(destRule==='dominant'){\n      if(Object.keys(wDom).length) secWeight=wDom;\n      else { secWeight=wAny; mv.destRuleFallback=true; }\n    }\n    mv.dest=Object.keys(secWeight).sort((a,b)=>secWeight[b]-secWeight[a]).slice(0,6);\n    if(!mv.dest.length){\n      mv.dest=[...new Set(Object.values(mv.nbrCurrentSector))].filter(destCode=>destCode&&destCode!==code&&!alreadyClosed(destCode)&&sameParent(destCode)).slice(0,6);\n      units.forEach(u=>{ if(mv.allowed[u]&&!Object.keys(mv.allowed[u]).length){ mv.dest.forEach(destCode=>mv.allowed[u][destCode]=1); } });\n    }\n  } else {\n    // GV/mercado: destino é SEMPRE todo irmão válido na hierarquia — NUNCA descoberto por\n    // adjacência geográfica de município. Uma GV/mercado pode ter território espalhado por\n    // dezenas de UFs sem qualquer conexão física entre as partes (a hierarquia aqui é\n    // organizacional, não geográfica) — \"vizinho por fronteira de município\" não é só caro (a\n    // query ao vivo vira um IN-list de centenas de municípios e estoura timeout: foi exatamente\n    // isso que quebrou o fechamento de um mercado espalhado) — é a pergunta errada. A fronteira que\n    // já importa (mesma GV / mesmo mercado) é imposta pela regra de parentesco logo acima; dentro\n    // dela QUALQUER irmão é candidato, e o otimizador decide pelo equilíbrio de span e continuidade\n    // de líder, nunca por proximidade física — geografia não pesa aqui.\n    mv.nbrCurrentSector={};\n    mv.gvRejected=[];\n    mv.bridgeMunis=closingNames.slice();\n    mv.ignoredBridges=[];\n    const siblings=level==='gv'\n      ? [...(DOM.gvsByMercado[ownParent]||[])]\n      : Object.keys(DOM.sectorsByMercado||{});\n    mv.dest=siblings.filter(destCode=>destCode!==code && !alreadyClosed(destCode) && sameParent(destCode));\n    units.forEach(u=>{ mv.dest.forEach(destCode=>{ mv.allowed[u][destCode]=1; mv.allowedRaw[u][destCode]=1; }); });\n  }\n  mv.dest=mv.dest.filter(sameParent);\n\n  // ---- O QUE É DESENHADO: território COMPLETO de cada destino (não só a borda), sempre lido da\n  // mesma base de dominantes já usada pro lado que fecha.\n  const territoryOf=destCode=>{\n    const secs=memberSectorsOf(level,destCode,world);\n    const out=new Set();\n    secs.forEach(s=>(domBase[String(s)]||new Set()).forEach(n=>out.add(n)));\n    return out;\n  };\n  const drawSet=new Set(mv.dest);\n  mv.dest.forEach(destCode=>{\n    territoryOf(destCode).forEach(n=>{\n      if(nbrNames.has(n)) return;\n      nbrNames.add(n);\n      mv.nbrCurrentSector[n]=(level==='setor'&&world.muniOverride&&world.muniOverride[n])?world.muniOverride[n]:destCode;\n    });\n  });\n  mv.nbrFeat=[...nbrNames].filter(n=>drawSet.has(mv.nbrCurrentSector[n])).map(n=>adj.feat[n]&&adj.feat[n].f).filter(Boolean);\n  units.forEach(u=>{ Object.keys(mv.allowed[u]||{}).forEach(destCode=>{ if(!mv.dest.includes(destCode)) delete mv.allowed[u][destCode]; }); });\n  mv.color={}; mv.dest.forEach((destCode,i)=>mv.color[destCode]=PALETTE[i%PALETTE.length]);\n\n  const accC={}; Object.entries(mv.nbrCurrentSector).forEach(([nb,destCode])=>{ if(!mv.dest.includes(destCode))return; const f=(adj.feat[nb]&&adj.feat[nb].f)||world.muniGeo[nb]; if(!f)return; const c=centroidLL(f); accC[destCode]=accC[destCode]||[0,0,0]; accC[destCode][0]+=c[0]; accC[destCode][1]+=c[1]; accC[destCode][2]++; });\n  mv.secCentroid={}; mv.dest.forEach(destCode=>{ if(accC[destCode]) mv.secCentroid[destCode]=[accC[destCode][0]/accC[destCode][2],accC[destCode][1]/accC[destCode][2]]; });\n\n  if(level==='setor'){\n    mv.muniCentroid={}; closingNames.forEach(n=>{ if(mv.geoFeat[n]) mv.muniCentroid[n]=centroidLL(mv.geoFeat[n]); });\n    mv.closeAdj={}; closingNames.forEach(n=>{ mv.closeAdj[n]=adj.feat[n]?adj.nbrs(n).filter(({o})=>closingSet.has(o)).map(({o})=>o):[]; });\n  } else {\n    mv.muniCentroid={};\n    Object.entries(mv.unitMunis).forEach(([u,ms])=>{\n      const cs=ms.map(n=>mv.geoFeat[n]&&centroidLL(mv.geoFeat[n])).filter(Boolean);\n      if(cs.length) mv.muniCentroid[u]=[cs.reduce((a,c)=>a+c[0],0)/cs.length,cs.reduce((a,c)=>a+c[1],0)/cs.length];\n    });\n    mv.closeAdj={};\n    const unitSet=new Set(units);\n    Object.entries(mv.unitMunis).forEach(([u,ms])=>{\n      const touch=new Set();\n      ms.forEach(n=>{ if(!adj.feat[n]) return; adj.nbrs(n).forEach(({o})=>{ const ou=mv.unitOfMuni[o]; if(ou&&ou!==u&&unitSet.has(ou)) touch.add(ou); }); });\n      mv.closeAdj[u]=[...touch];\n    });\n  }\n  const comp={}; let ci=0; units.forEach(n=>{ if(comp[n]!=null)return; const st=[n]; comp[n]=ci; while(st.length){ const x=st.pop(); (mv.closeAdj[x]||[]).forEach(o=>{ if(comp[o]==null){ comp[o]=ci; st.push(o); } }); } ci++; });\n  const compAllowed={}; units.forEach(n=>{ const cid=comp[n]; (compAllowed[cid]=compAllowed[cid]||new Set()); Object.keys(mv.allowed[n]||{}).forEach(s=>compAllowed[cid].add(s)); });\n  mv.reachable={}; units.forEach(n=>{ mv.reachable[n]=[...(compAllowed[comp[n]]||[])].filter(s=>mv.dest.includes(s)); });\n\n  if(mv.dest.length){\n    const destSectorMap={}; mv.dest.forEach(destCode=>{ destSectorMap[destCode]=memberSectorsOf(level,destCode,world); });\n    const allDestSecs=[...new Set(Object.values(destSectorMap).flat())];\n    const rList=allDestSecs.join(',');\n    const bSQL=`SELECT CD_SETOR setor, COUNT(DISTINCT CD_CONSULTORA) cbs,\n       SUM(CASE WHEN ST_FEZ_PED_CICLO_ATUAL THEN 1 ELSE 0 END) ativas,\n       ROUND(SUM(VL_PEDIDOS_CICLO),0) receita, ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat,\n       ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon, ROUND(SUM(QT_PONTOS_CICLO),0) pontos, ROUND(SUM(QT_PEDIDOS_CICLO),0) pedidos\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${rList}) AND DT_HISTORICO=${SNAP} GROUP BY CD_SETOR`;\n    const blSQL=`SELECT CD_SETOR setor, DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${rList}) AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY CD_SETOR, DS_NVL_PLAN_CRESC`;\n    const [bR,blR]=await Promise.all([q(bSQL),q(blSQL)]);\n    const secMetrics={}; rows(bR).forEach(r=>{ secMetrics[String(r.setor)]=r; if(!SESS.sectorBaseCache[String(r.setor)]) SESS.sectorBaseCache[String(r.setor)]=r; });\n    const secLevel={}; rows(blR).forEach(r=>{ const s=String(r.setor); (secLevel[s]=secLevel[s]||{})[r.nivel]=+r.cbs; });\n    mv.baseMetrics={}; mv.baseLevel={};\n    mv.dest.forEach(destCode=>{\n      const secs=destSectorMap[destCode];\n      const acc=blankM(); acc.setor=destCode;\n      secs.forEach(s=>{ const r=secMetrics[String(s)]; if(!r) return; Object.keys(blankM()).forEach(k=>{ acc[k]=(+acc[k]||0)+(+r[k]||0); }); });\n      mv.baseMetrics[destCode]=acc;\n      const lv={}; secs.forEach(s=>{ const l=secLevel[String(s)]; if(!l) return; Object.entries(l).forEach(([nv,c])=>{ lv[nv]=(lv[nv]||0)+c; }); });\n      mv.baseLevel[destCode]=lv;\n    });\n  } else { mv.baseMetrics={}; mv.baseLevel={}; }\n\n  mv.dest.forEach(destCode=>{\n    if(!mv.baseMetrics[destCode]) mv.baseMetrics[destCode]={setor:destCode,cbs:0,ativas:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0};\n    const inflowSrc=level==='setor'?world.sectorInflow:(level==='gv'?world.gvInflow:world.mercadoInflow);\n    const infl=inflowSrc&&inflowSrc[destCode];\n    if(infl) Object.keys(blankM()).forEach(k=>{ mv.baseMetrics[destCode][k]=(+mv.baseMetrics[destCode][k]||0)+(infl[k]||0); });\n    if(level==='setor'){\n      const inflLv=world.sectorInflowLevel&&world.sectorInflowLevel[destCode];\n      if(inflLv){ mv.baseLevel[destCode]=mv.baseLevel[destCode]||{}; Object.entries(inflLv).forEach(([nv,c])=>{ mv.baseLevel[destCode][nv]=(mv.baseLevel[destCode][nv]||0)+c; }); }\n    }\n  });\n\n  aggregateMovement(mv);\n  mv.assign=optimizeFor(mv);\n  return mv;\n}\n\n// ---------- cascade engine ----------\nasync function computeAt(i){\n  await ensurePinned();\n  const world=foldWorldState(SESS.timeline,i);\n  const sec=SESS.timeline[i].sector;\n  const level=SESS.timeline[i].level||'setor';\n  const mv=await computeMovement(sec,level,world);\n  SESS.timeline[i]=mv;\n}\nasync function recomputeFrom(i){\n  for(let k=i;k<SESS.timeline.length;k++){\n    loader(true,`recalculando etapa ${k+1} de ${SESS.timeline.length}…`);\n    try{ await computeAt(k); }\n    catch(e){\n      // A stage that failed to recompute still holds its PREVIOUS result, so the timeline would\n      // silently mix pre- and post-change etapas. Flag this stage and everything after it as stale\n      // (dashed chip + ⟳) so the state is visible and flushDirty retries from here.\n      SESS.dirtyFrom = SESS.dirtyFrom==null ? k : Math.min(SESS.dirtyFrom,k);\n      throw e;\n    }\n  }\n}\nasync function flushDirty(){\n  if(SESS.dirtyFrom==null) return;\n  const from=SESS.dirtyFrom; SESS.dirtyFrom=null;\n  loader(true,'recalculando etapas seguintes…');\n  try{ await recomputeFrom(from); }\n  catch(e){ $('#ds').textContent='erro ao recalcular cadeia: '+e.message; $('#ds').classList.add('err'); }\n  finally{ loader(false); }\n}\nasync function addMovement(level,sec){\n  level=level||'setor';\n  sec=(sec==null?'':String(sec)).trim();\n  if(!sec) return;\n  if(!movementAvailable(level)){ $('#ds').textContent=`Fechar ${LEVELS[level].label} ainda não está disponível.`; $('#ds').classList.add('err'); return; }\n  if(!hierarchyAllows(level)){ const minLabel=LEVELS[Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===sessionMinRank())].label; $('#ds').textContent=`esta sessão já fechou um nível mais alto — só \"${minLabel}\" (ou acima) fica disponível.`; $('#ds').classList.add('err'); return; }\n  const closedNow=new Set(SESS.timeline.filter(mv=>(mv.level||'setor')===level).map(mv=>mv.sector));\n  if(closedNow.has(sec)){ $('#ds').textContent=LEVELS[level].label+' '+sec+' já fechado(a) nesta timeline.'; $('#ds').classList.add('err'); return; }\n  if(level==='setor' && !SESS.sectorsList.some(s=>s.setor===sec)){ $('#ds').textContent='setor \"'+sec+'\" não existe no snapshot congelado.'; $('#ds').classList.add('err'); return; }\n  await flushDirty();\n  loader(true,`fechando ${lc(LEVELS[level].label)} `+sec+' — puxando dado do snapshot congelado…');\n  $('#liveState').classList.remove('err');\n  try{\n    SESS.timeline.push({sector:String(sec),level:level});\n    await computeAt(SESS.timeline.length-1);\n    SESS.selected=SESS.timeline.length-1;\n    $('#ds').classList.remove('err'); $('#ds').textContent='';\n  }catch(e){\n    SESS.timeline.pop();\n    $('#ds').textContent=`erro ao fechar ${lc(LEVELS[level].label)} `+sec+': '+e.message; $('#ds').classList.add('err');\n  } finally { loader(false); renderAll(); }\n}\nfunction removeFrom(i){\n  SESS.timeline.length=i;\n  SESS.selected=Math.min(SESS.selected,Math.max(0,SESS.timeline.length-1));\n  if(SESS.dirtyFrom!=null && SESS.dirtyFrom>=SESS.timeline.length) SESS.dirtyFrom=null;\n  renderAll();\n}\nasync function editSelected(mutator){\n  const mv=SESS.timeline[SESS.selected]; if(!mv) return;\n  mutator(mv);\n  if(SESS.selected<SESS.timeline.length-1){\n    SESS.dirtyFrom = SESS.dirtyFrom==null ? SESS.selected+1 : Math.min(SESS.dirtyFrom, SESS.selected+1);\n  }\n  renderAll();\n}\n\n// ---------- sectors list / picker ----------\nasync function loadSectors(){\n  const sql=`SELECT CD_SETOR setor, MAX(DS_ESTADO) uf, COUNT(DISTINCT CD_CONSULTORA) cbs, COUNT(DISTINCT DS_MUNICIPIO) munis\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR>0 AND DT_HISTORICO=(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1) GROUP BY CD_SETOR ORDER BY CD_SETOR`;\n  SESS.sectorsList=rows(await q(sql)).map(r=>({setor:String(r.setor),uf:r.uf,cbs:+r.cbs,munis:+r.munis}));\n}\nfunction renderSectorPicker(){\n  const ready=SESS.stage==='main';\n  const ob=$('#openMove'); if(ob) ob.disabled=!ready;\n}\n\n// ---------- movement modal (setor / GV / mercado) ----------\n// Hierarquia mercado > GV > setor: a sessão trava no nível da primeira movimentação. Misturar\n// níveis quebraria a leitura de \"antes/depois\" da cadeia — fechar uma GV já redistribui setores\n// inteiros pra outras GVs; fechar um setor isolado depois não bate mais com o mundo que sobrou.\n// de/a/no: contrações de \"de\"/\"a\"/\"em\" com o artigo de cada nível — GV é feminina (a Gerência de\n// Vendas), setor e mercado são masculinos. Errar o gênero aqui é o tipo de coisa que grita \"isto foi\n// feito pra setor e só remendado pros outros níveis\", exatamente o que este objeto existe pra evitar.\nconst LEVELS={\n  setor:{label:'Setor',codeLbl:'Setor a fechar',placeholder:'código do setor',childLabel:'Município',destLabel:'Setor',de:'do',a:'ao',no:'no',parentLabel:'GV',parentPhrase:'da mesma GV',parentWord:'gerência'},\n  gv:{label:'GV',codeLbl:'GV a fechar',placeholder:'código da GV',childLabel:'Setor',destLabel:'GV',de:'da',a:'à',no:'na',parentLabel:'Mercado',parentPhrase:'do mesmo mercado',parentWord:'mercado'},\n  mercado:{label:'Mercado',codeLbl:'Mercado a fechar',placeholder:'nome do mercado',childLabel:'GV',destLabel:'Mercado',de:'do',a:'ao',no:'no',parentLabel:null,parentPhrase:null,parentWord:null},\n};\n// GV é sigla — .toLowerCase() a transformaria em \"gv\", errado em qualquer posição da frase. Palavras\n// normais (Setor/Mercado/Município) minúsculam à vontade no meio de uma frase; GV nunca.\nconst LOWER={Setor:'setor',Mercado:'mercado',Município:'município',GV:'GV'};\nconst lc=w=>LOWER[w]||w.toLowerCase();\n// Hierarquia é uma catraca, não um cadeado: mercado(0) > GV(1) > setor(2). Fechar um nível mais\n// alto (número menor) trava PRA SEMPRE nesta sessão qualquer nível mais baixo (número maior) —\n// mas o próprio nível, ou um ainda mais alto, continua liberado. É assim que \"posso fechar setores\n// até fechar uma GV; posso fechar GVs até fechar um mercado\" funciona: nunca destrava de volta.\nconst LEVEL_RANK={mercado:0,gv:1,setor:2};\nfunction sessionMinRank(){\n  let r=LEVEL_RANK.setor;\n  SESS.timeline.forEach(mv=>{ const rk=LEVEL_RANK[mv.level||'setor']; if(rk<r) r=rk; });\n  return r;\n}\nfunction hierarchyAllows(lvl){ return LEVEL_RANK[lvl]<=sessionMinRank(); }\nfunction movementAvailable(lvl){ return lvl==='setor'||lvl==='gv'||lvl==='mercado'; }\n// ---------- hierarquia: setor ⊂ GV ⊂ mercado — tudo derivado da base de dominantes (DOM) ----------\n// memberSectors: o escopo de CD_SETOR que a query no CRM precisa cobrir pra fechar este código, em\n// qualquer nível — um setor é ele mesmo; uma GV é todo setor que a base lhe atribui; um mercado é\n// todo setor de toda GV que a base atribui a ele.\n// membros ATUAIS de um GV/mercado, um nível abaixo (setores de uma GV; GVs de um mercado) — nunca\n// só a foto estática do CSV: uma etapa anterior NESTA MESMA sessão pode já ter movido um filho pra\n// dentro ou pra fora deste código, e um fechamento seguinte tem de enxergar o mundo como ele está\n// agora, não como o CSV descreveu antes de qualquer movimentação.\nfunction currentMembersOf(level,code,world){\n  code=String(code);\n  // \"gv\": setores da GV (DOM.sectorsByGv). \"mercado\": GVs do mercado (DOM.gvsByMercado) — NUNCA\n  // sectorsByMercado, que pula a camada de GV inteira e faz memberSectorsOf tratar códigos de\n  // setor como se fossem códigos de GV (não batem em DOM.sectorsByGv, e o fechamento morre com\n  // \"não existe na base\" mesmo quando o mercado está lá).\n  const staticMembers=level==='gv'?(DOM.sectorsByGv[code]||new Set()):(DOM.gvsByMercado[code]||new Set());\n  const overrideMap=level==='gv'?(world&&world.secOverride):(world&&world.gvOverride);\n  const out=new Set(staticMembers);\n  if(overrideMap){\n    Object.entries(overrideMap).forEach(([child,dest])=>{\n      if(dest===code) out.add(child);\n      else if(staticMembers.has(child)) out.delete(child);\n    });\n  }\n  return [...out];\n}\n// setores que a query no CRM precisa cobrir pra fechar este código, em qualquer nível — setor é\n// ele mesmo; GV é seus setores ATUAIS; mercado é todo setor de toda GV ATUALMENTE sua (setor não\n// troca de GV sozinho numa sessão de mercado — só GV inteira troca de mercado).\nfunction memberSectorsOf(level,code,world){\n  code=String(code);\n  if(level==='setor') return [code];\n  if(level==='gv') return currentMembersOf('gv',code,world);\n  const gvs=currentMembersOf('mercado',code,world);\n  const out=new Set();\n  gvs.forEach(gv=>(DOM.sectorsByGv[gv]||new Set()).forEach(s=>out.add(s)));\n  return [...out];\n}\n// fronteira que um destino tem de respeitar: GV do setor que fecha, mercado da GV que fecha —\n// mercado não tem nada acima, então não existe regra pra ele. Estática de propósito: um setor não\n// troca de GV nem uma GV troca de mercado só por existirem etapas anteriores nesta sessão — quem\n// muda de dono é o NÍVEL ABAIXO do que está fechando, nunca o próprio código sendo avaliado aqui.\nfunction parentOf(level,code){\n  code=String(code);\n  if(level==='setor') return DOM.gvBySetor[code]||'';\n  if(level==='gv') return DOM.mercadoByGv[code]||'';\n  return '';\n}\n// dado o setor que uma município vizinha atualmente pertence (fato ao vivo do CRM, nunca muda de\n// verdade), a que CANDIDATO A DESTINO isso corresponde neste nível — considerando que uma etapa\n// anterior desta sessão pode já ter movido esse setor (GV fechando) ou essa GV (mercado fechando)\n// pra outro dono, o que a foto estática do CSV sozinha não enxergaria.\nfunction destUnitOfSector(level,setorCode,world){\n  setorCode=String(setorCode);\n  if(level==='setor') return setorCode;\n  if(level==='gv'){ const ov=world&&world.secOverride&&world.secOverride[setorCode]; return ov||DOM.gvBySetor[setorCode]||''; }\n  const gv=DOM.gvBySetor[setorCode]||'';\n  const ov=world&&world.gvOverride&&world.gvOverride[gv];\n  return ov||DOM.mercadoBySetor[setorCode]||'';\n}\nfunction currentMoveLevel(){ const r=root.querySelector('input[name=\"moveLevel\"]:checked'); return (r&&r.value)||'setor'; }\nfunction openMove(){\n  const minRank=sessionMinRank();\n  const lastLevel=SESS.timeline.length?(SESS.timeline[SESS.timeline.length-1].level||'setor'):'setor';\n  root.querySelectorAll('input[name=\"moveLevel\"]').forEach(r=>{\n    const lvl=r.value;\n    r.disabled=!movementAvailable(lvl)||!hierarchyAllows(lvl);\n    r.checked=hierarchyAllows(lastLevel)?lvl===lastLevel:lvl===Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===minRank);\n  });\n  const note=$('#moveLockNote');\n  const minLabel=LEVELS[Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===minRank)].label;\n  if(note) note.textContent=minRank<LEVEL_RANK.setor\n    ? `Esta sessão já fechou um nível mais alto na hierarquia — só \"${minLabel}\" (ou acima) continua disponível daqui pra frente.`\n    : 'Escolha o nível a fechar. Você pode fechar setores à vontade até fechar uma GV; aí só GV (ou mercado) daqui pra frente. Fechar um mercado trava a sessão em mercado.';\n  $('#moveCode').value='';\n  const err=$('#moveErr'); if(err){ err.textContent=''; err.classList.add('hidden'); }\n  const st=$('#moveStatus'); if(st) st.textContent='';\n  $('#moveBg').classList.remove('hidden');\n  renderMoveForm();\n  const c=$('#moveCancel'); if(c) c.focus();\n}\nfunction closeMove(){ const b=$('#moveBg'); if(b) b.classList.add('hidden'); }\nfunction renderMoveForm(){\n  root.querySelectorAll('#moveBg .opt-row').forEach(r=>r.classList.toggle('on',r.dataset.lvl===currentMoveLevel()));\n  const lvl=LEVELS[currentMoveLevel()];\n  $('#moveCodeLbl').textContent=lvl.codeLbl;\n  $('#moveCode').placeholder=lvl.placeholder;\n  loadMoveOptions();\n}\nasync function loadMoveOptions(){\n  const lvl=currentMoveLevel();\n  const dl=$('#moveCodeList'); if(!dl) return;\n  const closedSet=new Set(SESS.timeline.filter(mv=>(mv.level||'setor')===lvl).map(mv=>mv.sector));\n  if(lvl==='setor'){\n    dl.innerHTML=SESS.sectorsList.filter(s=>!closedSet.has(s.setor))\n      .map(s=>`<option value=\"${s.setor}\">${s.setor} · ${s.uf||'?'} · ${fmt(s.cbs)} CBs · ${s.munis} munic.</option>`).join('');\n    return;\n  }\n  dl.innerHTML='';\n  try{ await ensureDominantes(); }catch(e){ return; }\n  if(lvl==='gv'){\n    const gvs=Object.keys(DOM.sectorsByGv||{}).filter(g=>!closedSet.has(g)).sort();\n    dl.innerHTML=gvs.map(g=>`<option value=\"${g}\">${g}${DOM.gvLabel[g]?' · '+esc(DOM.gvLabel[g]):''} · ${DOM.sectorsByGv[g].size} setor(es)</option>`).join('');\n  } else if(lvl==='mercado'){\n    const mks=Object.keys(DOM.sectorsByMercado||{}).filter(m=>!closedSet.has(m)).sort();\n    dl.innerHTML=mks.map(m=>`<option value=\"${m}\">${m} · ${(DOM.gvsByMercado[m]||new Set()).size} GV(s) · ${DOM.sectorsByMercado[m].size} setor(es)</option>`).join('');\n  }\n}\nfunction moveRandPick(){\n  const opts=[...($('#moveCodeList').options||[])].map(o=>o.value);\n  if(!opts.length) return;\n  $('#moveCode').value=opts[Math.floor(Math.random()*opts.length)];\n}\nasync function submitMove(){\n  const lvl=currentMoveLevel();\n  const code=($('#moveCode').value||'').trim();\n  const err=$('#moveErr');\n  const fail=msg=>{ if(err){ err.textContent=msg; err.classList.remove('hidden'); } };\n  if(err){ err.textContent=''; err.classList.add('hidden'); }\n  if(!movementAvailable(lvl)) return fail(`Fechar ${LEVELS[lvl].label} ainda não está disponível.`);\n  if(!hierarchyAllows(lvl)){ const minLabel=LEVELS[Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===sessionMinRank())].label; return fail(`Esta sessão já fechou um nível mais alto — só \"${minLabel}\" (ou acima) fica disponível.`); }\n  if(!code) return fail('Informe um código.');\n  closeMove();\n  await addMovement(lvl,code);\n}\nfunction renderStage(){\n  const isMain=SESS.stage==='main';\n  const cc=$('#confirmCard'), mw=$('#mainWrap');\n  const hdr=$('#topHeader'), rl=$('#timelineRail');\n  if(hdr) hdr.classList.toggle('hidden', !isMain);\n  if(rl) rl.classList.toggle('hidden', !isMain);\n  if(cc) cc.classList.toggle('hidden', isMain);\n  if(mw) mw.classList.toggle('hidden', !isMain);\n  const cb=$('#confirmBody');\n  if(cb && !isMain) cb.innerHTML = SESS.snapshotRaw\n    ? `Snapshot congelado · atualizado em <b>${fmtSyncedAt(SESS.snapshotSyncedAt)}</b> · <b>${SESS.sectorsList.length}</b> setor(es) disponível(eis) pra fechar. Nenhuma consulta de detalhe roda até você confirmar e escolher o primeiro setor.`\n    : 'congelando snapshot…';\n}\n\n// ---------- session persistence ----------\n// ONE table (`timeline-shared-sessions`) holds BOTH kinds of row, so SESSIONS_DS and SHARED_DS\n// deliberately resolve to the SAME DataSet — the two names mark intent, not two stores:\n//   · working session  — carries `movements`; saveSession updates it in place\n//   · shared snapshot  — carries `sharedAt` + `stages`; doShare CREATES it and nothing updates it\n// listSessions MUST exclude the shared rows. They are write-once by contract, and if one showed\n// up in the picker, loading it and pressing Salvar would fire ds.update() straight at a frozen\n// snapshot. Filtering here is what keeps that unreachable — it is the guard, not a cosmetic.\n// Both lookups stay because a policy-gated DataSet's Process resolves late; same race the reader\n// component polls for. SESSIONS_DS lives at module scope so no call site can hit it in the TDZ.\nasync function listSessions(){\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS)); if(!ds) return [];\n  await ds.refresh();\n  const recs=ds.records()||[];\n  return [...recs].filter(r=>!r.sharedAt).sort((a,b)=>new Date(b.updatedAt||0)-new Date(a.updatedAt||0));\n}\nasync function refreshSessionPicker(){\n  const list=await listSessions().catch(()=>[]);\n  SESS.sessionsList=list;\n  const opts=list.map(s=>`<option value=\"${s._id}\">${esc(s.label||'sem nome')} · ${fmt((s.movements||[]).length)} etapa(s)</option>`).join('')+`<option value=\"\">+ Nova sessão</option>`;\n  const sel=$('#sessionPick'); if(sel){ sel.innerHTML=opts; sel.value=SESS.sessionId||''; }\n  const csel=$('#confirmSessionPick'); if(csel){ csel.innerHTML=opts; csel.value=SESS.sessionId||''; }\n}\nasync function saveSession(){\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS));\n  if(!ds){ $('#sessStatus').textContent='dataset de sessões indisponível'; return; }\n  if(!SESS.timeline.length){ $('#sessStatus').textContent='nada pra salvar ainda'; return; }\n  await flushDirty();\n  const label=SESS.sessionLabel||('Sessão '+new Date().toLocaleString('pt-BR'));\n  const nowIso=new Date().toISOString();\n  const payload={\n    label,\n    settings:{...DEFAULT_SETTINGS,...(SESS.settings||{})},\n    snapshotRaw:SESS.snapshotRaw, snapshotSyncedAt:SESS.snapshotSyncedAt, snapLiteral:SESS.snapLiteral, eloMaxCycle:SESS.eloMaxCycle,\n    movements:SESS.timeline.map(mv=>({\n      sector:mv.sector, level:mv.level||'setor', assign:mv.assign, policy:mv.policy,\n      matrix:mv.matrix, trend:mv.trend, levels:mv.levels, levelByMuni:mv.levelByMuni,\n      pureOwnMetrics:mv.pureOwnMetrics, domUF:mv.domUF, dest:mv.dest, color:mv.color,\n      gv:mv.gv, gvRejected:mv.gvRejected,\n      missingMunis:mv.missingMunis, csvMunisTotal:mv.csvMunisTotal, ufGuessed:mv.ufGuessed,\n      baseMetrics:mv.baseMetrics, baseLevel:mv.baseLevel, geoFeat:mv.geoFeat, nbrFeat:mv.nbrFeat,\n      nbrCurrentSector:mv.nbrCurrentSector, allowed:mv.allowed, allowedRaw:mv.allowedRaw, reachable:mv.reachable,\n      destRule:mv.destRule, destMinCbs:mv.destMinCbs, destRuleFallback:mv.destRuleFallback, optTier:mv.optTier,\n      cbsByMuni:mv.cbsByMuni, shapeMunis:mv.shapeMunis, excludedMunis:mv.excludedMunis, domCsvMunis:mv.domCsvMunis,\n      excludedCbs:mv.excludedCbs, noShapeMunis:mv.noShapeMunis, noShapeCbs:mv.noShapeCbs,\n      secCentroid:mv.secCentroid, muniCentroid:mv.muniCentroid, closeAdj:mv.closeAdj, optBadge:mv.optBadge,\n    })),\n    updatedAt:nowIso,\n  };\n  $('#sessStatus').textContent='salvando…';\n  try{\n    if(SESS.sessionId){\n      await ds.update({_id:SESS.sessionId,...payload});\n    } else {\n      payload.createdAt=nowIso;\n      const created=await ds.create(payload);\n      SESS.sessionId=(created&&created._id)||(created&&created.data&&created.data._id)||(ds.selected&&ds.selected()&&ds.selected()._id)||null;\n    }\n    SESS.sessionLabel=label;\n    await refreshSessionPicker();\n    $('#sessStatus').textContent='salvo ✓';\n  }catch(e){ $('#sessStatus').textContent='erro ao salvar: '+e.message; }\n  setTimeout(()=>{ if($('#sessStatus')) $('#sessStatus').textContent=''; },2200);\n}\n// Duplicate = drop sessionId so saveSession takes its CREATE branch instead of its update branch.\n// The source record is never written to, the copy gets a fresh _id and its own createdAt, and the\n// copy becomes the active session (so the next Salvar edits the copy, not the original).\nasync function duplicateSession(){\n  if(!SESS.timeline.length){ $('#sessStatus').textContent='nada pra duplicar ainda'; return; }\n  const prevId=SESS.sessionId, prevLabel=SESS.sessionLabel;\n  SESS.sessionId=null;\n  SESS.sessionLabel=(SESS.sessionLabel||'Sessão')+' (cópia)';\n  await saveSession();\n  if(!SESS.sessionId){\n    // saveSession swallows its own errors into #sessStatus, so a still-null id after the await is\n    // the only failure signal we get. Put the original identity back — otherwise a failed copy\n    // silently leaves the session detached, and the next Salvar would fork a new record instead\n    // of updating the one the user thought they were editing.\n    SESS.sessionId=prevId; SESS.sessionLabel=prevLabel;\n    await refreshSessionPicker().catch(()=>{});\n    return;\n  }\n  if($('#sessStatus')) $('#sessStatus').textContent='duplicado ✓';\n}\nasync function loadSession(id){\n  if(!id) return newSession();\n  const list=(SESS.sessionsList&&SESS.sessionsList.length)?SESS.sessionsList:await listSessions().catch(()=>[]);\n  const rec=list.find(s=>String(s._id)===String(id));\n  if(!rec) return newSession();\n  loader(true,'carregando sessão…');\n  SESS.sessionId=rec._id; SESS.sessionLabel=rec.label;\n  SESS.snapshotRaw=rec.snapshotRaw; SESS.snapshotSyncedAt=rec.snapshotSyncedAt||null; SESS.snapLiteral=rec.snapLiteral; SESS.eloMaxCycle=rec.eloMaxCycle;\n  SESS.settings={...DEFAULT_SETTINGS,...(rec.settings||{})};\n  SESS.timeline=(rec.movements||[]).map(m=>({sector:String(m.sector),level:m.level||'setor'}));\n  SESS.selected=0; SESS.sectorBaseCache={}; SESS.stage='main'; SESS.dirtyFrom=null;\n  try{\n    for(let i=0;i<SESS.timeline.length;i++){\n      const saved=(rec.movements||[])[i]||{};\n      // Sessions saved before the UF became part of the key hold bare-name keys in matrix rows,\n      // assign, geoFeat and closeAdj alike. Restoring that cache would mix key shapes the moment the\n      // stage is recomputed — and it would preserve the very collision this fixes — so those stages\n      // are replayed against the pinned snapshot and the manual assignment is remapped by name.\n      const legacyKeys=!!(saved.matrix&&saved.matrix.length&&saved.matrix.every(r=>r&&r.uf==null));\n      // Sessões salvas antes da regra de gerência podem carregar um destino de outra GV no cache.\n      // Restaurar esse cache manteria o setor proibido na tela para sempre, então essas etapas são\n      // reprocessadas contra o snapshot fixado — igual ao tratamento das chaves legadas.\n      const crossGv=await savedCrossesGv(saved);\n      if(saved.matrix&&!legacyKeys&&!crossGv){\n        loader(true,`restaurando etapa ${i+1}/${SESS.timeline.length} — setor ${SESS.timeline[i].sector} (cache, sem consulta)…`);\n        const mv={sector:String(saved.sector), level:saved.level||'setor', policy:saved.policy||'fora', view:'tobe', selMuni:null, optBadge:saved.optBadge||''};\n        mv.matrix=saved.matrix; mv.trend=saved.trend||[]; mv.levels=saved.levels||[]; mv.levelByMuni=saved.levelByMuni||{};\n        mv.pureOwnMetrics=saved.pureOwnMetrics||blankM(); mv.domUF=saved.domUF; mv.dest=saved.dest||[]; mv.color=saved.color||{};\n        mv.baseMetrics=saved.baseMetrics||{}; mv.baseLevel=saved.baseLevel||{}; mv.geoFeat=saved.geoFeat||{}; mv.nbrFeat=saved.nbrFeat||[];\n        mv.nbrCurrentSector=saved.nbrCurrentSector||{}; mv.allowed=saved.allowed||{}; mv.reachable=saved.reachable||{};\n        mv.allowedRaw=saved.allowedRaw||saved.allowed||{};\n        mv.destRule=saved.destRule||SESS.settings.destRule; mv.destMinCbs=saved.destMinCbs||SESS.settings.destMinCbs; mv.destRuleFallback=!!saved.destRuleFallback;\n        mv.optTier=(saved.optTier==null?0:saved.optTier);\n        mv.gv=saved.gv||''; mv.gvRejected=saved.gvRejected||[];\n        mv.missingMunis=saved.missingMunis||[]; mv.csvMunisTotal=saved.csvMunisTotal||0;\n        mv.ufGuessed=saved.ufGuessed||{};\n        mv.cbsByMuni=saved.cbsByMuni||{}; mv.shapeMunis=saved.shapeMunis||[];\n        mv.excludedMunis=saved.excludedMunis||[]; mv.excludedCbs=saved.excludedCbs||0; mv.domCsvMunis=saved.domCsvMunis||0;\n        mv.noShapeMunis=saved.noShapeMunis||[]; mv.noShapeCbs=saved.noShapeCbs||0;\n        mv.secCentroid=saved.secCentroid||{}; mv.muniCentroid=saved.muniCentroid||{}; mv.closeAdj=saved.closeAdj||{};\n        aggregateMovement(mv);\n        mv.assign=saved.assign||{};\n        if((mv.level||'setor')==='setor') mv.dest.forEach(s=>{ if(!SESS.sectorBaseCache[s]) SESS.sectorBaseCache[s]=mv.baseMetrics[s]; });\n        SESS.timeline[i]=mv;\n      } else {\n        loader(true,`replay ${i+1}/${SESS.timeline.length} — setor ${SESS.timeline[i].sector}…`);\n        await computeAt(i);\n        if(saved.assign) SESS.timeline[i].assign=legacyKeys?remapAssign(SESS.timeline[i],saved.assign):saved.assign;\n        if(saved.policy) SESS.timeline[i].policy=saved.policy;\n      }\n    }\n    await loadSectors();\n  }catch(e){ $('#ds').textContent='erro ao carregar sessão: '+e.message; $('#ds').classList.add('err'); }\n  finally{ loader(false); }\n  renderAll();\n}\n// Re-points a legacy bare-name assignment at the NAME/UF key it can only mean. When a name resolves\n// to more than one município in the recomputed stage the manual choice is genuinely ambiguous —\n// dropping it lets the optimizer allocate instead of guessing on the operator's behalf.\n// True when a cached stage names a destination outside the closing sector's gerência de vendas.\n// Judges the cache by TODAY's rule, reading the (memoised) dominantes base rather than trusting what\n// the session was saved with. Unknown GV on the closing sector answers false: the stage then loads\n// from cache instead of being replayed into the hard throw in computeMovement, which would leave the\n// operator staring at a broken session with no way back.\nasync function savedCrossesGv(saved){\n  if(!saved||!saved.sector||!(saved.dest||[]).length) return false;\n  let gvBy={};\n  try{ await ensureDominantes(); gvBy=DOM.gvBySetor||{}; }catch(e){ return false; }\n  const own=gvBy[String(saved.sector)]||'';\n  if(!own) return false;\n  if(saved.dest.some(s=>(gvBy[String(s)]||'')!==own)) return true;\n  // A stage saved before foreign-GV neighbours stopped being drawn can hold a perfectly clean dest and\n  // STILL carry their shapes in nbrFeat/nbrCurrentSector — it would repaint the old map straight from\n  // cache. An unknown gerência is not evidence of a foreign one, so only a known, differing GV replays.\n  return Object.values(saved.nbrCurrentSector||{}).some(s=>{ const g=gvBy[String(s)]||''; return g&&g!==own; });\n}\nfunction remapAssign(mv,savedAssign){\n  const keys=Object.keys(mv.munis||{}).filter(k=>k!=='__OUTROS__');\n  const out={};\n  Object.entries(savedAssign||{}).forEach(([k,v])=>{\n    if(keys.indexOf(k)>=0){ out[k]=v; return; }\n    const cand=keys.filter(x=>mName(x)===mName(k));\n    if(cand.length===1) out[cand[0]]=v;\n  });\n  return out;\n}\nfunction newSession(){\n  SESS.sessionId=null; SESS.sessionLabel=null; SESS.timeline=[]; SESS.selected=0; SESS.sectorBaseCache={};\n  SESS.settings={...DEFAULT_SETTINGS};\n  SESS.snapshotRaw=null; SESS.snapshotSyncedAt=null; SESS.snapLiteral=null; SESS.eloMaxCycle=null; SESS.stage='loading'; SESS.dirtyFrom=null;\n  (async()=>{\n    loader(true,'iniciando nova sessão…');\n    try{ await Promise.all([ensurePinned(), loadSectors()]); SESS.stage='confirm'; }\n    catch(e){ setError('erro init: '+e.message); }\n    finally{ loader(false); renderAll(); }\n  })();\n}\n\n// ---------- snapshot refresh ----------\n// Deliberately breaks the frozen-snapshot guarantee, on demand: re-resolves DT_HISTORICO and\n// replays the whole timeline against the new one. Skips the replay when the snapshot has not\n// actually moved, so a stray click on a long timeline costs one cheap probe instead of ~8 queries\n// per etapa.\nasync function refreshSnapshot(){\n  if(SESS.stage!=='main' || SESS.snapBusy) return;\n  SESS.snapBusy=true;\n  const prevRaw=SESS.snapshotRaw;\n  const pill=$('#liveState'); if(pill){ pill.classList.add('busy'); pill.textContent='◌ atualizando snapshot…'; }\n  loader(true,'buscando o snapshot mais recente…');\n  let ok=false;\n  try{\n    const p=await resolveSnapshotPin();\n    if(!p.snapshotRaw) throw new Error('não foi possível resolver DT_HISTORICO');\n    const same=String(p.snapshotRaw)===String(prevRaw);\n    SESS.snapshotRaw=p.snapshotRaw; SESS.snapshotSyncedAt=p.syncedAt; SESS.snapLiteral=p.snapLiteral; SESS.eloMaxCycle=p.eloMaxCycle;\n    await loadSectors();   // the sector list follows the live max, so it can have moved too\n    if(same){\n      $('#ds').textContent=`snapshot já era o mais recente · atualizado em ${fmtSyncedAt(p.syncedAt)} — nada recalculado`;\n    } else if(SESS.timeline.length){\n      SESS.dirtyFrom=null;\n      SESS.sectorBaseCache={};   // baselines come from the snapshot, so every one of them changed\n      loader(true,'snapshot novo — recalculando a timeline…');\n      await recomputeFrom(0);\n      $('#ds').textContent=`snapshot atualizado em ${fmtSyncedAt(p.syncedAt)} · ${fmt(SESS.timeline.length)} etapa(s) recalculada(s)`;\n    } else {\n      $('#ds').textContent=`snapshot atualizado em ${fmtSyncedAt(p.syncedAt)}`;\n    }\n    $('#ds').classList.remove('err');\n    ok=true;\n  }catch(e){\n    const at=(SESS.dirtyFrom==null?null:SESS.dirtyFrom+1);\n    $('#ds').textContent='erro ao atualizar o snapshot'+(at?` na etapa ${at} — etapa ${at} em diante ficou desatualizada (⟳)`:'')+': '+e.message;\n    $('#ds').classList.add('err');\n  }finally{\n    SESS.snapBusy=false;\n    if(pill) pill.classList.remove('busy');\n    loader(false); renderAll();\n    if(ok) setTimeout(()=>{ const d=$('#ds'); if(d&&!d.classList.contains('err')) d.textContent=''; },7000);\n  }\n}\n\n// ---------- session options ----------\nfunction previewDest(mv,rule,minCbs){\n  if(!mv||!mv.allowedRaw||!mv.munis) return null;\n  const w={}; let relaxed=false;\n  const cbsOf=m=>(mv.cbsByMuni&&mv.cbsByMuni[m]!=null)?mv.cbsByMuni[m]:((mv.munis[m]&&mv.munis[m].cbs)||0);\n  const add=m=>Object.entries(mv.allowedRaw[m]||{}).forEach(([s,v])=>{ w[s]=(w[s]||0)+v; });\n  // allowedRaw only carries municipalities already inside the sector's dominance base, so there is\n  // nothing left to filter out here beyond the destination rule itself.\n  Object.keys(mv.allowedRaw).forEach(m=>{ const cbs=cbsOf(m);\n    if(rule==='dominant'&&cbs<minCbs) return; add(m); });\n  if(rule==='dominant'&&!Object.keys(w).length){ Object.keys(mv.allowedRaw).forEach(add); relaxed=true; }\n  return {dest:Object.keys(w).sort((a,b)=>w[b]-w[a]).slice(0,6),relaxed};\n}\nfunction readOptForm(){\n  const r=root.querySelector('input[name=\"destRule\"]:checked');\n  let n=Math.round(+$('#optMinCbs').value);\n  if(!(n>0)) n=DEFAULT_SETTINGS.destMinCbs;\n  return {destRule:(r&&r.value)||DEFAULT_SETTINGS.destRule, destMinCbs:n,\n          targetSpan:numOrNull($('#optTargetSpan').value),\n          maxSpan:numOrNull($('#optMaxSpan').value),\n          minSpan:numOrNull($('#optMinSpan').value)};\n}\nfunction spanFormError(f){\n  if(f.minSpan&&f.maxSpan&&f.minSpan>f.maxSpan) return `Span mínimo (${fmt(f.minSpan)}) não pode ser maior que o máximo (${fmt(f.maxSpan)}).`;\n  if(f.targetSpan&&f.maxSpan&&f.targetSpan>f.maxSpan) return `Span alvo (${fmt(f.targetSpan)}) não pode passar do máximo (${fmt(f.maxSpan)}).`;\n  if(f.targetSpan&&f.minSpan&&f.targetSpan<f.minSpan) return `Span alvo (${fmt(f.targetSpan)}) não pode ficar abaixo do mínimo (${fmt(f.minSpan)}).`;\n  return '';\n}\nfunction renderOptPreview(){\n  const el=$('#optPreview'); if(!el) return;\n  const f=readOptForm();\n  root.querySelectorAll('.opt-row').forEach(r=>{\n    if(r.dataset.r!=null) r.classList.toggle('on',r.dataset.r===f.destRule);\n  });\n  const wrap=$('#optMinCbsWrap'); if(wrap) wrap.classList.toggle('off',f.destRule!=='dominant');\n  const err=spanFormError(f), ee=$('#optSpanErr');\n  if(ee){ ee.textContent=err; ee.classList.toggle('hidden',!err); }\n  const ap=$('#optApply'); if(ap) ap.disabled=!!err;\n  const mv=SESS.timeline[SESS.selected];\n  // Prévia inteira é sobre conceitos de setor (ponte por município, span alvo/máx/mín) que não\n  // existem em GV/mercado — mostrar aqui só confundiria com números que não valem nada.\n  if(mv && (mv.level||'setor')!=='setor'){ el.innerHTML=''; return; }\n  if(!mv||!mv.munis){ el.innerHTML='Nenhuma etapa calculada ainda — as regras passam a valer no próximo fechamento.'; return; }\n  const cbsOf=m=>(mv.cbsByMuni&&mv.cbsByMuni[m]!=null)?mv.cbsByMuni[m]:((mv.munis[m]&&mv.munis[m].cbs)||0);\n  const ms=Object.keys(mv.geoFeat||{});\n  const ig=f.destRule==='dominant'?ms.filter(m=>cbsOf(m)<f.destMinCbs):[];\n  const pv=previewDest(mv,f.destRule,f.destMinCbs);\n  let h=`<b style=\"color:var(--ink)\">Prévia — etapa ${SESS.selected+1}, setor ${mv.sector}</b><br>`;\n  const exN=(mv.excludedMunis||[]).length;\n  if(exN) h+=`Base de dominantes: <b>${fmt(ms.length)}</b> município(s) no mapa · <span style=\"color:var(--closing)\">${fmt(exN)} fora da base — ${fmt(mv.excludedCbs||0)} CBs seguem suas líderes</span><br>`;\n  h+=`${fmt(ms.length-ig.length)} de ${fmt(ms.length)} município(s) valem como ponte`;\n  if(ig.length) h+=` · <span style=\"color:var(--closing)\">ignorados:</span> ${ig.slice(0,8).join(', ')}${ig.length>8?' +'+(ig.length-8):''}`;\n  if(pv){\n    h+=`<br>Setores destino com esta regra: <b style=\"color:var(--ink)\">${pv.dest.join(', ')||'–'}</b>`;\n    const cur=(mv.dest||[]).join(', ');\n    if(pv.dest.join(',')!==(mv.dest||[]).join(',')) h+=` <span style=\"color:var(--closing)\">(hoje: ${cur||'–'})</span>`;\n    if(pv.relaxed) h+=`<br><b style=\"color:var(--closing)\">⚠ nenhum município dominante nesta etapa — a regra relaxa pra \"qualquer município\" pra não deixar a etapa sem destino.</b>`;\n  }\n  if(f.targetSpan||f.maxSpan||f.minSpan){\n    const sp=spanMapFor(mv,mv.assign||{}), v=spanViolationsOf(sp,{target:f.targetSpan,max:f.maxSpan,min:f.minSpan});\n    h+=`<br><br>Span dos destinos como está hoje: ${mv.dest.map(s=>s+' <b>'+fmt(sp[s])+'</b>').join(' · ')||'–'}`;\n    if(v.over.length||v.under.length) h+=`<br><span style=\"color:var(--closing)\">Fora dos limites: ${[...v.over.map(s=>s+' ▲ acima do máx'),...v.under.map(s=>s+' ▼ abaixo do mín')].join(', ')}</span> — o modelo ÓTIMO tenta corrigir no recálculo.`;\n    else h+=` <span style=\"color:var(--ok)\">— já dentro dos limites</span>`;\n  }\n  if(SESS.timeline.length) h+=`<br><span style=\"margin-top:4px;display:inline-block\">Aplicar <b>recalcula as ${fmt(SESS.timeline.length)} etapa(s)</b> contra o snapshot congelado — os destinos ajustados à mão serão refeitos pelo modelo ÓTIMO.</span>`;\n  el.innerHTML=h;\n}\nfunction openOptions(){\n  const st={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  root.querySelectorAll('input[name=\"destRule\"]').forEach(r=>{ r.checked=(r.value===st.destRule); });\n  $('#optMinCbs').value=st.destMinCbs;\n  $('#optTargetSpan').value=st.targetSpan||'';\n  $('#optMaxSpan').value=st.maxSpan||'';\n  $('#optMinSpan').value=st.minSpan||'';\n  $('#optApply').disabled=false;\n  $('#optSessName').textContent=SESS.sessionLabel||'sessão não salva';\n  $('#optStatus').textContent='';\n  $('#optionsBg').classList.remove('hidden');\n  renderOptPreview();\n  const c=$('#optCancel'); if(c) c.focus();\n}\nfunction closeOptions(){ const b=$('#optionsBg'); if(b) b.classList.add('hidden'); }\nasync function applyOptions(){\n  const f=readOptForm();\n  if(spanFormError(f)) return;\n  const prev={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  const changed = prev.destRule!==f.destRule\n    || (f.destRule==='dominant' && prev.destMinCbs!==f.destMinCbs)\n    || numOrNull(prev.targetSpan)!==f.targetSpan\n    || numOrNull(prev.maxSpan)!==f.maxSpan\n    || numOrNull(prev.minSpan)!==f.minSpan;\n  SESS.settings={...prev,...f};\n  closeOptions();\n  if(!changed || !SESS.timeline.length){ renderAll(); return; }\n  SESS.dirtyFrom=null;\n  SESS.sectorBaseCache={};   // the recompute re-derives every baseline; keeping the old ones would\n                             // leave \"Impacto acumulado\" comparing against pre-change numbers\n  loader(true,'aplicando nova regra de destino — recalculando timeline…');\n  try{\n    await recomputeFrom(0);\n    $('#ds').textContent=''; $('#ds').classList.remove('err');\n  }catch(e){\n    const at=(SESS.dirtyFrom==null?0:SESS.dirtyFrom)+1;\n    $('#ds').textContent=`erro ao aplicar a regra na etapa ${at}: ${e.message} — etapa ${at} em diante ficou desatualizada (⟳)`;\n    $('#ds').classList.add('err');\n  }finally{ loader(false); renderAll(); }\n}\n\nconst SHARED_DS='timeline-shared-sessionsDataSet';\nconst AUTH_REF='timelineSimAuthFinal';   // single source of truth for the auth strategy reference\nlet SHARE={busy:false,url:null,last:null};\n// Serialise a PRESENTATION model, not the raw session, so the reader stays purely presentational\n// and never re-derives spans/desloc/orphans — no second copy of this engine to keep in sync.\n// NOTE: METRICS_DEF[2] is the money marker; read it as !!md[2] (a literal dollar sign in this\n// position trips the upsert's parse check).\nfunction buildShareStages(){\n  return SESS.timeline.map((mv,i)=>{\n    const c=computeDerived(mv);\n    const incBy={};\n    mv.dest.forEach(s=>{ incBy[s]=Object.fromEntries(METRICS_DEF.map(md=>[md[0],0])); });\n    Object.entries(mv.munis).forEach(([m,o])=>{\n      if(m==='__OUTROS__') return;\n      const s=mv.assign[m]; if(!s||!incBy[s]) return;\n      METRICS_DEF.forEach(md=>{ incBy[s][md[0]]+=o[md[0]]||0; });\n    });\n    const saiTot={};\n    METRICS_DEF.forEach(md=>{\n      const k=md[0];\n      saiTot[k]=Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,p)=>a+((p[1][k])||0),0);\n    });\n    // orphans follow their leader's home: they enter that sector AND leave the closing one\n    c.leaders.forEach(L=>{\n      const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n      if(!om||!L.home) return;\n      METRICS_DEF.forEach(md=>{ const k=md[0];\n        if(incBy[L.home]) incBy[L.home][k]+=om[k]||0;\n        saiTot[k]+=om[k]||0; });\n    });\n    const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n    const sv=mv.dest.map(s=>c.span[s]);\n    const baseOf=(s,k)=>{ const b=mv.baseMetrics[s]||{}; return +(b[k]||0); };\n    return {\n      i:i, sector:mv.sector, level:mv.level||'setor', unitOfMuni:mv.unitOfMuni||{}, policy:mv.policy, domUF:mv.domUF,\n      optTier:(mv.optTier==null?0:mv.optTier),\n      destRuleFallback:!!mv.destRuleFallback,\n      dest:mv.dest.slice(), color:Object.assign({},mv.color), assign:Object.assign({},mv.assign),\n      kpis:{ cbsRealocadas:closingCbsOf(mv), desloc:c.desloc,\n             spread:(sv.length?Math.max.apply(null,sv)-Math.min.apply(null,sv):0),\n             municipios:ms.length, lideres:mv.leaders.length },\n      destinos:mv.dest.map(s=>({ s:s, base:baseOf(s,'cbs'), inc:c.inc[s]||0, orph:c.orph[s]||0, span:c.span[s]||0, gained:c.gained[s]||0 })),\n      municipios:ms.map(m=>(mv.level&&mv.level!=='setor')?{ nome:m, uf:'', cbs:mv.munis[m].cbs, destino:mv.assign[m]||null }:{ nome:mName(m), uf:mUf(m), cbs:mv.munis[m].cbs, destino:mv.assign[m]||null }),\n      excluidos:(mv.excludedMunis||[]).map(x=>({ muni:x.muni, cbs:x.cbs })),\n      orphanTotal:c.orphanTotal||0, orphanStranded:c.orphanStranded||0, noShapeCbs:mv.noShapeCbs||0,\n      lideres:c.leaders.map(L=>({ lider:(L.lider||'').split(' ').slice(0,2).join(' '), grupo:L.grupo,\n        total:L.total, home:L.home, desloc:L.desloc, orphan:L.orphan||0 })),\n      metricas:METRICS_DEF.map(md=>{ const k=md[0];\n        return { key:k, label:md[1], money:!!md[2],\n          rows:mv.dest.map(s=>({ s:s, antes:baseOf(s,k), depois:baseOf(s,k)+((incBy[s]&&incBy[s][k])||0) })),\n          sai:saiTot[k]||0 }; }),\n      geoFeat:mv.geoFeat, nbrFeat:mv.nbrFeat, excludedFeat:[]\n    };\n  });\n}\nfunction buildShareSummary(stages){\n  const world=foldWorldState(SESS.timeline,SESS.timeline.length);\n  const receivers=[];\n  SESS.timeline.forEach((mv,i)=>{\n    const own=mv.pureOwnMetrics||{};\n    receivers.push({ s:mv.sector, situacao:'fechado (etapa '+(i+1)+')', antes:+(own.cbs||0), depois:0 });\n  });\n  const seen={};\n  Object.values(world.muniOverride).forEach(s=>{\n    if(seen[s]) return; seen[s]=1;\n    const base=SESS.sectorBaseCache[s]||{}, infl=world.sectorInflow[s]||{};\n    receivers.push({ s:s, situacao:'recebeu território', antes:+(base.cbs||0), depois:(+(base.cbs||0))+(infl.cbs||0) });\n  });\n  return { stages:stages.length, sectors:SESS.timeline.map(mv=>mv.sector),\n    totalCbs:stages.reduce((a,s)=>a+(s.kpis.cbsRealocadas||0),0),\n    totalDesloc:stages.reduce((a,s)=>a+(s.kpis.desloc||0),0), receivers:receivers };\n}\nfunction renderShareBody(){\n  const el=$('#shareBody'); if(!el) return;\n  const go=$('#shareGo'), cp=$('#shareCopy'), op=$('#shareOpen'), cc=$('#shareCancel');\n  if(SHARE.url){\n    el.innerHTML=`<div class=\"opt-note\" style=\"margin-top:0\"><b style=\"color:var(--ink)\">Sessão compartilhada ✓</b><br>\n      Link de leitura desta simulação congelada. Quem abrir precisa ter login no simulador.\n      <div class=\"shareurl\">${esc(SHARE.url)}</div></div>`;\n    if(go) go.classList.add('hidden');\n    if(cp) cp.classList.remove('hidden');\n    if(op) op.classList.remove('hidden');\n    if(cc) cc.textContent='Fechar';\n    return;\n  }\n  if(go) go.classList.remove('hidden');\n  if(cp) cp.classList.add('hidden');\n  if(op) op.classList.add('hidden');\n  if(cc) cc.textContent='Cancelar';\n  const st={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  const desloc=SESS.timeline.reduce((a,mv)=>a+(mv.munis?computeDerived(mv).desloc:0),0);\n  const destTxt=st.destRule==='dominant'?('dominantes ≥'+fmt(st.destMinCbs)+' CBs'):'qualquer município';\n  el.innerHTML=`<div class=\"opt-lbl\">Confirmar compartilhamento</div>\n    <div class=\"opt-sub\">Publica uma <b>cópia congelada</b> desta sessão, exatamente no estado atual.</div>\n    <div class=\"opt-note\" style=\"margin-top:0\">\n      · <b>${fmt(SESS.timeline.length)}</b> etapa(s) — ${esc(SESS.timeline.map(mv=>mv.sector).join(' → '))}<br>\n      · snapshot atualizado em <b>${esc(fmtSyncedAt(SESS.snapshotSyncedAt))}</b><br>\n      · base: <b>municípios dominantes (CSV)</b> · destino: <b>${esc(destTxt)}</b> · span: <b>${esc(spanShort(st))}</b><br>\n      · <b>${fmt(desloc)}</b> CBs deslocadas somando a cadeia\n    </div>\n    <div class=\"opt-note\">A cópia <b>não</b> acompanha edições posteriores desta sessão — pra atualizar, compartilhe de novo e use o link novo. O link exige login no simulador; não é público.</div>\n    ${SHARE.last?`<div class=\"opt-note\"><b style=\"color:var(--ink)\">Último link gerado nesta sessão</b> — aponta pro estado de <b>antes</b> das edições feitas depois dele.<div class=\"shareurl\">${esc(SHARE.last)}</div></div>`:''}`;\n}\nasync function openShare(){\n  if(!SESS.timeline.length){ $('#ds').textContent='nada pra compartilhar — feche pelo menos um setor primeiro.'; $('#ds').classList.add('err'); return; }\n  await flushDirty();\n  SHARE.url=null;\n  const s=$('#shareStatus'); if(s){ s.textContent=''; s.classList.remove('err'); }\n  renderShareBody();\n  $('#shareBg').classList.remove('hidden');\n  const b=$('#shareCancel'); if(b) b.focus();\n}\nfunction closeShare(){ const b=$('#shareBg'); if(b) b.classList.add('hidden'); }\nasync function doShare(){\n  if(SHARE.busy) return;\n  const go=$('#shareGo'), stx=$('#shareStatus');\n  SHARE.busy=true;\n  if(go){ go.disabled=true; go.textContent='Compartilhando…'; }\n  if(stx){ stx.textContent=''; stx.classList.remove('err'); }\n  try{\n    // Resolved as a GLOBAL PROCESS, not a declared param — which only works because the block\n    // instance on the `main` artboard binds `sharedDataset: @timeline-shared-sessionsDataSet`.\n    // That binding is load-bearing: a Process is only define()d once something on the rendering\n    // tree references it, so without it this lookup returns null and sharing dies on the guard\n    // below. Do NOT remove the instance param because this file never reads d2.params.sharedDataset.\n    const ds=d2.data(SHARED_DS);\n    if(!ds) throw new Error('dataset de sessões compartilhadas indisponível');\n    await flushDirty();\n    const stages=buildShareStages();\n    const summary=buildShareSummary(stages);\n    let who='';\n    try{ const a=d2.auth(AUTH_REF); who=(a&&a.user&&(a.user.email||a.user.nome))||''; }catch(e){}\n    const created=await ds.create({\n      label:SESS.sessionLabel||('Sessão '+new Date().toLocaleString('pt-BR')),\n      snapshotRaw:SESS.snapshotRaw, snapshotSyncedAt:SESS.snapshotSyncedAt,\n      settings:{...DEFAULT_SETTINGS,...(SESS.settings||{})},\n      stages:stages, summary:summary, sharedBy:who, sharedAt:new Date().toISOString()\n    });\n    const id=(created&&created._id)||(created&&created.data&&created.data._id)||(ds.selected&&ds.selected()&&ds.selected()._id)||null;\n    if(!id) throw new Error('registro gravado, mas sem _id na resposta — não foi possível montar o link');\n    SHARE.url=window.location.origin+'/sessions/'+id;\n    SHARE.last=SHARE.url;\n    renderShareBody();\n  }catch(e){\n    if(stx){ stx.textContent='erro ao compartilhar: '+e.message; stx.classList.add('err'); }\n  }finally{\n    SHARE.busy=false;\n    if(go){ go.disabled=false; go.textContent='Compartilhar'; }\n  }\n}\n\n// ---------- rendering ----------\nfunction renderTimeline(){\n  const el=$('#movementChips'); if(!el) return;\n  if(!SESS.timeline.length){ el.innerHTML='<span class=\"empty\">Nenhuma etapa ainda — use \"Adicionar movimentação\" à direita.</span>'; return; }\n  el.innerHTML=SESS.timeline.map((mv,i)=>{\n    const ready=mv.dest!==undefined;\n    const c=ready?computeDerived(mv):null;\n    const stale=SESS.dirtyFrom!=null && i>=SESS.dirtyFrom;\n    return `<div class=\"tchip ${i===SESS.selected?'on':''} ${stale?'stale':''}\" data-i=\"${i}\">\n      <span class=\"tnum\">${i+1}</span><span class=\"tsec\">${LEVELS[mv.level||'setor'].label} ${mv.sector}</span>\n      <span class=\"tstat\">${ready?((mv.level||'setor')==='setor'?fmt(c.desloc)+' desloc.':fmt(closingCbsOf(mv))+' CBs'):'…'}</span>\n      ${stale?'<span class=\"tstale\" title=\"recalcula ao selecionar outra etapa\">⟳</span>':''}\n      <span class=\"trm\" data-rm=\"${i}\" title=\"remover esta etapa e as seguintes\">✕</span>\n    </div>`;\n  }).join('');\n  el.querySelectorAll('.tchip').forEach(ch=>{\n    ch.addEventListener('click',async (e)=>{\n      if(e.target.dataset.rm!=null && e.target.dataset.rm!==''){ removeFrom(+e.target.dataset.rm); return; }\n      const i=+ch.dataset.i;\n      if(i!==SESS.selected) await flushDirty();\n      SESS.selected=i; renderAll();\n    });\n  });\n}\nfunction renderRuleNote(){\n  const el=$('#ruleNote'); if(!el) return;\n  const st={...DEFAULT_SETTINGS,...(SESS.settings||{})};\n  const mv=SESS.timeline[SESS.selected];\n  const lvl=LEVELS[(mv&&mv.level)||'setor'];\n  let h='';\n  if(!mv || (mv.level||'setor')==='setor'){\n    h+=`<b style=\"color:var(--ink)\">Base de municípios dominantes:</b> só os municípios que a base (CSV) atribui ao setor entram no mapa e na alocação`;\n    if(mv&&mv.domCsvMunis) h+=` · <b>${fmt(mv.domCsvMunis)}</b> na base do setor ${mv.sector}`;\n    if(mv&&mv.excludedCbs) h+=` · <b style=\"color:var(--closing)\">${fmt(mv.excludedMunis.length)} município(s) / ${fmt(mv.excludedCbs)} CBs</b> fora da base nesta etapa — não aparecem no mapa e as CBs seguem suas líderes (quebra de vínculo)`;\n  } else {\n    // GV/mercado não têm spillover (todo setor pertence a exatamente uma GV/mercado na base), então\n    // não existe \"excluído\" pra reportar aqui — só o tamanho da base e em quantas unidades ela se agrupa.\n    h+=`<b style=\"color:var(--ink)\">Base de ${lc(lvl.childLabel)}s dominantes:</b> ${mv.domCsvMunis!=null?`<b>${fmt(mv.domCsvMunis)}</b> município(s) `:''}na base ${lvl.de} ${lc(lvl.label)} ${mv.sector}, agrupados em <b>${fmt(Object.keys(mv.unitMunis||{}).length)}</b> ${lc(lvl.childLabel)}(s)`;\n  }\n  if(DOM.error) h+=` · <b style=\"color:var(--closing)\">⚠ base indisponível: ${esc(DOM.error)}</b>`;\n  h+=`<br>`;\n  h+=`<b style=\"color:var(--ink)\">Regra de destino:</b> `;\n  h+= st.destRule==='dominant'\n    ? `só municípios com <b>≥${fmt(st.destMinCbs)} CBs</b> trazem ${lc(lvl.destLabel)} pra lista de destinos`\n    : `qualquer município ${lvl.de} ${lc(lvl.label)} fechad${lvl.a==='à'?'a':'o'} vale como ponte <span style=\"opacity:.7\">(padrão)</span>`;\n  if(mv&&st.destRule==='dominant'){\n    // A ponte é sempre por MUNICÍPIO, em qualquer nível — mv.ignoredBridges já é a lista definitiva\n    // que computeMovement calculou, então lê-la aqui em vez de recalcular contra mv.munis, que em\n    // GV/mercado está no grão de setor/GV e daria o número errado.\n    const ig=mv.ignoredBridges||[];\n    if(ig.length) h+=` · <b style=\"color:var(--closing)\">${fmt(ig.length)}</b> ignorado(s) como ponte (ainda redistribuído(s)): ${ig.slice(0,5).join(', ')}${ig.length>5?' +'+(ig.length-5):''}`;\n    if(mv.destRuleFallback) h+=`<br><b style=\"color:var(--closing)\">⚠ nenhum município dominante nesta etapa — regra relaxada pra \"qualquer município\".</b>`;\n  }\n  el.innerHTML=h;\n}\nfunction renderSpanRule(){\n  const el=$('#spanRule'); if(!el) return;\n  const mv=SESS.timeline[SESS.selected], lim=spanLimits(mv);\n  if(!mv||!mv.munis||mv.view==='asis'||!spanLimitsOn(lim)){ el.innerHTML=''; return; }\n  const sp=spanMapFor(mv,mv.assign||{}), v=spanViolationsOf(sp,lim);\n  const bits=[];\n  if(lim.target) bits.push(`alvo <b>${fmt(lim.target)}</b>`);\n  if(lim.max) bits.push(`máx <b>${fmt(lim.max)}</b>`);\n  if(lim.min) bits.push(`mín <b>${fmt(lim.min)}</b>`);\n  let h=`Pré-requisito de span: ${bits.join(' · ')} CBs — `;\n  if(v.over.length||v.under.length){\n    h+=`<b style=\"color:var(--closing)\">⚠ `;\n    if(v.over.length) h+=`acima do máximo: ${v.over.map(s=>s+' ('+fmt(sp[s])+')').join(', ')}`;\n    if(v.over.length&&v.under.length) h+=' · ';\n    if(v.under.length) h+=`abaixo do mínimo: ${v.under.map(s=>s+' ('+fmt(sp[s])+')').join(', ')}`;\n    h+=`</b>`;\n    if(mv.optTier===1) h+=`<br><span style=\"opacity:.85\">Nenhuma alocação conectada respeita o span mínimo nesta etapa — o modelo relaxou essa exigência.</span>`;\n    else if(mv.optTier===2) h+=`<br><span style=\"opacity:.85\">Nenhuma alocação conectada respeita os limites nesta etapa — o modelo relaxou essa exigência.</span>`;\n    else if(mv.optTier===-1) h+=`<br><span style=\"opacity:.85\">Etapa resolvida por heurística — os limites não são garantidos.</span>`;\n  } else h+=`<b style=\"color:var(--ok)\">✓ todos os setores receptores dentro dos limites</b>`;\n  el.innerHTML=`<div class=\"note\" style=\"margin:-2px 0 6px\">${h}</div>`;\n}\nfunction renderContig(){const mv=SESS.timeline[SESS.selected];const el=$('#contig');if(!el||!mv)return;\n  if(mv.view==='asis'){el.innerHTML='';return;}\n  const lvl=LEVELS[mv.level||'setor'];\n  // Indicador de conectividade geográfica só se aplica a fechamento de setor — ver contigBadFor.\n  if((mv.level||'setor')!=='setor'){ el.innerHTML=''; return; }\n  const bad=contigBadFor(mv,mv.assign);\n  el.innerHTML=bad.length\n    ?`<div class=\"note\" style=\"color:var(--closing);font-weight:600;margin:-2px 0 6px\">⚠ Território desconectado em ${bad.join(', ')} — ${lc(lvl.childLabel)}(s) desconectado(s) do território ${lvl.de} ${lc(lvl.label)}. Use ★ ÓTIMO ou reassocie no mapa.</div>`\n    :`<div class=\"note\" style=\"color:var(--ok);margin:-2px 0 6px\">✓ Sem quebras — cada ${lc(lvl.label)} forma uma região conectada.</div>`;\n}\nfunction closingCbsOf(mv){ return Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,[,o])=>a+o.cbs,0); }\nfunction sectorTotalCbs(mv){ return Object.values(mv.munis).reduce((a,o)=>a+o.cbs,0); }\nfunction renderKpis(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  const childPlural=lvl.childLabel==='Setor'?'Setores':(lvl.childLabel==='GV'?'GVs':'Municípios');\n  if(mv.view==='asis'){const mc=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').length;\n    $('#kpis').innerHTML=[\n      {n:fmt(sectorTotalCbs(mv)),l:`CBs ${lvl.no} ${lc(lvl.label)} ${mv.sector} (antes desta etapa)`,t:`Total de CBs ${lvl.no} ${lc(lvl.label)} hoje — já considerando o que etapas anteriores da timeline mandaram pra cá.`},\n      {n:mv.csvMunisTotal?`${fmt(mc)}/${fmt(mv.csvMunisTotal)}`:fmt(mc),l:`${childPlural} ${lvl.de} ${lc(lvl.label)}`,t:`${lvl.childLabel}(s) desenhados nesta etapa${mv.csvMunisTotal?` de ${fmt(mv.csvMunisTotal)} que a base de dominantes atribui ${lvl.a} ${lc(lvl.label)}`:''}.${(mv.missingMunis||[]).length?' Fora do mapa: '+mv.missingMunis.map(x=>x.muni+' ('+x.reason+')').join(' · '):''}${Object.keys(mv.ufGuessed||{}).length?' UF inferida da UF dominante do setor (nome repetido em outra UF carregada): '+Object.entries(mv.ufGuessed).map(([nm,u])=>nm+'→'+u).join(', '):''}`},\n      {n:fmt(mv.leaders.length),l:'Líderes (grupos)',t:`Líderes presentes ${lvl.no} ${lc(lvl.label)} nesta etapa.`},\n      {n:fmt(mv.dest.length),l:`${lvl.destLabel}s adjacentes`,t:`${lvl.destLabel}s vizinh${lvl.destLabel==='GV'?'as':'os'} (ainda abertas)${lvl.parentPhrase?` ${lvl.parentPhrase}`:''}${mv.gv?` (${mv.gv})`:''} que poderiam receber os ${lc(lvl.childLabel)}s.${(mv.gvRejected||[]).length&&lvl.parentWord?' Fora do mapa, por serem de outr'+(lvl.parentWord==='gerência'?'a':'o')+' '+lvl.parentWord+': '+mv.gvRejected.join(', ')+'.':''}`},\n    ].map(k=>`<div class=\"kpi\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n    return;}\n  const sv=mv.dest.map(s=>c.span[s]);const spread=sv.length?Math.max(...sv)-Math.min(...sv):0;\n  const zero=mv.dest.filter(s=>c.gained[s]===0);const totCbs=closingCbsOf(mv);\n  $('#kpis').innerHTML=[\n    // \"CBs fora do X\" só faz sentido pra fechamento de setor — a mecânica de vínculo (CBs que\n    // seguem a líder pra fora do território) é uma coisa de setor/orphan, não de GV/mercado.\n    (mv.level||'setor')==='setor'?{n:fmt(c.desloc),l:mv.policy==='fora'?`CBs fora ${lvl.de} ${lc(lvl.destLabel)}`:'CBs c/ quebra de relação',cls:'warn',t:`CBs cuja líder acabou noutr${lvl.a==='à'?'a':'o'} ${lc(lvl.destLabel)} nesta etapa.`}:null,\n    {n:fmt(spread),l:'Desequilíbrio (máx−mín CBs)',cls:spread>800?'warn':'ok',t:`Diferença de tamanho entre ${lvl.destLabel==='GV'?'a maior e a menor':'o maior e o menor'} ${lc(lvl.destLabel)} depois desta etapa.`},\n    {n:fmt(totCbs),l:'CBs realocadas',cls:'',t:`Total de CBs ${lvl.de} ${lc(lvl.label)} ${mv.sector} sendo redistribuídas nesta etapa.`},\n    (mv.level||'setor')==='setor'?{n:zero.length?zero.join(', '):'nenhum',l:`${lvl.destLabel} sem líder herdada`,cls:zero.length?'warn':'ok',t:`${lvl.destLabel} que recebe ${lc(lvl.childLabel)}(s) mas nenhuma líder nesta etapa.`}:null,\n  ].filter(Boolean).concat(mv.excludedCbs?[{n:fmt(mv.excludedCbs),l:`CBs fora do setor · ${fmt(mv.excludedMunis.length)} município(s) fora da base`,cls:'warn',t:'CBs em municípios que a base de dominantes não atribui a este setor: ficam fora do mapa e da alocação geográfica e seguem a líder do seu grupo.'}]:[])\n   .concat((mv.missingMunis||[]).length?[{n:fmt(mv.missingMunis.length),l:`Município(s) da base fora do mapa · ${fmt(mv.csvMunisTotal)} na base`,cls:'warn',t:`A base de dominantes atribui ${fmt(mv.csvMunisTotal)} município(s) ao setor ${mv.sector} e ${fmt(mv.csvMunisTotal-mv.missingMunis.length)} chegaram ao mapa. Fora: ${mv.missingMunis.map(x=>x.muni+' — '+x.reason+(x.cbs?' ('+fmt(x.cbs)+' CBs)':'')).join(' · ')}`}]:[])\n   .map(k=>`<div class=\"kpi ${k.cls}\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n}\nfunction renderSpans(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  if(mv.view==='asis'){const items=[{s:mv.sector,val:sectorTotalCbs(mv),color:CLOSE_COLOR}].concat(mv.dest.map(s=>({s,val:+(mv.baseMetrics[s]?.cbs||0),color:mv.color[s]})));\n    const max=Math.max(...items.map(x=>x.val),1);\n    $('#spans').innerHTML=items.map(x=>`<div class=\"bar-row\"><div class=\"tag\" style=\"color:${x.color}\">${x.s}</div><div class=\"bar-track\">\n      <div class=\"bar-inc\" style=\"width:${x.val/max*100}%;background:${x.color}\"></div></div>\n      <div class=\"bar-val\">${fmt(x.val)}</div></div>`).join('')\n      +`<div class=\"note\">Estrutura antes desta etapa — ${lvl.a} ${lc(lvl.label)} <b>${mv.sector}</b> ainda existe. Alterne pra <b>TOBE</b> pra ver a redistribuição.</div>`;\n    return;}\n  const lim=spanLimits(mv),on=spanLimitsOn(lim);\n  const rawMax=Math.max(...mv.dest.map(s=>c.span[s]),lim.target||0,lim.max||0,1);\n  const max=on?rawMax*1.04:rawMax;   // headroom so a marker sitting at the scale top stays visible\n  const marks='';\n  $('#spans').innerHTML=mv.dest.map(s=>{const base=+(mv.baseMetrics[s]?.cbs||0),i=c.inc[s];\n    const over=lim.max&&c.span[s]>lim.max,under=lim.min&&c.span[s]<lim.min;\n    return `<div class=\"bar-row\"><div class=\"tag\" style=\"color:${mv.color[s]}\">${s}</div><div class=\"bar-track\">\n      <div class=\"bar-base\" style=\"width:${base/max*100}%;background:${mv.color[s]}\"></div>\n      <div class=\"bar-inc\" style=\"width:${i/max*100}%;background:${mv.color[s]}\"></div>${marks}</div>\n      <div class=\"bar-val\" style=\"${over?'color:var(--closing);font-weight:700':(under?'color:#ff9500;font-weight:700':'')}\">${fmt(c.span[s])} <span style=\"opacity:.6\">(+${fmt(i)})</span>${over?' ▲':''}${under?' ▼':''}</div></div>`;}).join('')\n    +`<div class=\"note\">Claro = base (já incluindo etapas anteriores) · cheio = CBs entrantes ${lvl.de} ${mv.sector} nesta etapa.${c.orphanTotal?` Inclui <b>${fmt(c.orphanTotal-c.orphanStranded)}</b> CB(s) de municípios fora da base de dominantes que seguiram suas líderes.`:''}</div>`;\n}\n// Two very different reasons land rows in the __OUTROS__ bucket — no IBGE shape, or outside the\n// sector's dominance base. Same bucket computationally, but they must not read as the same thing.\nfunction outsideNote(mv){\n  const bits=[];\n  if(mv.noShapeCbs) bits.push(`<b>${fmt(mv.noShapeCbs)}</b> CBs em ${fmt(mv.noShapeMunis.length)} município(s) sem shape na UF`);\n  if(mv.excludedCbs) bits.push(`<b>${fmt(mv.excludedCbs)}</b> CBs em ${fmt(mv.excludedMunis.length)} município(s) <b>fora da base de dominantes</b> deste setor — <b style=\"color:var(--closing)\">quebra de vínculo</b>`);\n  if(!bits.length) return '';\n  let h=`<div class=\"note\">Fora do mapa e da alocação geográfica: ${bits.join(' · ')}.`;\n  if(mv.excludedCbs){\n    const c=computeDerived(mv);\n    h+=` As <b>${fmt(mv.excludedCbs)}</b> CBs em quebra de vínculo <b>seguem a líder do seu grupo</b>`;\n    const byS=mv.dest.filter(s=>c.orph[s]).map(s=>`<b style=\"color:${mv.color[s]}\">${s}</b> (${fmt(c.orph[s])})`);\n    h+=byS.length?`: ${byS.join(' · ')}.`:'.';\n    if(c.orphanStranded) h+=` <b style=\"color:var(--closing)\">${fmt(c.orphanStranded)} CB(s) sem destino</b> — a líder não ficou com nenhum município realocado, então não há setor pra seguir.`;\n  }\n  if(mv.noShapeCbs) h+=` As CBs sem shape na UF continuam fora de qualquer setor destino.`;\n  if(mv.excludedMunis&&mv.excludedMunis.length) h+=`<br><span style=\"opacity:.85\">${mv.excludedMunis.slice(0,8).map(x=>x.muni+' ('+fmt(x.cbs)+')').join(', ')}${mv.excludedMunis.length>8?' +'+(mv.excludedMunis.length-8):''}</span>`;\n  return h+`</div>`;\n}\nfunction renderMuni(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const lvl=LEVELS[mv.level||'setor'];\n  const plural=lvl.childLabel==='Setor'?'Setores':(lvl.childLabel==='GV'?'GVs':'Municípios');\n  const fem=lvl.childLabel==='GV';\n  const t=$('#muniListTitle'); if(t) t.textContent=`${plural} → ${lc(lvl.destLabel)} destino`;\n  const nt=$('#muniListNote'); if(nt) nt.innerHTML=`Clique no <b>código ${lvl.a} ${lc(lvl.destLabel)}</b> pra mandar ${lvl.a} ${lc(lvl.childLabel)} <b>inteir${fem?'a':'o'}</b> pra lá (regra de negócio). A cor acompanha o mapa.`;\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  if(mv.view==='asis'){$('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(mv.munis[m].cbs)} CBs</div><span class=\"badge\" style=\"background:${CLOSE_COLOR};color:#fff;border-color:${CLOSE_COLOR}\">${lc(lvl.label)} ${mv.sector}</span></div>`).join('')\n      +`<div class=\"note\">Hoje tod${fem?'as':'os'} pertencem ${lvl.a} ${lc(lvl.label)} <b>${mv.sector}</b>. No <b>TOBE</b> cada um vira botões de destino.</div>`+outsideNote(mv);return;}\n  $('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(mv.munis[m].cbs)} CBs</div>\n    <div class=\"seg\" data-m=\"${m}\">${mv.dest.map(s=>`<button data-s=\"${s}\" class=\"${mv.assign[m]===s?'on':''}\" style=\"${mv.assign[m]===s?'background:'+mv.color[s]:''}\">${s}</button>`).join('')}</div></div>`).join('')\n    +outsideNote(mv);\n  $('#muniList').querySelectorAll('.seg button').forEach(b=>b.onclick=()=>{const m=b.parentElement.dataset.m,s=b.dataset.s;editSelected(m2=>{m2.assign[m]=s;});});\n}\nfunction renderLeaders(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);\n  if(mv.view==='asis'){$('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>Grupo</th><th>CBs</th><th>${LEVELS[mv.level||'setor'].label}</th></tr></thead><tbody>`\n    +mv.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${L.grupo}</td><td>${fmt(L.total)}</td><td><span class=\"home\" style=\"background:${CLOSE_COLOR}\"></span>${mv.sector}</td></tr>`).join('')\n    +`<tr style=\"font-weight:700\"><td>TOTAL</td><td></td><td>${fmt(mv.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td></tr></tbody>`;return;}\n  const showOrph=c.orphanTotal>0;\n  $('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>CBs</th><th>Home</th>${mv.dest.map(s=>`<th>${s}</th>`).join('')}${showOrph?'<th title=\"CBs em municípios fora da base de dominantes que seguiram esta líder\">Fora do setor</th>':''}<th>Desloc.</th></tr></thead><tbody>`\n   +c.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${fmt(L.total)}</td>\n     <td>${L.home?`<span class=\"home\" style=\"background:${mv.color[L.home]}\"></span>${L.home}`:'–'}</td>\n     ${mv.dest.map(s=>`<td class=\"cell\" style=\"${s===L.home?'color:'+mv.color[s]+';font-weight:700':''}\">${L.byS[s]||'·'}</td>`).join('')}\n     ${showOrph?`<td class=\"cell\" style=\"${L.orphan&&!L.orphanHome?'color:var(--closing);font-weight:700':''}\" title=\"${L.orphan?(L.orphanHome?'seguem a líder para o setor '+L.orphanHome:'sem home — CBs sem setor pra seguir'):''}\">${L.orphan?fmt(L.orphan)+(L.orphanHome?'':' ⚠'):'·'}</td>`:''}\n     <td style=\"color:${L.desloc?'var(--closing)':'var(--mut)'};font-weight:${L.desloc?700:400}\">${L.desloc||'0'}</td></tr>`).join('')\n   +`<tr style=\"font-weight:700\"><td>TOTAL</td><td>${fmt(c.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td>${mv.dest.map(s=>`<td>${fmt(c.leaders.reduce((a,L)=>a+L.byS[s],0))}</td>`).join('')}${showOrph?`<td>${fmt(c.orphanTotal)}</td>`:''}<td style=\"color:var(--closing)\">${fmt(c.desloc)}</td></tr></tbody>`;\n}\nfunction renderMatrix(){const mv=SESS.timeline[SESS.selected]; if(!mv) return;\n  // Esta matriz cruza LINHAS DO CRM (município x grupo) — no fechamento de GV/mercado a unidade\n  // que troca de dono é setor/GV, não município, e reconstruir a matriz nesse grão é trabalho ainda\n  // não feito; mostrar a tabela como se fosse por município aqui daria linhas vazias sem avisar.\n  if((mv.level||'setor')!=='setor'){ $('#matrix').innerHTML=`<tr><td class=\"note\" style=\"padding:10px\">Matriz por município × grupo ainda não disponível pra fechamento de ${LEVELS[mv.level].label} — use os KPIs, líderes e plano de migração acima.</td></tr>`; return; }\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  // `_out` is a flag stamped on the ROW, and rows are shared between etapas (absorbed + inherited\n  // orphans), so the last etapa to aggregate wins and reading it here can describe the wrong etapa.\n  // Derive the bucket from THIS mv instead. Summing rather than .find() because several out-municípios\n  // of the same group all collapse into __OUTROS__, and only the first was being counted.\n  const outOf=x=>!mv.geoFeat[mkey(x.municipio,x.uf)];\n  const cell=(m,g)=>mv.matrix.reduce((a,x)=>a+((((outOf(x)?'__OUTROS__':mkey(x.municipio,x.uf))===m)&&String(x.grupo)===g)?+x.cbs:0),0);\n  const mx=Math.max(...ms.flatMap(m=>mv.leaders.map(L=>cell(m,L.grupo))),1);\n  // No destination column here. This is the AS-IS matrix: every município in it still belongs to the\n  // closing sector, so showing where it is HEADED mixed the two states of the world in one table and\n  // made the ASIS view read like a plan. The destination lives on the map and in \"Municípios → setor\n  // destino\", which are about the to-be.\n  $('#matrix').innerHTML=`<thead><tr><th>Município</th>${mv.leaders.map(L=>`<th title=\"${L.lider}\">${L.grupo}</th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +ms.map(m=>`<tr><td>${m}</td>`\n     +mv.leaders.map(L=>{const v=cell(m,L.grupo);return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v||'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(mv.munis[m].cbs)}</td></tr>`).join('')+`</tbody>`;\n}\n// TOBE counterpart of the AS-IS matrix: the same município × grupo grid plus the two columns the AS-IS\n// view deliberately refuses to carry — where the município ends up, and under which líder.\n// \"Líder resultante\" is the líder holding the most CBs in that município. A município is served by several\n// grupos at once and the data names no single owner, so the dominant one is the honest answer and its\n// share is printed beside it instead of being hidden behind a single name.\nfunction renderMatrixTobe(){const mv=SESS.timeline[SESS.selected]; const el=$('#matrixTobe'); const nt=$('#tobeNote');\n  if(!el) return;\n  if(!mv||!mv.munis){ el.innerHTML=''; if(nt) nt.innerHTML=''; return; }\n  if((mv.level||'setor')!=='setor'){ el.innerHTML=`<tr><td class=\"note\" style=\"padding:10px\">Matriz por município × grupo ainda não disponível pra fechamento de ${LEVELS[mv.level].label} — use os KPIs, líderes e plano de migração acima.</td></tr>`; if(nt) nt.innerHTML=''; return; }\n  const c=computeDerived(mv);\n  const homeOf={}; c.leaders.forEach(L=>{ homeOf[L.grupo]=L.home; });\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  // One pass over the matrix instead of a scan per cell: the AS-IS table can afford the naive version\n  // on six municípios, but this one asks for the dominant líder too, which multiplies the lookups.\n  const outOf=x=>!mv.geoFeat[mkey(x.municipio,x.uf)];\n  const cellBy={}; mv.matrix.forEach(x=>{ const k=(outOf(x)?'__OUTROS__':mkey(x.municipio,x.uf))+'|'+String(x.grupo);\n    cellBy[k]=(cellBy[k]||0)+ +x.cbs; });\n  const cell=(m,g)=>cellBy[m+'|'+g]||0;\n  const mx=Math.max(...ms.flatMap(m=>mv.leaders.map(L=>cell(m,L.grupo))),1);\n  const domLeader=m=>{ let best=null,bc=0;\n    mv.leaders.forEach(L=>{ const v=cell(m,L.grupo); if(v>bc){ bc=v; best=L; } });\n    return best?{L:best,cbs:bc}:null; };\n  const short=L=>(L.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+L.grupo);\n  // NOT named `rows` — that is the module-level Databricks response helper, and shadowing it inside a\n  // render function is a trap for whoever adds a query here later.\n  // Off-mismatch ainda calculado pra alimentar a nota abaixo da tabela — só não vira mais coluna: o\n  // destino agora é \"Setor destino\" por GRUPO no rodapé, não mais por município na grade.\n  const recs=ms.map(m=>{ const d=mv.assign[m]||null, dl=domLeader(m);\n    const home=dl?(homeOf[dl.L.grupo]||null):null;\n    return {m:m,off:!!(dl&&home&&d&&home!==d)}; });\n  el.innerHTML=`<thead><tr><th>Município</th>${mv.leaders.map(L=>`<th title=\"${esc(L.lider||'')}\">${L.grupo}<br><span style=\"font-weight:400;font-size:10px;color:var(--mut)\">${esc(short(L))}</span></th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +ms.map(m=>`<tr><td>${m}</td>`\n     +mv.leaders.map(L=>{const v=cell(m,L.grupo);return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v||'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(mv.munis[m].cbs)}</td></tr>`).join('')\n   // Um rótulo de destino por GRUPO (coluna), não por município (linha) — cada grupo tem UM home\n   // fixo (computeDerived já calculou), então ele mora uma vez no rodapé, igual o nome já morava.\n   +`<tr class=\"lider-foot\"><td>Setor destino</td>`\n     +mv.leaders.map(L=>{const h=homeOf[L.grupo]; return `<td>${h?`<span class=\"home\" style=\"background:${mv.color[h]}\"></span>${h}`:'–'}</td>`;}).join('')\n     +`<td></td></tr>`\n   +`</tbody>`;\n  if(nt){\n    const offN=recs.filter(r=>r.off).length;\n    nt.innerHTML=(mv.policy!=='fora')\n      ? `Política <b>quebrar relação</b>: as CBs deixam a líder atual e passam à estrutura do setor destino — o rodapé mostra pra onde cada grupo vai.`\n      : `<b>Setor destino</b> = o setor que recebe a maior parte das CBs de cada líder (rodapé, uma vez por grupo). `\n        +(offN?`<span style=\"color:var(--closing)\">${fmt(offN)} município(s) ⚠</span> têm a líder dominante indo para um setor diferente do dela — as CBs seguem a líder (é disso que o KPI “CBs fora do setor” é feito).`\n          :`Em todos os municípios a líder dominante termina no mesmo setor que recebe o território.`);\n  }\n}\nfunction renderPolicy(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  // O toggle fora-do-setor/quebra-de-relação é uma decisão de setor fechando — em GV/mercado a\n  // seção some inteira em vez de mostrar um controle que não corresponde a nada calculado.\n  const sec=$('#policySection'); if(sec) sec.classList.toggle('hidden',(mv.level||'setor')!=='setor');\n  if((mv.level||'setor')!=='setor') return;\n  $('#policyNote').innerHTML=mv.policy==='fora'\n    ?`<b style=\"color:var(--acc)\">Mantendo vínculo:</b> ${fmt(c.desloc)} CBs seguem com a líder mas ficam <b>fisicamente noutr${lvl.a==='à'?'a':'o'} ${lc(lvl.destLabel)}</b>. Preserva relação; suja geografia.`\n    :`<b style=\"color:var(--acc)\">Quebrando vínculo:</b> ${fmt(c.desloc)} CBs <b>re-vinculam a uma líder d${lvl.a==='à'?'a':'o'} ${lc(lvl.destLabel)} destino</b>. Geografia limpa; rompe relação.`;\n}\nfunction renderPlan(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor']; const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>a.localeCompare(b));\n  // GV/mercado: a chave \"m\" aqui é um código de setor/GV, não uma chave município/UF — mName/mUf\n  // parseariam lixo, então o CSV de plano exporta o código cru nesses níveis.\n  mv._plan=ms.map(m=>(mv.level&&mv.level!=='setor')?{municipio:m,uf:'',cbs:mv.munis[m].cbs,destino:mv.assign[m]}:{municipio:mName(m),uf:mUf(m),cbs:mv.munis[m].cbs,destino:mv.assign[m]});\n  $('#plan').innerHTML=`Etapa ${SESS.selected+1} · ${lvl.label} <b>${mv.sector}</b> → ${ms.map(m=>`${m}→<b style=\"color:${mv.color[mv.assign[m]]}\">${mv.assign[m]||'–'}</b>`).join(' · ')}<br>\n   Política: <b>${mv.policy==='fora'?`manter vínculo (CBs fora ${lvl.de} ${lc(lvl.destLabel)})`:'quebrar relação'}</b> · ${fmt(c.desloc)} CBs impactadas nesta etapa.`\n   +(c.orphanTotal?`<br>Fora da base de dominantes: ${fmt(c.orphanTotal)} CB(s) seguem suas líderes — ${mv.dest.filter(s=>c.orph[s]).map(s=>`${s} (${fmt(c.orph[s])})`).join(' · ')||'–'}${c.orphanStranded?` · <b style=\"color:var(--closing)\">${fmt(c.orphanStranded)} sem líder com destino</b>`:''}. Não entram no CSV, que é por município.`:'');\n}\nfunction renderReport(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv);\n  mv.metric=mv.metric||'cbs';\n  const KEYS=METRICS_DEF.map(m=>m[0]);\n  const incBy={};mv.dest.forEach(s=>incBy[s]=Object.fromEntries(KEYS.map(k=>[k,0])));\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(!s||!incBy[s])return;KEYS.forEach(k=>incBy[s][k]+=o[k]||0);});\n  // orphan CBs land on their leader's home sector, not on a geographic destination\n  c.leaders.forEach(L=>{ const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n    if(om&&L.home&&incBy[L.home]) KEYS.forEach(k=>incBy[L.home][k]+=om[k]||0); });\n  const closeTot={};KEYS.forEach(k=>closeTot[k]=Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,[,o])=>a+(o[k]||0),0));\n  // they leave the closing sector too, so antes → depois still balances\n  c.leaders.forEach(L=>{ const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n    if(om&&L.home) KEYS.forEach(k=>closeTot[k]+=om[k]||0); });\n  const trend=mv.trend.slice(-6);\n  const chartHTML=mk=>{\n    const isM=['receita','rec_nat','rec_avon'].includes(mk); const fN=isM?money:fmt;\n    const data=mv.dest.map(s=>({label:s,color:mv.color[s],antes:+(mv.baseMetrics[s]?.[mk]||0),depois:+(mv.baseMetrics[s]?.[mk]||0)+(incBy[s]?.[mk]||0)}));\n    data.push({label:mv.sector+'✕',color:'#ff3b30',antes:closeTot[mk]||0,depois:0});\n    const max=Math.max(...data.flatMap(d=>[d.antes,d.depois]),1);\n    const W=720,H=290,base=238,top=42,maxH=base-top,n=data.length,gw=(W-16)/n;\n    let g='';\n    data.forEach((d,i)=>{const cx=8+gw*i+gw/2,bw=Math.min(30,gw*0.30);\n      const ah=d.antes/max*maxH,dh=d.depois/max*maxH,ax=cx-bw-2,dx=cx+2;\n      g+=`<rect x=\"${ax.toFixed(1)}\" y=\"${(base-ah).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${ah.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\" opacity=\"0.35\"/>`;\n      g+=`<rect x=\"${dx.toFixed(1)}\" y=\"${(base-dh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${dh.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\"/>`;\n      g+=`<text x=\"${(ax+bw/2).toFixed(1)}\" y=\"${(base-ah-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9\" fill=\"#9a9aa8\">${fN(d.antes)}</text>`;\n      g+=`<text x=\"${(dx+bw/2).toFixed(1)}\" y=\"${(base-dh-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9.5\" font-weight=\"700\" fill=\"${d.color}\">${fN(d.depois)}</text>`;\n      const dp=d.antes?((d.depois-d.antes)/d.antes*100):(d.depois?100:0);\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"16\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"${dp>=0?'#34c759':'#ff3b30'}\">${dp>0?'+':''}${dp.toFixed(0)}%</text>`;\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"${base+16}\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"#1c1c2b\">${d.label}</text>`;});\n    return `<svg viewBox=\"0 0 ${W} ${H}\" style=\"width:100%\"><line x1=\"8\" y1=\"${base}\" x2=\"${W-8}\" y2=\"${base}\" stroke=\"#e7e7ef\"/>${g}</svg>`;\n  };\n  let h=`<div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap\">\n      <h2 style=\"margin:0\">Impacto antes → depois desta etapa</h2>\n      <select id=\"metricSel\">${METRICS_DEF.map(m=>`<option value=\"${m[0]}\" ${m[0]===mv.metric?'selected':''}>${m[1]}</option>`).join('')}</select></div>\n    <div class=\"note\" style=\"margin:2px 0 4px\">Barra clara = <b>antes desta etapa</b> (já incluindo etapas anteriores) · barra cheia = <b>depois</b> · <b style=\"color:var(--acc)\">${mv.sector}✕</b> = ${lc(lvl.label)} fechad${lvl.a==='à'?'a':'o'} nesta etapa · % = variação</div>\n    ${chartHTML(mv.metric)}`;\n  h+=`<h2 style=\"margin-top:16px\">Antes → Depois por ${lc(lvl.destLabel)} receptor (snapshot congelado)</h2>\n    <div class=\"scroll\"><table><thead><tr><th>${lvl.destLabel}</th>${METRICS_DEF.map(m=>`<th>${m[1]}</th>`).join('')}</tr></thead><tbody>`;\n  mv.dest.forEach(s=>{const b=mv.baseMetrics[s]||{};const inc=incBy[s];\n    h+=`<tr><td><span class=\"home\" style=\"background:${mv.color[s]}\"></span>${s}</td>`+\n      METRICS_DEF.map(([k])=>{const bv=+b[k]||0,av=bv+(inc[k]||0);const d=bv?((av-bv)/bv*100):0;const f=METRICS_DEF.find(x=>x[0]===k)[2]==='$'?money:fmt;\n        return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;}).join('')+`</tr>`;});\n  h+=`<tr style=\"opacity:.8\"><td>${mv.sector} (sai)</td>`+METRICS_DEF.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(closeTot[k]||0)}</td>`).join('')+`</tr>`;\n  h+=`</tbody></table></div>`;\n  const incLvl={};mv.dest.forEach(s=>incLvl[s]={});\n  Object.keys(mv.munis).forEach(m=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(!s||!incLvl[s])return;const lv=mv.levelByMuni[m]||{};for(const nv in lv)incLvl[s][nv]=(incLvl[s][nv]||0)+lv[nv];});\n  c.leaders.forEach(L=>{ const ol=mv.orphanLevelByGroup&&mv.orphanLevelByGroup[L.grupo];\n    if(ol&&L.home&&incLvl[L.home]) Object.entries(ol).forEach(([nv,n])=>incLvl[L.home][nv]=(incLvl[L.home][nv]||0)+n); });\n  h+=`<h2 style=\"margin-top:18px\">Consultoras por nível — antes → depois por ${lc(lvl.destLabel)} receptor</h2>\n    <div class=\"scroll\"><table><thead><tr><th>${lvl.destLabel}</th>${NIVEIS.map(n=>`<th>${n}</th>`).join('')}<th>Total</th></tr></thead><tbody>`;\n  mv.dest.forEach(s=>{const base=mv.baseLevel[s]||{},inc=incLvl[s]||{};let tb=0,ta=0;\n    h+=`<tr><td><span class=\"home\" style=\"background:${mv.color[s]}\"></span>${s}</td>`+\n      NIVEIS.map(nv=>{const b=base[nv]||0,i=inc[nv]||0,a=b+i;tb+=b;ta+=a;\n        return `<td>${a?fmt(a):'·'}${i?`<div class=\"delta up\">+${fmt(i)}</div>`:''}</td>`;}).join('')+\n      `<td style=\"font-weight:700\">${fmt(ta)}${ta-tb?`<div class=\"delta up\">+${fmt(ta-tb)}</div>`:''}</td></tr>`;});\n  h+=`</tbody></table></div><div class=\"note\">Número = CBs por nível <b>depois</b> desta etapa · <span style=\"color:var(--ok)\">+N</span> = entrantes do ${mv.sector}.</div>`;\n  const spk=(key)=>{const vals=trend.map(t=>+t[key]||0);const mx=Math.max(...vals,1);\n    return `<div class=\"spark\">${vals.map(v=>`<div class=\"b\" style=\"height:${(v/mx*100).toFixed(0)}%\" title=\"${fmt(v)}\"></div>`).join('')}</div>\n      <div class=\"note\">${trend.map(t=>String(t.ciclo).slice(-2)).join(' · ')}</div>`;};\n  h+=`<h2 style=\"margin-top:18px\">Setor ${mv.sector} — últimos ${trend.length} ciclos até o snapshot congelado</h2>\n    <div class=\"note\" style=\"margin:-4px 0 8px\">Tendência histórica <b>do próprio setor</b> — não ajustada pela absorção hipotética de etapas anteriores (limitação conhecida: tendência é por setor, não decomponível por município).</div>\n    <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:10px\">\n    ${[['cbs','CBs'],['ativas','Ativas'],['receita','Receita'],['pontos','Pontos']].map(([k,l])=>`<div><div class=\"note\" style=\"margin:0 0 3px;font-weight:600;color:var(--ink)\">${l}</div>${spk(k)}</div>`).join('')}</div>`;\n  $('#tab-report').innerHTML=h;\n  const sel=$('#metricSel'); if(sel) sel.onchange=()=>{mv.metric=sel.value; renderReport();};\n}\nfunction renderCumulative(){\n  const el=$('#tab-cumulative'); if(!el) return;\n  // Esta view soma a cadeia inteira via foldWorldState().muniOverride/sectorBaseCache — hoje só\n  // populados pelo fechamento de setor. GV/mercado têm o rastro equivalente (secOverride/gvOverride),\n  // mas essa agregação ainda não foi refeita nesse grão; melhor avisar do que mostrar tabela vazia.\n  const lockLvl=SESS.timeline.length?(SESS.timeline[0].level||'setor'):'setor';\n  if(lockLvl!=='setor'){ el.innerHTML=`<div class=\"note\">Impacto acumulado da cadeia ainda não disponível pra sessões de ${lc(LEVELS[lockLvl].label)} — use o relatório da etapa (aba anterior) etapa a etapa.</div>`; return; }\n  if(!SESS.timeline.length){ el.innerHTML='<div class=\"note\">Nenhuma etapa na timeline ainda.</div>'; return; }\n  const ready=SESS.timeline.every(mv=>mv.munis);\n  if(!ready){ el.innerHTML='<div class=\"note\">Aguardando etapas terminarem de calcular…</div>'; return; }\n  const finalWorld=foldWorldState(SESS.timeline,SESS.timeline.length);\n  const openSectors=[...new Set(Object.values(finalWorld.muniOverride))];\n  let h=`<h2 style=\"margin:0 0 8px\">Linha do tempo — ${SESS.timeline.length} fechamento(s)</h2>\n    <div class=\"note\" style=\"margin:-2px 0 10px\">Setores fechados, em ordem: ${SESS.timeline.map(mv=>mv.sector).join(' → ')}. Cada linha abaixo soma o efeito de <b>toda a cadeia</b>, não apenas da última etapa.</div>`;\n  h+=`<div class=\"scroll\"><table><thead><tr><th>Setor</th><th>Situação</th>${METRICS_DEF.map(([,l])=>`<th>${l}</th>`).join('')}</tr></thead><tbody>`;\n  SESS.timeline.forEach((mv,i)=>{\n    h+=`<tr style=\"opacity:.75\"><td>${mv.sector}</td><td>fechado (etapa ${i+1})</td>`+METRICS_DEF.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(mv.pureOwnMetrics?.[k]||0)}</td>`).join('')+`</tr>`;\n  });\n  openSectors.forEach(s=>{\n    const base=SESS.sectorBaseCache[s]||{};\n    const infl=finalWorld.sectorInflow[s]||{};\n    h+=`<tr><td>${s}</td><td>recebeu território</td>`+METRICS_DEF.map(([k,,t])=>{\n      const bv=+base[k]||0, av=bv+(infl[k]||0); const d=bv?((av-bv)/bv*100):0; const f=(t==='$'?money:fmt);\n      return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;\n    }).join('')+`</tr>`;\n  });\n  h+=`</tbody></table></div><div class=\"note\">\"Antes\" de um setor receptor é o snapshot congelado (sem nenhum fechamento aplicado); \"depois\" soma tudo que a cadeia inteira jogou pra ele.</div>`;\n  el.innerHTML=h;\n}\nfunction project(feats){let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  const each=cb=>feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(p=>p.forEach(r=>r.forEach(cb)));});\n  each(p=>{mnx=Math.min(mnx,p[0]);mxx=Math.max(mxx,p[0]);mny=Math.min(mny,p[1]);mxy=Math.max(mxy,p[1]);});\n  const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=700,H=500,pad=22;\n  const gw=(mxx-mnx)*kx,gh=(mxy-mny),sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n  const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n  return p=>[ox+(p[0]-mnx)*kx*sc, oy+(mxy-p[1])*sc];\n}\nfunction pathFor(f,pr){const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  return ps.map(poly=>poly.map(r=>'M'+r.map(p=>{const q=pr(p);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');}\nfunction cxy(f,pr){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(poly=>poly[0].forEach(p=>{const q=pr(p);x+=q[0];y+=q[1];n++;}));return[x/n,y/n];}\n// ---------- label placement ----------\n// Pole of inaccessibility: the interior point farthest from ANY boundary of the dissolved area. A\n// centroid is only a mean — on a concave sector, one split across a river, or a member whose vertex\n// average falls in a bay, it lands outside the very territory it is supposed to name, which is what\n// pushed the sector numbers off their own shapes. Even-odd across every ring of every member is a\n// correct union test here because municipalities tile rather than overlap.\n// The search box is clipped to the viewBox, so the result is inside the territory AND on screen.\n// Rings are decimated first — this is a label, not a measurement — which keeps a 20-município sector\n// to a few million ops, and the result is cached per stage: geometry never changes between renders,\n// only the fill does.\nfunction labelPointFor(fs,pr){\n  const rings=[];\n  (fs||[]).forEach(f=>{ if(!f||!f.geometry) return;\n    const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n    ps.forEach(poly=>poly.forEach(r=>{\n      if(!r||r.length<4) return;\n      const step=Math.max(1,Math.floor(r.length/120)),pts=[];\n      for(let i=0;i<r.length;i+=step) pts.push(pr(r[i]));\n      if(pts.length>3) rings.push(pts);\n    }));});\n  if(!rings.length) return null;\n  let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  mnx=Math.max(mnx,2); mny=Math.max(mny,2); mxx=Math.min(mxx,698); mxy=Math.min(mxy,498);\n  if(!(mxx>mnx&&mxy>mny)) return null;\n  const inside=(x,y)=>{ let c=false;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      if(((a[1]>y)!==(b[1]>y)) && (x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])) c=!c; }\n    return c; };\n  const edgeDist=(x,y)=>{ let m=Infinity;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      const dx=b[0]-a[0],dy=b[1]-a[1],L=dx*dx+dy*dy;\n      let t=L?(((x-a[0])*dx+(y-a[1])*dy)/L):0; t=t<0?0:(t>1?1:t);\n      const ex=a[0]+t*dx-x,ey=a[1]+t*dy-y,d=ex*ex+ey*ey; if(d<m)m=d; }\n    return Math.sqrt(m); };\n  const W=mxx-mnx,H=mxy-mny,N=26;\n  let best=null,bd=-1;\n  for(let i=0;i<=N;i++) for(let j=0;j<=N;j++){\n    const x=mnx+W*i/N,y=mny+H*j/N;\n    if(!inside(x,y)) continue;\n    const d=edgeDist(x,y); if(d>bd){ bd=d; best=[x,y]; }\n  }\n  if(!best) return null;\n  // Hill-climb off the coarse grid: the grid only has to land somewhere in the right lobe, the\n  // refinement finds the deep point inside it — halving the step whenever no neighbour improves.\n  let step=Math.max(W,H)/N/2;\n  for(let k=0;k<7;k++){\n    let moved=false;\n    for(const o of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]){\n      const x=best[0]+o[0]*step,y=best[1]+o[1]*step;\n      if(x<mnx||x>mxx||y<mny||y>mxy||!inside(x,y)) continue;\n      const d=edgeDist(x,y); if(d>bd){ bd=d; best=[x,y]; moved=true; }\n    }\n    if(!moved) step/=2;\n  }\n  // The clearance travels with the point: it is the radius of the largest circle that fits inside the\n  // territory there, which is exactly the budget a caller needs to decide how much text will fit.\n  return {p:best,r:bd};\n}\nfunction labelPointOf(mv,key,fs,pr){\n  if(!mv._lbl) mv._lbl={};\n  if(!(key in mv._lbl)) mv._lbl[key]=labelPointFor(fs,pr);\n  return mv._lbl[key];\n}\n// ---------- map zoom/pan ----------\n// Zoom é feito manipulando o viewBox, nunca um transform CSS — assim rótulos e polígonos vivem no\n// MESMO sistema de coordenadas e escalam juntos automaticamente (texto tem font-size em unidades do\n// viewBox, então encolher o viewBox amplia tudo por igual, sem lógica extra pro tamanho da fonte).\nconst MAP_W=700, MAP_H=500, MAP_ZOOM_MAX=8;\nlet mapView={x:0,y:0,w:MAP_W,h:MAP_H};\nfunction applyMapView(){\n  const svg=$('#map'); if(!svg) return;\n  svg.setAttribute('viewBox', mapView.x.toFixed(2)+' '+mapView.y.toFixed(2)+' '+mapView.w.toFixed(2)+' '+mapView.h.toFixed(2));\n  // Rótulo tem font-size em unidades do viewBox, então sem isso ele cresceria junto com o zoom até\n  // virar um borrão — encolhendo o font-size na mesma proporção que o viewBox encolhe cancela\n  // exatamente o zoom, e o texto fica do MESMO tamanho na tela em qualquer nível de zoom.\n  const fsScale=mapView.w/MAP_W;\n  svg.querySelectorAll('text[data-fs]').forEach(t=>{\n    const base=+t.getAttribute('data-fs')||8;\n    t.setAttribute('font-size',(base*fsScale).toFixed(2));\n  });\n  // Mesmo raciocínio pro halo do texto e pro fio que liga um rótulo deslocado ao seu município —\n  // os dois têm largura de traço fixa em unidades do viewBox, então sem reescalar iam virar uma\n  // mancha grossa (halo) ou uma barra (fio) no zoom alto em vez de ficarem do mesmo tamanho na tela.\n  svg.querySelectorAll('[data-sw]').forEach(el=>{\n    const base=+el.getAttribute('data-sw')||1;\n    el.style.strokeWidth=(base*fsScale).toFixed(3)+'px';\n  });\n}\nfunction zoomMapBy(factor){\n  const cx=mapView.x+mapView.w/2, cy=mapView.y+mapView.h/2;\n  let w=mapView.w/factor, h=mapView.h/factor;\n  w=Math.min(MAP_W, Math.max(MAP_W/MAP_ZOOM_MAX, w));\n  h=Math.min(MAP_H, Math.max(MAP_H/MAP_ZOOM_MAX, h));\n  let x=cx-w/2, y=cy-h/2;\n  x=Math.min(MAP_W-w, Math.max(0,x));\n  y=Math.min(MAP_H-h, Math.max(0,y));\n  mapView={x,y,w,h};\n  applyMapView();\n}\n// Arrastar sempre fica DENTRO do canvas de 700x500 (clamp em [0, MAP_W-w] / [0, MAP_H-h]) — nunca\n// existe um x/y que deixa o viewBox parcialmente fora da área com conteúdo, então o mapa nunca\n// \"some\" da tela, só pode chegar até a borda do que existe pra ver.\nlet mapDrag=null, mapDidDrag=false;\nfunction mapPointerDown(e){\n  const svg=$('#map'); if(!svg) return;\n  mapDrag={x0:e.clientX,y0:e.clientY,vx0:mapView.x,vy0:mapView.y,rect:svg.getBoundingClientRect()};\n  mapDidDrag=false;\n  svg.classList.add('dragging');\n  // NÃO setPointerCapture aqui: capturar o ponteiro no <svg> reencaminha o click resultante pro\n  // próprio <svg> em vez do <path> do município embaixo do cursor — todo clique parava de chegar\n  // no onclick de reatribuição, não só depois de um arrasto de verdade. move/up escutam em `root`\n  // (nunca substituído por renderAll) em vez de no <svg>, então o arrasto continua funcionando\n  // mesmo se o ponteiro sair da área do mapa no meio do gesto.\n}\nfunction mapPointerMove(e){\n  if(!mapDrag) return;\n  const dxPix=e.clientX-mapDrag.x0, dyPix=e.clientY-mapDrag.y0;\n  if(!mapDidDrag && Math.hypot(dxPix,dyPix)>4) mapDidDrag=true;\n  if(!mapDrag.rect.width||!mapDrag.rect.height) return;\n  const sx=mapView.w/mapDrag.rect.width, sy=mapView.h/mapDrag.rect.height;\n  let x=mapDrag.vx0-dxPix*sx, y=mapDrag.vy0-dyPix*sy;\n  x=Math.min(MAP_W-mapView.w, Math.max(0,x));\n  y=Math.min(MAP_H-mapView.h, Math.max(0,y));\n  mapView={...mapView,x,y};\n  applyMapView();\n}\nfunction mapPointerUp(){\n  if(!mapDrag) return;\n  mapDrag=null;\n  const svg=$('#map'); if(svg) svg.classList.remove('dragging');\n}\nfunction drawMap(){\n  const mv=SESS.timeline[SESS.selected]; const svg=$('#map'); const ignNote=$('#mapIgnoredNote');\n  if(!mv){ svg.innerHTML=''; if(ignNote) ignNote.innerHTML=''; return; }\n  const level=mv.level||'setor';\n  // setor fecha: a unidade É o município. GV/mercado fecham: cada município herda a atribuição do\n  // setor/GV a que pertence — municípios do mesmo setor sempre têm a MESMA cor e giram JUNTOS no\n  // clique, porque é o setor inteiro (não o município) que troca de dono nesses níveis.\n  const unitAssign=m=>level==='setor'?mv.assign[m]:mv.assign[(mv.unitOfMuni||{})[m]];\n  const c=computeDerived(mv);const NS='http://www.w3.org/2000/svg'; svg.innerHTML='';\n  const feats=[...mv.nbrFeat,...Object.values(mv.geoFeat)]; if(!feats.length) return;\n  const pr=project(feats);\n  const world=foldWorldState(SESS.timeline,SESS.selected);\n  // The context layer is ONE area per SECTOR, not one shape per município. The unit of this decision\n  // is the destination sector; a scatter of individually outlined municipalities carrying their own\n  // names invited exactly the wrong reading of the map. Members of a sector are filled AND stroked in\n  // the same colour, so the shared borders vanish and the group reads as a single dissolved\n  // territory — the IBGE shapes do not share vertices precisely enough to extract a clean union\n  // outline, and carrying a real polygon-clipping union for a context layer is not worth it.\n  // Labels are COLLECTED here and drawn last, after every path. Drawn inline they were buried by the\n  // next sector's fill, and placement cannot avoid a collision it has not seen yet.\n  const labels=[],placed=[];\n  const textW=(s,fs)=>String(s).length*fs*0.56;\n  // WCAG relative luminance of the fill a label sits on, so ink and halo can invert per label. \"Would\n  // white be better?\" has no single answer on this map: white reads on #5856d6 (L≈0.14) and disappears\n  // on #ff9500 and #34c759 (both L≈0.43, where black wins ~9:1 against white's ~2.2:1) and on every\n  // context fill, which is .32 opacity over white. Decided per label, never globally.\n  const lum=col=>{let h=String(col||'').replace('#','');\n    if(h.length===3) h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];\n    if(h.length!==6) return 1;\n    const c=[0,2,4].map(i=>parseInt(h.slice(i,i+2),16)/255)\n      .map(v=>v<=0.03928?v/12.92:Math.pow((v+0.055)/1.055,2.4));\n    return 0.2126*c[0]+0.7152*c[1]+0.0722*c[2];};\n  // Centred on (x,y) in BOTH axes. The old box hung off the first line's baseline, so every two-line\n  // label sat half its own height below the point it was supposed to mark.\n  const rectOf=(x,y,lines)=>{const w=Math.max.apply(null,lines.map(l=>textW(l.t,l.size)));\n    const h=lines.reduce((a,l)=>a+l.size*1.2,0);\n    return {x1:x-w/2-2.5,x2:x+w/2+2.5,y1:y-h/2-1.5,y2:y+h/2+1.5};};\n  const nbrBySec={};\n  mv.nbrFeat.forEach(f=>{\n    const nm=mkey(f.properties.name||f.properties.NOME||f.properties.nome||'',f.__uf);\n    const s=String((mv.nbrCurrentSector&&mv.nbrCurrentSector[nm])||'');\n    (nbrBySec[s]=nbrBySec[s]||[]).push(f);\n  });\n  Object.keys(nbrBySec).forEach(s=>{\n    const fs=nbrBySec[s];\n    const isDest=!!s&&mv.dest.includes(s);\n    const closedCtx=!!s&&world.closedSectors.has(s);\n    const col=isDest?(mv.color[s]||null):null;\n    fs.forEach(f=>{\n      const p=document.createElementNS(NS,'path');\n      p.setAttribute('d',pathFor(f,pr));\n      p.setAttribute('class','sect'+(isDest?'':(closedCtx?' closed-ctx':' plain')));\n      if(col){ p.setAttribute('fill',col); p.setAttribute('stroke',col); }\n      svg.appendChild(p);\n    });\n    if(!s) return;\n    // One number per sector, at the pole of inaccessibility of its DISSOLVED area — the whole group\n    // of members at once, not a representative member, so the number sits in the visual middle of the\n    // territory the operator actually sees.\n    const lp=labelPointOf(mv,'s:'+s,fs,pr);\n    // Context areas always read LIGHT — .sect paints dest colours at fill-opacity .32 over white, and\n    // plain/closed-ctx are near-white greys — so a sector number never wants white ink, whatever colour\n    // the sector itself was assigned.\n    if(lp&&lp.p) labels.push({anchor:lp.p,big:true,dark:false,lines:[{t:s,size:11.5,weight:700}]});\n  });\n  const asis=mv.view==='asis';\n  // The UF prefix is information only when the stage actually straddles a border. On a single-UF setor\n  // it is the same three characters repeated on every label — a large share of the clutter and none of\n  // the meaning. The hover tooltip carries the UF either way.\n  const showUf=new Set(Object.keys(mv.geoFeat).map(k=>mUf(k)).filter(Boolean)).size>1;\n  if(level==='setor'){\n    // Setor fechando: a UNIDADE é o próprio município — cada um se comporta, colore e rotula\n    // individualmente, exatamente como sempre foi.\n    Object.entries(mv.geoFeat).forEach(([m,f])=>{const p=document.createElementNS(NS,'path');p.setAttribute('d',pathFor(f,pr));\n      const fillCol=asis?CLOSE_COLOR:(mv.color[unitAssign(m)]||'#444');\n      p.setAttribute('class','muni');p.setAttribute('fill',fillCol);\n      if(!asis) p.onclick=()=>{editSelected(m2=>{const i=m2.dest.indexOf(m2.assign[m]);if(m2.dest.length)m2.assign[m]=m2.dest[(i+1)%m2.dest.length];m2.selMuni=m;});};\n      p.style.cursor=asis?'default':'pointer';\n      p.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';\n        t.innerHTML=asis?`<b>${mDisp(m)}</b><br>${fmt(mv.cbsByMuni[m]||0)} CBs · setor ${mv.sector}`:`<b>${mDisp(m)}</b><br>${fmt(mv.cbsByMuni[m]||0)} CBs → ${unitAssign(m)||'–'}<br><span style=\"color:var(--mut)\">clique p/ trocar</span>`;};\n      p.onmouseleave=()=>$('#tip').style.opacity=0;svg.appendChild(p);\n      const lp=labelPointOf(mv,'m:'+m,[f],pr);\n      const at=(lp&&lp.p)||cxy(f,pr);\n      const nmFull=(showUf&&mUf(m)?mUf(m)+'-':'')+mName(m);\n      const num=fmt(mv.cbsByMuni[m]||0)+(asis?'':'·'+(unitAssign(m)||'–'));\n      labels.push({anchor:at,cbs:+(mv.cbsByMuni[m]||0),dark:lum(fillCol)<0.18,\n        lines:[{t:nmFull,size:8.2,weight:700},{t:num,size:7,weight:500,mut:true}]});\n    });\n  } else {\n    // GV/mercado fechando: a UNIDADE é o setor (ou a GV) inteiro, nunca o município isolado —\n    // vários municípios do mesmo setor têm de ler como UMA área só, nunca como municípios separados\n    // que por acaso têm a mesma cor. Mesmo truque do contexto vizinho logo acima: stroke = fill via\n    // estilo inline (uma regra de classe como .muni{stroke:#fff} outrank­aria um atributo stroke\n    // comum, então a costura entre membros só some setando o style direto) — e UM rótulo só por\n    // unidade, no ponto de inacessibilidade da área dissolvida, nunca um rótulo por município.\n    Object.entries(mv.unitMunis||{}).forEach(([u,ms])=>{\n      const fillCol=asis?CLOSE_COLOR:(mv.color[mv.assign[u]]||'#444');\n      const feats=ms.map(m=>mv.geoFeat[m]).filter(Boolean);\n      // Todo município deste u é UMA unidade só na tela — passar o mouse sobre qualquer pedaço tem\n      // que acender o grupo inteiro, não só o pedaço que o cursor está tocando. :hover no CSS só\n      // sabe acender o próprio <path>; o grupo inteiro precisa de JS guardando os irmãos.\n      const els=[];\n      ms.forEach(m=>{\n        const f=mv.geoFeat[m]; if(!f) return;\n        const p=document.createElementNS(NS,'path');p.setAttribute('d',pathFor(f,pr));\n        p.setAttribute('class','muni');p.setAttribute('fill',fillCol);p.style.stroke=fillCol;\n        els.push(p);\n        if(!asis) p.onclick=()=>{editSelected(m2=>{const i=m2.dest.indexOf(m2.assign[u]);if(m2.dest.length)m2.assign[u]=m2.dest[(i+1)%m2.dest.length];m2.selMuni=u;});};\n        p.style.cursor=asis?'default':'pointer';\n        p.onmouseenter=()=>{ els.forEach(e=>e.classList.add('munihl')); };\n        p.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';\n          t.innerHTML=asis?`<b>${LEVELS[level].childLabel} ${u}</b><br>${fmt(mv.munis[u]?.cbs||0)} CBs · ${LEVELS[level].label} ${mv.sector}`:`<b>${LEVELS[level].childLabel} ${u}</b><br>${fmt(mv.munis[u]?.cbs||0)} CBs → ${mv.assign[u]||'–'}<br><span style=\"color:var(--mut)\">clique p/ trocar</span>`;};\n        p.onmouseleave=()=>{ els.forEach(e=>e.classList.remove('munihl')); $('#tip').style.opacity=0; };svg.appendChild(p);\n      });\n      const lp=labelPointOf(mv,'u:'+u,feats,pr);\n      const at=(lp&&lp.p)||(feats[0]&&cxy(feats[0],pr));\n      if(at){\n        const num=fmt(mv.munis[u]?.cbs||0)+(asis?'':'·'+(mv.assign[u]||'–'));\n        labels.push({anchor:at,big:true,cbs:+(mv.munis[u]?.cbs||0),dark:lum(fillCol)<0.18,\n          lines:[{t:`${LEVELS[level].childLabel} ${u}`,size:9,weight:700},{t:num,size:7.5,weight:500,mut:true}]});\n      }\n    });\n  }\n  // Placement, not de-cluttering. Every label IS drawn: it starts at its anchor and, if the text does\n  // not fit the territory or the slot is already taken, walks outward ring by ring until it finds\n  // clear canvas, trailing a hairline leader back to the shape it names. The previous pass dropped the\n  // loser of every collision outright, which is why municípios silently had no name at all.\n  // Order still matters — sector numbers first, then municípios by CB weight — because the earlier a\n  // label is placed the closer to home it lands.\n  const CW=700,CH=500,CPAD=2;\n  const inBounds=r=>r.x1>=CPAD&&r.x2<=CW-CPAD&&r.y1>=CPAD&&r.y2<=CH-CPAD;\n  const hits=r=>placed.reduce((a,q)=>a+((r.x2<q.x1||r.x1>q.x2||r.y2<q.y1||r.y1>q.y2)?0:1),0);\n  // Ring 0 is the anchor itself. Rings step 9u out over 16 directions, stretched horizontally because\n  // a label is wider than it is tall and sideways room is the room it actually needs.\n  const CAND=[[0,0]];\n  for(let ring=1;ring<=7;ring++){ const d=ring*9;\n    for(let a=0;a<16;a++){ const th=a*Math.PI/8; CAND.push([Math.cos(th)*d*1.35,Math.sin(th)*d]); } }\n  labels.sort((a,b)=>(b.big?1:0)-(a.big?1:0)||((b.cbs||0)-(a.cbs||0)));\n  labels.forEach(L=>{\n    let chosen=null,best=null,bestN=Infinity;\n    // Centred on the anchor is ALWAYS the first choice. An earlier cut here refused the anchor whenever\n    // the text was wider than the município's inscribed circle — true of almost every real name at this\n    // scale (BA-SAO SEBASTIAO DO PASSE is ~140u against ~80u of clearance), so every single label got\n    // nudged outside and nothing ended up centred on anything. Spilling past a border is normal on a\n    // choropleth and the halo keeps it readable; only a genuine COLLISION earns a move.\n    for(const off of CAND){\n      const x=L.anchor[0]+off[0], y=L.anchor[1]+off[1];\n      const r=rectOf(x,y,L.lines);\n      if(!inBounds(r)) continue;\n      const n=hits(r);\n      if(!n){ chosen={x,y,r,off}; break; }\n      // No clear slot yet — remember the LEAST crowded one. Falling back to the first in-bounds\n      // candidate meant falling back to the anchor every time, so a crowded map piled every loser onto\n      // the same point. CAND is ordered nearest-first and the compare is strict, so ties keep the\n      // closest slot.\n      if(n<bestN){ bestN=n; best={x,y,r,off}; }\n    }\n    // Canvas genuinely full: take the nearest in-bounds slot and accept the overlap. A label sitting\n    // on top of another is recoverable by eye; a name that was never drawn is not.\n    if(!chosen) chosen=best||{x:L.anchor[0],y:L.anchor[1],r:rectOf(L.anchor[0],L.anchor[1],L.lines),off:[0,0]};\n    placed.push(chosen.r);\n    const g=document.createElementNS(NS,'g'); g.setAttribute('class','lbl'+(L.big?' big':''));\n    if(Math.abs(chosen.off[0])>0.5||Math.abs(chosen.off[1])>0.5){\n      // The leader stops at the label's edge rather than its centre, so it never runs under the glyphs.\n      const cx=(chosen.r.x1+chosen.r.x2)/2, cy=(chosen.r.y1+chosen.r.y2)/2;\n      const hw=(chosen.r.x2-chosen.r.x1)/2, hh=(chosen.r.y2-chosen.r.y1)/2;\n      const dx=L.anchor[0]-cx, dy=L.anchor[1]-cy;\n      const tt=Math.min(dx?hw/Math.abs(dx):Infinity, dy?hh/Math.abs(dy):Infinity);\n      const ln=document.createElementNS(NS,'line'); ln.setAttribute('class','lead');\n      ln.style.stroke=L.dark?'#fff':'#1c1c1e';\n      // A largura do fio também é fixa em unidades do viewBox, igual o halo do texto — sem\n      // reescalar junto, no zoom alto ela vira uma barra grossa em vez de um fio fino.\n      ln.setAttribute('data-sw','0.8'); ln.style.strokeWidth='0.8px';\n      ln.setAttribute('x1',L.anchor[0].toFixed(1)); ln.setAttribute('y1',L.anchor[1].toFixed(1));\n      ln.setAttribute('x2',(cx+dx*tt).toFixed(1)); ln.setAttribute('y2',(cy+dy*tt).toFixed(1));\n      g.appendChild(ln);\n    }\n    const hTot=L.lines.reduce((a,l)=>a+l.size*1.2,0);\n    let ty=chosen.y-hTot/2+L.lines[0].size*0.86;\n    // Inline style rather than a class: a stylesheet rule would outrank a presentation attribute and\n    // undo the per-label contrast decision made above.\n    const ink=L.dark?'#fff':'#1c1c1e', inkMut=L.dark?'rgba(255,255,255,.85)':'#48484d';\n    const halo=L.dark?'rgba(0,0,0,.6)':'#fff';\n    // Halo do texto (o \"contorno\" que garante legibilidade sobre qualquer cor de fundo) também é\n    // fixo em unidades do viewBox via .lbl text{stroke-width}/.lbl.big text{stroke-width} — sem\n    // reescalar junto com o font-size, no zoom alto ele vira uma mancha escura cobrindo a letra\n    // inteira em vez de um contorno fino, exatamente o \"Setor 3570\" ilegível reportado.\n    const swBase=L.big?2.9:2.2;\n    L.lines.forEach(l=>{ const t=document.createElementNS(NS,'text');\n      t.setAttribute('x',chosen.x.toFixed(1)); t.setAttribute('y',ty.toFixed(1));\n      t.setAttribute('font-size',l.size); t.setAttribute('data-fs',l.size); t.setAttribute('font-weight',l.weight||600);\n      t.setAttribute('data-sw',swBase); t.style.strokeWidth=swBase+'px';\n      t.style.fill=l.mut?inkMut:ink; t.style.stroke=halo;\n      t.textContent=l.t; g.appendChild(t); ty+=l.size*1.2; });\n    svg.appendChild(g);\n  });\n  $('#legend').innerHTML=asis\n    ?`<span><b style=\"background:${CLOSE_COLOR}\"></b>${LEVELS[level].label} ${mv.sector} (estrutura antes desta etapa — ${level==='setor'?Object.keys(mv.geoFeat).length+' municípios':Object.keys(mv.unitMunis||{}).length+' '+LEVELS[level].childLabel.toLowerCase()+'(s)'})</span>`\n    // No \"vizinhos (contexto)\" or \"setor já fechado antes\" entry any more: every feature still drawn\n    // belongs to a destination sector, so .sect.plain and .sect.closed-ctx can no longer be produced\n    // and a legend key for them would point at nothing on the map.\n    :mv.dest.map(s=>`<span><b style=\"background:${mv.color[s]}\"></b>${LEVELS[level].destLabel} ${s} — ${c.gained[s]} líder(es)</span>`).join('');\n  // Municipalities outside the dominance base are deliberately NOT drawn — no shape, no hover, no\n  // legend entry. They are reported in the panel instead, so the map only ever shows territory that\n  // is actually being reallocated.\n  if(ignNote) ignNote.innerHTML=(mv.excludedMunis&&mv.excludedMunis.length)\n    ? `<b style=\"color:var(--closing)\">${fmt(mv.excludedMunis.length)} município(s) / ${fmt(mv.excludedCbs||0)} CBs</b> fora da base de dominantes deste setor não são desenhados no mapa — as CBs seguem a líder do seu grupo (detalhe em “Municípios → setor destino”).`\n    : '';\n  applyMapView();\n}\nasync function runSql(){const sql=$('#sqlIn').value.trim();$('#sqlStatus').textContent='rodando…';$('#sqlStatus').classList.remove('err');\n  try{ await ensurePinned(); const resp=await q(sql);const rs=rows(resp);const cols=(resp.manifest?.schema?.columns||[]).map(c=>c.name);\n    $('#sqlOut').innerHTML=`<thead><tr>${cols.map(c=>`<th>${c}</th>`).join('')}</tr></thead><tbody>`+rs.slice(0,200).map(r=>`<tr>${cols.map(c=>`<td>${r[c]??'·'}</td>`).join('')}</tr>`).join('')+`</tbody>`;\n    $('#sqlStatus').textContent=rs.length+' linha(s)';}\n  catch(e){$('#sqlStatus').textContent='bloqueado/erro: '+e.message;$('#sqlStatus').classList.add('err');}\n}\nfunction renderAll(){\n  renderStage();\n  renderTimeline(); renderSectorPicker();\n  const shb=$('#openShare'); if(shb) shb.disabled=!SESS.timeline.length;\n  const dpb=$('#dupSession'); if(dpb) dpb.disabled=!SESS.timeline.length;\n  const mv=SESS.timeline[SESS.selected];\n  // resolveSnapshotPin falls back to a live sub-select when it cannot pin a literal; saying\n  // \"congelado\" in that case would be a lie, so the pill reports what is actually in the SQL.\n  const pinned=SESS.snapLiteral && !/^\\(SELECT/i.test(SESS.snapLiteral);\n  $('#liveState').textContent = SESS.snapshotRaw\n    ? (pinned?'● snapshot congelado · ':'◍ snapshot ao vivo (não fixado) · ')+'atualizado em '+fmtSyncedAt(SESS.snapshotSyncedAt)+' · ↻'\n    : '● iniciando';\n  const mp=$('#basePill');\n  if(mp){\n    const warn=[];\n    if(DOM.bySetor&&!DOM.hasUf) warn.push('⚠ sem coluna UF');\n    if(DOM.badUf) warn.push('⚠ '+fmt(DOM.badUf)+' UF não reconhecida(s)');\n    if(DOM.clashes) warn.push('⚠ '+fmt(DOM.clashes)+' homônimo(s) no mesmo setor');\n    mp.textContent=DOM.bySetor?('◇ base dominantes: '+fmt(DOM.setores)+' setores · '+fmt(DOM.rows)+' municípios'+(warn.length?' · '+warn.join(' · '):'')):(DOM.error?'◇ base dominantes: indisponível':'◇ base dominantes: carregando…');\n    mp.title=DOM.error||(DOM.bySetor?((DOM.hasUf\n        ?('Coluna de UF usada: \"'+DOM.ufCol+'\".')\n        :('SEM coluna de UF. Colunas lidas: '+((DOM.headers||[]).join(' | ')||'?')+'. A UF de cada município vem desta base — sem ela, homônimos voltam a ser casados só pelo nome.'))\n      +(DOM.badUf?('\\n'+DOM.badUf+' linha(s) com UF irreconhecível — tratadas como desconhecida.'):'')\n      +(DOM.clashes?('\\n'+DOM.clashes+' município(s) aparecem no mesmo setor com UFs diferentes. O SQL agrupa só por DS_MUNICIPIO, então esses chegam somados numa linha única e não há como separá-los.'):'')\n      ):'')||DOM.url||''; }\n  const rp=$('#rulePill'); if(rp) rp.textContent='◈ destino: '+ruleShort(SESS.settings);\n  const sp=$('#spanPill'); if(sp) sp.textContent='◎ span: '+spanShort(SESS.settings);\n  renderRuleNote(); renderSpanRule();\n  if(!mv){ ['#map','#matrix','#matrixTobe','#tobeNote','#leaders'].forEach(s=>{const el=$(s);if(el)el.innerHTML='';}); $('#kpis').innerHTML=''; $('#spans').innerHTML=''; $('#muniList').innerHTML=''; $('#plan').innerHTML=''; return; }\n  $('#mapTitle').textContent=`Etapa ${SESS.selected+1}/${SESS.timeline.length} — fechando ${lc(LEVELS[mv.level||'setor'].label)} ${mv.sector}`;\n  $('#viewToggle').querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.v===mv.view));\n  $('#policy').querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.p===mv.policy));\n  $('#optBadge').textContent=mv.optBadge||'';\n  drawMap(); renderContig(); renderSpanRule(); renderKpis(); renderSpans(); renderMuni(); renderLeaders(); renderMatrix(); renderMatrixTobe(); renderPolicy(); renderPlan(); renderReport(); renderCumulative();\n}\n\n// ---------- event wiring ----------\n$('#confirmSnap').onclick=()=>{ SESS.stage='main'; renderAll(); };\n$('#openMove').onclick=openMove;\n$('#moveClose').onclick=closeMove;\n$('#moveCancel').onclick=closeMove;\n$('#moveBg').addEventListener('click',e=>{ if(e.target.id==='moveBg') closeMove(); });\nroot.querySelectorAll('input[name=\"moveLevel\"]').forEach(r=>r.addEventListener('change',renderMoveForm));\n$('#moveRand').onclick=moveRandPick;\n$('#moveGo').onclick=submitMove;\n$('#moveCode').addEventListener('keydown',e=>{ if(e.key==='Enter') submitMove(); });\n$('#confirmSessionPick').onchange=()=>loadSession($('#confirmSessionPick').value);\n$('#optim').onclick=()=>editSelected(mv=>{ mv.assign=optimizeFor(mv); });\n$('#autoTobe').onclick=()=>editSelected(mv=>{ mv.assign=autoAssignFor(mv); mv.optBadge='TOBE por adjacência'; });\n$('#viewToggle').querySelectorAll('button').forEach(b=>b.onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;mv.view=b.dataset.v;renderAll();});\n$('#mapZoomIn').onclick=()=>zoomMapBy(1.4);\n$('#mapZoomOut').onclick=()=>zoomMapBy(1/1.4);\n$('#map').addEventListener('pointerdown',mapPointerDown);\nroot.addEventListener('pointermove',mapPointerMove);\nroot.addEventListener('pointerup',mapPointerUp);\nroot.addEventListener('pointerleave',mapPointerUp);\n// Arrastar não pode virar \"cliquei num município e troquei o destino sem querer\": se o ponteiro se\n// moveu além do limiar de arrasto, este clique (capture, antes de chegar no <path>) é engolido.\n$('#map').addEventListener('click',e=>{ if(mapDidDrag){ e.stopPropagation(); e.preventDefault(); mapDidDrag=false; } },true);\n$('#policy').querySelectorAll('button').forEach(b=>b.onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;mv.policy=b.dataset.p;renderKpis();renderPolicy();renderPlan();});\nroot.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('on',x===b));\n  ['matrix','tobe','report','cumulative','sql'].forEach(t=>$('#tab-'+t).classList.toggle('hidden',t!==b.dataset.tab));});\n$('#runSql').onclick=runSql;\n$('#copyPlan').onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;navigator.clipboard.writeText(JSON.stringify({setor:mv.sector,politica:mv.policy,plano:mv._plan},null,2));$('#copyPlan').textContent='copiado ✓';setTimeout(()=>$('#copyPlan').textContent='Copiar',1500);};\n$('#csvPlan').onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;const csv='municipio,uf,cbs,setor_destino\\n'+mv._plan.map(p=>`${p.municipio},${p.uf||''},${p.cbs},${p.destino}`).join('\\n');\n  const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));a.download=`plano_migracao_setor_${mv.sector}.csv`;a.click();};\n$('#saveSession').onclick=saveSession;\n$('#dupSession').onclick=duplicateSession;\n$('#sessionPick').onchange=()=>loadSession($('#sessionPick').value);\n$('#liveState').onclick=refreshSnapshot;\n$('#liveState').addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); refreshSnapshot(); } });\n$('#openOptions').onclick=openOptions;\n$('#optClose').onclick=closeOptions;\n$('#optCancel').onclick=closeOptions;\n$('#optApply').onclick=applyOptions;\n$('#optMinCbs').addEventListener('input',renderOptPreview);\n['#optTargetSpan','#optMaxSpan','#optMinSpan'].forEach(id=>$(id).addEventListener('input',renderOptPreview));\nroot.querySelectorAll('input[name=\"destRule\"]').forEach(r=>r.addEventListener('change',renderOptPreview));\n$('#optionsBg').addEventListener('click',e=>{ if(e.target.id==='optionsBg') closeOptions(); });\nroot.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeOptions(); closeShare(); closeMove(); } });\n\n$('#openShare').onclick=openShare;\n$('#shareX').onclick=closeShare;\n$('#shareCancel').onclick=closeShare;\n$('#shareGo').onclick=doShare;\n$('#shareCopy').onclick=()=>{ if(!SHARE.url) return; navigator.clipboard.writeText(SHARE.url);\n  const b=$('#shareCopy'); if(b){ b.textContent='copiado ✓'; setTimeout(()=>{ const x=$('#shareCopy'); if(x) x.textContent='Copiar link'; },1500); } };\n$('#shareOpen').onclick=()=>{ if(SHARE.url) window.open(SHARE.url,'_blank'); };\n$('#shareBg').addEventListener('click',e=>{ if(e.target.id==='shareBg') closeShare(); });\n\n// ---------- init ----------\n(async()=>{\n  loader(true,'congelando snapshot da sessão…');\n  try{\n    await Promise.all([ensurePinned(), loadSectors(), refreshSessionPicker()]);\n    SESS.stage='confirm';\n  }catch(e){ setError('erro init: '+e.message); }\n  finally{ loader(false); renderAll(); const rd=$('#ready'); if(rd) rd.setAttribute('data-loaded','1'); }\n})();\n\n  }\n}\n"}},"sessionsDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"sessionsDataset"}}}},"dominantesCsvUrl":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"dominantesCsvUrl"}}}}}}}}}},{"_id":"6a7f2f11a14a6f1480484324","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"KRgNUar3hCwC4PglklYuS","package":"natura-geomarketing","extends":"data2-core-primitives@Block","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const T='product.perfil_cb.crm_perfil_cb_diaria_historico';\nconst ELO='product.elo_modelo_combinado.commercial_consultant_index';\nconst PALETTE=['#5856d6','#ff9500','#28cd41','#af52de','#ff2d55','#a2845e'];\nconst CLOSE_COLOR='#8e8e93';\nconst UF_CODE={RO:'11',AC:'12',AM:'13',RR:'14',PA:'15',AP:'16',TO:'17',MA:'21',PI:'22',CE:'23',RN:'24',PB:'25',PE:'26',AL:'27',SE:'28',BA:'29',MG:'31',ES:'32',RJ:'33',SP:'35',PR:'41',SC:'42',RS:'43',MS:'50',MT:'51',GO:'52',DF:'53'};\nconst UF_BORDERS={AC:['AM','RO'],AL:['BA','PE','SE'],AM:['AC','RO','RR','PA','MT'],AP:['PA'],\n BA:['AL','SE','PE','PI','TO','GO','MG','ES'],CE:['PI','PB','PE','RN'],DF:['GO','MG'],\n ES:['BA','MG','RJ'],GO:['DF','MT','MS','MG','BA','TO'],MA:['PA','TO','PI'],\n MG:['BA','GO','DF','MS','SP','RJ','ES'],MS:['MT','GO','MG','SP','PR'],MT:['RO','AM','PA','TO','GO','MS'],\n PA:['AP','AM','MT','TO','MA','RR'],PB:['RN','CE','PE'],PE:['PB','CE','PI','BA','AL'],\n PI:['MA','TO','BA','PE','CE'],PR:['SP','MS','SC'],RJ:['ES','MG','SP'],RN:['CE','PB'],\n RO:['AC','AM','MT'],RR:['AM','PA'],RS:['SC'],SC:['PR','RS'],SE:['BA','AL'],\n SP:['MG','RJ','PR','MS'],TO:['PA','MA','PI','BA','GO','MT']};\nconst NIVEIS=['Bronze','Cristal','Prata','Ouro','Diamante','Diamante+'];\nconst METRICS_DEF=[['cbs','CBs'],['ativas','Ativas'],['pedidos','Pedidos'],['pontos','Pontos'],['receita','Receita','$'],['rec_nat','Rec. Natura','$'],['rec_avon','Rec. Avon','$']];\nconst OPT_K=1.5;\nconst SESSIONS_DS='geo-timeline-sessionsDataSet';   // dedicated saved-sessions table, separate from SHARED_DS — see notes at listSessions()\n// Span defaults are the real operating band, not placeholders: setor 2200 ≤ span ≤ 3500 (alvo 2700),\n// GV 10000 ≤ span ≤ 22000 (alvo 16000). Mercado não tem um histórico equivalente ainda, então nasce\n// sem limite (null) até alguém configurar. Span agora é uma escala POR NÍVEL: fechar um setor só olha\n// span.setor, fechar uma GV só olha span.gv, fechar um mercado só olha span.mercado — nunca se misturam.\n// Existing sessions are NOT touched — their persisted settings carry explicit values that spread OVER\n// these in openOptions/applyOptions, which is deliberate: their etapas were computed under the old\n// band and changing it silently would leave the stored result and the rule that produced it out of sync.\nconst DEFAULT_SETTINGS={span:{\n  setor:{targetSpan:2700,maxSpan:3500,minSpan:2200},\n  gv:{targetSpan:16000,maxSpan:22000,minSpan:10000},\n  mercado:{targetSpan:null,maxSpan:null,minSpan:null},\n},\n  forceContiguity:true,\n};\nconst DOM_MEDIA_BASE='https://natura-geomarketing.data2.media/file/';\nconst DOM_CSV_FALLBACK=DOM_MEDIA_BASE+'yw_TQdp0QXEk6JsrliAqP'; \nconst numOrNull=v=>{const n=Math.round(+v);return (isFinite(n)&&n>0)?n:null;};\nconst spanShort=st=>{const p=[];if(st&&st.targetSpan)p.push('alvo '+st.targetSpan);if(st&&st.maxSpan)p.push('máx '+st.maxSpan);if(st&&st.minSpan)p.push('mín '+st.minSpan);return p.length?p.join(' · '):'livre';};\n\nexport default {\n  css: `\n  :host{display:block;height:100%;--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--acc2:#0060df;--closing:#ff3b30;--ok:#28cd41;\n    /* Tokens do AppKit real (macOS Big Sur): raio 6 e sombra y1/blur3/15% preto são os valores\n       documentados de botão/controle do sistema, não um chute — usados em todo botão, select,\n       segmented control e tab pra ler como controle nativo em vez de elemento web plano. */\n    --radius-ctrl:6px;--shadow-ctrl:0 1px 3px rgba(0,0,0,.15)}\n  *{box-sizing:border-box}\n  .simroot{position:relative;height:100%;overflow:auto;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}\n  header{padding:10px 16px;background:rgba(246,246,248,.85);backdrop-filter:blur(20px) saturate(1.8);-webkit-backdrop-filter:blur(20px) saturate(1.8);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-wrap:wrap}\n  header h1{font-size:14.5px;margin:0;font-weight:600;letter-spacing:-.1px;color:var(--ink)}\n  .mac-spacer{flex:1}\n  .howto{padding:8px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--mut);align-items:center}\n  .howto b{color:var(--ink)} .howto span{max-width:340px}\n  .pill{font-size:10.5px;padding:2.5px 9px;border-radius:99px;border:1px solid var(--line);color:var(--mut);background:var(--panel2)}\n  .pill.live{color:var(--ink);cursor:pointer;user-select:none;transition:border-color .1s,color .1s,background .1s}\n  .pill.live:hover{border-color:var(--acc);color:var(--acc);background:#eaf3ff}\n  .pill.live:focus-visible{outline:2px solid rgba(0,122,255,.35);outline-offset:1px}\n  .pill.live.busy{border-color:var(--acc);color:var(--acc);cursor:progress}\n  .pill.live.locked{border-color:#ff9500;color:#ff9500;background:#fff6ea}\n  .status{font-size:11px;color:var(--mut)}.status.err{color:var(--closing)}\n  select,input,textarea,button{font:inherit}\n  select,input{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:var(--radius-ctrl);padding:5.5px 9px;box-shadow:var(--shadow-ctrl)}\n  /* Seta nativa do <select> cola na borda direita sem respiro nenhum — appearance:none tira a seta\n     do sistema e desenha uma própria, com o mesmo respiro (9px) do padding horizontal do resto. */\n  select{appearance:none;-webkit-appearance:none;-moz-appearance:none;padding-right:26px;\n    background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5.5 8L10 12.5L14.5 8' fill='none' stroke='%238e8e93' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n    background-repeat:no-repeat;background-position:right 9px center;background-size:13px}\n  select:focus,input:focus{outline:2px solid rgba(0,122,255,.35);outline-offset:0;border-color:var(--acc)}\n  /* Native <select>/<datalist> OPEN-LIST popups are drawn by the browser itself and cannot be\n     restyled on any engine — only the closed control can be skinned. .macsel repaints the open list\n     as a real DOM element (an actual AppKit-style popover) while the underlying <select>/<input>\n     stays the real state — same .value/.onchange/.options, every existing read/write untouched. */\n  .macsel{position:relative}\n  .macsel-btn{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;text-align:left;background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:var(--radius-ctrl);padding:5.5px 9px;box-shadow:var(--shadow-ctrl);cursor:pointer}\n  .macsel-btn:hover{background:var(--panel2)}\n  .macsel-btn:focus-visible{outline:2px solid rgba(0,122,255,.35);outline-offset:0;border-color:var(--acc)}\n  .macsel-btn .macsel-lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n  .macsel-btn .macsel-car{color:#8e8e93;flex:0 0 auto;font-size:11px;transform:translateY(1px)}\n  /* .macsel-panel/.macsel-opt/.macsel-empty are styled from a plain <style> injected inside\n     macPortal() (mounted(), below), not here — panels live in a document.body portal, which can't\n     see CSS scoped to this shadow root. Body, not this shadow tree: a shadow-root-relative fixed\n     portal empirically lost the stacking fight against the \"Adicionar movimentação\" modal's own\n     backdrop-filter layer despite a far higher z-index; swapping to a body portal (the platform's\n     documented overlay pattern) painted correctly on the first try. */\n  #moveCode{padding-right:26px;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5.5 8L10 12.5L14.5 8' fill='none' stroke='%238e8e93' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");background-repeat:no-repeat;background-position:right 9px center;background-size:13px}\n  button.act{background:var(--acc);color:#fff;border:1px solid var(--acc2);border-radius:var(--radius-ctrl);padding:6px 13px;font-weight:600;cursor:pointer;box-shadow:var(--shadow-ctrl);transition:background .1s}\n  button.act:hover{background:var(--acc2)}\n  button.act:active{background:#0052c4;box-shadow:inset 0 1px 2px rgba(0,0,0,.25)}\n  button.ghost{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:var(--radius-ctrl);padding:5.5px 12px;cursor:pointer;box-shadow:var(--shadow-ctrl);transition:background .1s}\n  button.ghost:hover{background:var(--panel2)}\n  button.ghost:active{background:#e5e5ea;box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}\n  button.ghost.on{border-color:var(--acc);color:var(--acc);background:#eaf3ff}\n  button.ghost:disabled,button.act:disabled{opacity:.4;cursor:not-allowed;box-shadow:none}\n  .sessbar{padding:9px 20px;background:var(--panel2);border-bottom:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap;align-items:center}\n  .rail{padding:9px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:8px;align-items:center;overflow-x:auto}\n  #movementChips{display:flex;gap:8px;flex:0 0 auto}\n  #addMovementGroup{display:flex;gap:8px;align-items:center;flex:0 0 auto;margin-left:auto;padding-left:12px;border-left:1px solid var(--line)}\n  #addMovementGroup input{min-width:150px}\n  .tchip{display:flex;align-items:center;gap:7px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;cursor:pointer;font-size:12px;flex:0 0 auto}\n  .tchip.on{border-color:var(--acc);background:#eaf3ff}\n  .tchip.stale{border-style:dashed;opacity:.85}\n  .tchip .tstale{color:#ff9500;font-weight:700}\n  .tchip .tnum{background:var(--ink);color:#fff;border-radius:99px;width:16px;height:16px;font-size:9.5px;display:flex;align-items:center;justify-content:center;font-weight:700}\n  .tchip .tsec{font-weight:600}\n  .tchip .tstat{color:var(--mut)}\n  .tchip .trm{color:var(--mut);font-weight:700;padding:0 3px}\n  .tchip .trm:hover{color:var(--closing)}\n  .tchip.tchip-transfer{background:var(--panel)}\n  .tchip.tchip-transfer .tsec{font-weight:500}\n  .tchip .tarw{color:var(--mut);font-weight:700;font-size:11px}\n  .rail .empty{color:var(--mut);font-size:12px}\n  .wrap{display:grid;grid-template-columns:minmax(360px,1fr) 430px;gap:14px;padding:14px 16px;align-items:start}\n  @media(max-width:1150px){.wrap{grid-template-columns:1fr}}\n  .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:14px;box-shadow:0 1px 3px rgba(0,0,0,.08)}\n  .card h2{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);margin:0 0 9px;font-weight:600}\n  /* user-select, not pointer-events. .lbl already sets pointer-events:none, which stops the text from\n     RECEIVING clicks but does not exclude it from the browser's selection range — so cycling a\n     município's destination with repeated clicks kept dragging a double-click selection across the\n     labels. Nothing drawn in an svg here is text anyone needs to copy. */\n  svg{width:100%;height:auto;display:block;user-select:none;-webkit-user-select:none}\n  /* O <svg> tinha width:100%;height:auto — a altura era só uma função da LARGURA da coluna, então\n     numa tela larga e curta o mapa virava mais alto que o espaço vertical sobrando e a página\n     inteira crescia além da tela. max-height no wrapper + height:100%;width:auto;max-width:100% no\n     próprio svg é o equivalente de object-fit:contain: o navegador encaixa o viewBox 700x500 (via\n     preserveAspectRatio, que já existia) dentro do que sobrar de altura OU largura, o que for menor\n     primeiro, sem distorcer nem cortar nada. */\n  /* Altura real vem de fitMapHeight() (JS): mede quanto sobrou de viewport abaixo do topo do\n     card e aplica isso como altura inline — assim o mapa para exatamente na borda da tela\n     independente do que tem acima (cabeçalho, trilho de etapas, toolbar) ou abaixo dele.\n     60vh aqui é só o valor antes do primeiro paint do JS rodar. */\n  .map-wrap{position:relative;height:60vh;display:flex;justify-content:center;overflow:hidden}\n  #map{cursor:grab;touch-action:none;width:100%;height:100%}\n  #map.dragging{cursor:grabbing}\n  .zoom-pill{position:absolute;left:10px;bottom:10px;display:flex;align-items:stretch;background:var(--panel);border:1px solid var(--line);border-radius:99px;box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);overflow:hidden;z-index:3}\n  .zoom-pill button{width:30px;height:30px;border:0;background:transparent;color:var(--ink);font:16px/1 -apple-system,BlinkMacSystemFont,sans-serif;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center}\n  .zoom-pill button:hover{background:var(--panel2)}\n  .zoom-pill button:active{background:#e5e5ea}\n  .zoom-pill .zoom-div{width:1px;background:var(--line)}\n  .osm-panel{position:absolute;left:10px;top:10px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);padding:8px 10px;font-size:11.5px;z-index:3;min-width:104px}\n  .osm-panel-icon{position:absolute;left:10px;top:10px;width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);cursor:pointer;color:var(--ink);z-index:3}\n  .osm-panel-icon:hover{background:var(--panel2)}\n  .osm-panel-hd{font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between}\n  .osm-group-hd{font-size:9.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600;margin:2px 0 4px}\n  .osm-group-hd.sep{margin-top:8px;padding-top:8px;border-top:1px solid var(--line)}\n  .osm-row{display:flex;align-items:center;gap:6px;padding:2px 0;cursor:pointer;color:var(--ink)}\n  .osm-row.osm-sub{padding-left:14px;font-size:10.5px;color:var(--mut)}\n  .osm-row input{accent-color:var(--acc);margin:0}\n  .osm-status{margin-top:6px;font-size:10px;color:var(--mut);max-width:150px;line-height:1.4}\n  /* White hairlines inside the closing sector: the municipios read as one territory cut into parts\n     rather than as separate outlined units, and the saturated fills carry the grouping instead of the\n     ink. Neighbour sectors keep their own coloured strokes (.sect) — that contrast is what separates\n     what is being reallocated from what is only context. Selection no longer draws an outline; the\n     white border was the click affordance AND the default is now white, so it had nothing left to say. */\n  .muni{stroke:#fff;stroke-width:1.1;stroke-linejoin:round;stroke-linecap:round;cursor:pointer;transition:filter .1s}.muni:hover{filter:brightness(1.15)}.muni.munihl{filter:brightness(1.15)}\n  /* GV/mercado closing (\"Contorno de Setores\" toggle): traces one unit's (setor's) DISSOLVED\n     territory as a whole, never a per-município line — drawn once per member município, but always\n     BEFORE that unit's own opaque fills, so every internal edge (shared by two municípios of the\n     SAME unit) gets painted over twice by those fills and only the unit's true external boundary\n     survives. Same border-before-fill trick already used above for .sect-rejected-border. */\n  .unit-outline{fill:none;stroke:#fff;stroke-width:1.1;stroke-linejoin:round;stroke-linecap:round;pointer-events:none}\n  .sect{stroke-width:1;stroke-linejoin:round;stroke-linecap:round;fill-opacity:.32;stroke-opacity:.6}\n  /* .sect's normal look is translucent (fill-opacity .32) — the plain brightness bump .muni.munihl\n     uses would barely register on top of that, so this highlight also raises opacity so the piece\n     visibly pops when a fix-card plan hovers it (see moveWork/setPlanHl). */\n  .sect.munihl{fill-opacity:.68;filter:brightness(1.12)}\n  /* Discard-paint highlight (DISC_PAINT, drawMap): unlike .munihl elsewhere (fix-card hover preview,\n     transfer-stage permanent highlight, GV/mercado group hover) — which brightens/opacifies the\n     existing fill — a discarded-move município must keep its ORIGINAL colour exactly as it would\n     render outside paint mode; the colour still means whatever it always meant (destination sector,\n     context, etc). So this is a dedicated stroke-only overlay that never touches fill/opacity, and\n     !important to win over any inline stroke a município's own path already carries (e.g. a\n     GV/mercado unit's stroke=fill inline style). */\n  .disc-hl{stroke:var(--acc)!important;stroke-width:0.5!important;stroke-opacity:1!important}\n  /* Mesmo truque do .sect.rejected acima — stroke = fill, não uma cor de borda diferente. O bucket\n     __MULTI_NOVALID__ pode cobrir VÁRIOS pedaços já pré-recortados do mesmo município compartilhado\n     (mv.sharedMuniSlices fatia geometricamente uma vez, antes de saber quais fatias são válidas) —\n     um stroke diferente do fill desenhava uma costura visível entre cada pedaço mesmo todos pintados\n     do mesmo cinza. */\n  .sect.plain{fill:#e8e8ec;fill-opacity:1}\n  .sect.closed-ctx{fill:#e2e2e7;stroke:#aeaeb2;stroke-dasharray:3 2;fill-opacity:1;stroke-opacity:1}\n  /* stroke igual ao fill, não \"sem stroke\": dois preenchimentos opacos idênticos lado a lado ainda\n     rasterizam com uma costura fina de antialiasing na borda compartilhada — um traço fino da MESMA\n     cor cobre exatamente esse pixel de costura sem acrescentar nenhuma borda visível de verdade.\n     Diferente do truque \"sem stroke\" usado no destino (fill-opacity .32, translúcido): lá, stroke\n     igual ao fill dobra a composição e escurece; aqui, opaco sobre opaco é só a mesma cor de novo. */\n  .sect.rejected{fill:#e4e4ea;stroke:#e4e4ea;stroke-width:0.6;fill-opacity:1;cursor:help}\n  .sect-rejected-border{fill:none;stroke:#b6b6bd;stroke-width:1;stroke-opacity:.6;stroke-linejoin:round;pointer-events:none}\n  /* Same halo trick used by .sect-rejected-border, drawn for the OPPOSITE reason: a município with\n     >1 real sector already gets the diagonal hatch overlay (see multiSectorHatch) — this border makes\n     that same município's outline read clearly against whatever sits behind/around it, since the\n     hatch alone is easy to miss at a glance. */\n  .sect-multi-border{fill:none;stroke:#1c1c1e;stroke-width:0.6;stroke-opacity:.4;stroke-linejoin:round;pointer-events:none}\n  .sect.unresolved{fill:#fbeceb;stroke:#e8a29c;stroke-dasharray:2 2;fill-opacity:1;stroke-opacity:1;cursor:help}\n  .lbl{pointer-events:none;filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.16))}\n  /* No plate behind the text: a halo painted UNDER the glyphs (paint-order) keeps a name legible on any\n     fill without stamping an opaque box over the territory it names. Ink and halo are chosen per label\n     from the luminance of the fill underneath, so they are deliberately NOT declared here — a\n     stylesheet rule outranks a presentation attribute and would silently override that choice. */\n  .lbl text{text-anchor:middle;paint-order:stroke fill;stroke-width:1.8px;stroke-linejoin:round;stroke-linecap:round;letter-spacing:-.1px;font-family:-apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif}\n  .lbl.big text{stroke-width:2.4px}\n  .lbl line.lead{stroke-opacity:.6;stroke-width:.8;stroke-linecap:round}\n  .legend{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;font-size:12px}\n  .legend b{display:inline-block;width:11px;height:11px;border-radius:4px;margin-right:5px;vertical-align:-1px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}\n  .kpis{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-bottom:12px}\n  .kpi{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:9px 11px}\n  .kpi .n{font-size:19px;font-weight:700}.kpi .l{font-size:10.5px;color:var(--mut);margin-top:2px}\n  .kpi.warn{border-color:#ffd1cc}.kpi.warn .n{color:var(--closing)}.kpi.ok{border-color:#bdeecb}.kpi.ok .n{color:var(--ok)}\n  .bar-row{display:flex;align-items:center;gap:8px;margin:6px 0}\n  .bar-row .tag{width:42px;font-weight:600;font-size:12px}\n  .bar-track{flex:1;height:18px;background:var(--panel2);border-radius:5px;overflow:hidden;display:flex;position:relative}\n  .bar-mark{position:absolute;top:0;bottom:0;width:2px;opacity:.9;z-index:2}\n  .bar-base{height:100%;opacity:.4}.bar-inc{height:100%}\n  /* Faixa listrada pra diferenciar um ganho vindo de uma TRANSFERÊNCIA manual de município do ganho\n     normal de uma etapa de fechamento (sólido) — mesma cor do setor por baixo, só o padrão muda.\n     !important pra vencer o style inline (shorthand 'background', que já zera background-image). */\n  .bar-inc.xfer{background-image:repeating-linear-gradient(135deg,rgba(255,255,255,.55) 0 5px,rgba(255,255,255,0) 5px 10px)!important}\n  .bar-val{width:132px;text-align:right;font-size:11px;color:var(--mut);font-variant-numeric:tabular-nums}\n  table{border-collapse:collapse;width:100%;font-size:11.5px}\n  th,td{padding:5px 6px;text-align:right;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}\n  th:first-child,td:first-child{text-align:left}\n  #matrixTobe th,#matrixTobe td{text-align:center}\n  #matrix th,#matrix td{text-align:center}\n  #matrix th:first-child,#matrix td:first-child,#matrixTobe th:first-child,#matrixTobe td:first-child{text-align:left}\n  thead th{color:var(--mut);font-weight:600;font-size:10.5px;position:sticky;top:0;background:var(--panel)}\n  .home{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:4px}\n  .muni-item{display:flex;flex-direction:column;gap:6px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin-bottom:6px}\n  .muni-item-hd{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}\n  .muni-item .nm{font-weight:600;font-size:12px}.muni-item .ct{flex:0 0 auto;color:var(--mut);font-size:11px}\n  .muni-item>.badge{align-self:flex-start}\n  /* Nome + contagem moram na propria linha (.muni-item-hd), separada da linha de destino — nao mais\n     uma unica linha disputando espaco entre nome, contagem e os botoes de setor. GV/mercado pode\n     oferecer bem mais que os 6 destinos do setor (todo irmao da mercado, as vezes dezenas): a linha\n     de destino agora usa a LARGURA INTEIRA do card (stretch do flex-column do .muni-item), e um nome\n     de municipio comprido cresce pra baixo (flex-wrap na linha de cima) em vez de espremer o destino.\n     .seg mantem scroll horizontal proprio (min-width:0 e o que deixa um item flex encolher alem do\n     tamanho do conteudo, liberando a barra) pros casos com destinos demais mesmo com a largura cheia. */\n  .seg{display:flex;background:var(--panel2);border:1px solid rgba(0,0,0,.22);border-radius:7px;overflow-x:auto;overflow-y:hidden;min-width:0}\n  /* Without this, .seg's overflow-x:auto falls back to the OS/browser's native scrollbar — full-size\n     track + arrow buttons on Windows Chromium, dwarfing the 26px-tall control it belongs to. A thin\n     WebKit scrollbar (no buttons, no track chrome) reads as part of the control instead of a foreign\n     OS widget bolted under it. */\n  .seg::-webkit-scrollbar{height:5px}\n  .seg::-webkit-scrollbar-button{display:none;width:0;height:0}\n  .seg::-webkit-scrollbar-track{background:transparent}\n  .seg::-webkit-scrollbar-thumb{background:rgba(0,0,0,.18);border-radius:99px}\n  .seg::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3)}\n  .seg button{border:0;border-right:1px solid var(--line);background:transparent;color:var(--mut);padding:4px 8px;font-size:11px;font-weight:600;cursor:pointer;flex:0 0 auto}\n  .seg button:last-child{border-right:0}\n  .seg button.on{color:#fff}\n  .toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}\n  /* Segmented control de verdade: trilho cinza (panel2) + o segmento ativo vira uma cápsula branca\n     flutuando por cima (shadow-ctrl), não só uma mudança de cor plana. */\n  .toggle{display:flex;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:2px;gap:1px;font-size:12px}\n  .toggle button{background:transparent;border:0;border-radius:6px;color:var(--mut);padding:5px 10px;cursor:pointer;font-weight:600;transition:background .12s,box-shadow .12s,color .12s}\n  .toggle button.on{background:#fff;color:var(--ink);box-shadow:var(--shadow-ctrl)}\n  .note{color:var(--mut);font-size:10.5px;margin-top:7px;line-height:1.5}\n  .rule-item{padding:9px 0}\n  .rule-item+.rule-item{border-top:1px solid var(--line);margin-top:2px;padding-top:11px}\n  .rule-cap{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin-bottom:5px}\n  .rule-ok{color:var(--ok);font-size:12.5px}\n  .rule-issues{display:flex;flex-direction:column;gap:8px}\n  .rule-issue-row{display:flex;flex-direction:column;gap:7px}\n  .rule-issue-hd{display:flex;align-items:flex-start;gap:6px}\n  .rule-issue-hd button.rule-issue{flex:1 1 auto;min-width:0}\n  button.fixdismiss{flex:0 0 auto;background:transparent;border:1px solid var(--line);color:var(--mut);font-weight:600;font-size:12px;line-height:1;padding:5px 8px;border-radius:var(--radius-ctrl);cursor:pointer;transition:background .1s,color .1s}\n  button.fixdismiss:hover{background:var(--panel2);color:var(--ink)}\n  button.rule-issue{align-self:flex-start;background:#fdeceb;border:1px solid #f3b4ae;color:var(--closing);font-weight:600;font-size:12px;padding:5px 10px;border-radius:var(--radius-ctrl);box-shadow:var(--shadow-ctrl);cursor:pointer;text-align:left;transition:background .1s;max-width:100%;white-space:normal}\n  button.rule-issue:hover{background:#fbdedb}\n  button.rule-issue:disabled{opacity:.6;cursor:progress}\n  .fixresult{display:flex;flex-direction:column;gap:8px}\n  .fixcard{border:1px solid var(--line);background:var(--panel2);border-radius:10px;padding:10px 12px;font-size:12px;line-height:1.55;display:flex;flex-direction:column;gap:7px}\n  .fixcard .chips{display:flex;flex-wrap:wrap;gap:5px}\n  .chip{display:inline-flex;align-items:center;font-size:11px;font-variant-numeric:tabular-nums;padding:2.5px 8px;border-radius:99px;background:var(--panel);border:1px solid var(--line);color:var(--ink)}\n  .chip.bad{color:var(--closing);border-color:#f3b4ae;background:#fdeceb;font-weight:600}\n  .fixcard .verdict{font-weight:600}\n  .fixcard .verdict.ok{color:var(--ok)}\n  .fixcard .verdict.bad{color:var(--closing)}\n  .fixcard .hint{color:var(--mut);font-size:11px}\n  .fixcard .applyfix{align-self:flex-start}\n  .cell{font-variant-numeric:tabular-nums}\n  .lider-foot td{background:var(--panel2);font-weight:600;font-size:11px;color:var(--ink);border-bottom:2px solid var(--line);padding:5px 6px;text-align:left}\n  .scroll{max-height:240px;overflow:auto}\n  /* Tables meant to be READ in full rather than sampled. Only the vertical cap is dropped — overflow\n     stays auto so the wide grupo grid can still scroll sideways inside its card, which is the one axis\n     that genuinely cannot fit. */\n  .scroll.grow{max-height:none}\n  .tip{position:fixed;pointer-events:none;background:#1c1c1e;color:#fff;border-radius:8px;padding:7px 9px;font-size:11px;opacity:0;transition:opacity .1s;z-index:9;max-width:230px;box-shadow:0 2px 8px rgba(0,0,0,.28)}\n  .spark{display:flex;align-items:flex-end;gap:3px;height:44px}\n  .spark .b{flex:1;background:var(--acc);border-radius:2px 2px 0 0;min-height:2px;opacity:.85}\n  .delta{font-size:10px}.delta.up{color:var(--ok)}.delta.dn{color:var(--closing)}\n  .tabs{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}\n  .tabs button{background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--mut);padding:6px 12px;cursor:pointer;font-weight:600;box-shadow:var(--shadow-ctrl);transition:background .1s,box-shadow .1s}\n  .tabs button.on{background:#fff;color:var(--ink);border-color:var(--acc);box-shadow:0 1px 3px rgba(0,0,0,.2)}\n  .hidden{display:none}\n  .sqlbox{width:100%;height:60px;font-family:ui-monospace,Menlo,monospace;font-size:11px;resize:vertical}\n  .badge{font-size:10px;padding:1px 6px;border-radius:5px;background:var(--panel2);border:1px solid var(--line);color:var(--mut)}\n  /* #optBadge é o único badge que costuma quebrar em várias linhas (resumo inteiro do otimizador) —\n     o padding vertical de 1px do .badge genérico (pensado pra um badge de uma linha só) deixa o\n     texto quase colado nas bordas de cima/baixo quando quebra; display:inline-block faz o padding\n     valer pra caixa inteira, não só a ponta da primeira/última linha de um span inline. */\n  #optBadge{display:inline-block;padding:5px 8px;line-height:1.5}\n  .sheet-bg{position:fixed;inset:0;background:rgba(28,28,30,.34);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;z-index:40;padding:24px}\n  .sheet-bg.hidden{display:none}\n  .sheet{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:0 24px 60px rgba(0,0,0,.22);width:min(580px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden}\n  .sheet-hd{display:flex;align-items:center;gap:9px;padding:12px 16px;border-bottom:1px solid var(--line)}\n  .sheet-hd h3{margin:0;font-size:14px;font-weight:600;letter-spacing:-.1px}\n  .sheet-x{background:transparent;border:0;color:var(--mut);font-size:13px;cursor:pointer;padding:3px 7px;border-radius:6px}\n  .sheet-x:hover{background:var(--panel2);color:var(--ink)}\n  .sheet-bd{padding:14px 16px;overflow:auto}\n  .sheet-ft{display:flex;align-items:center;gap:8px;padding:11px 16px;border-top:1px solid var(--line);background:var(--panel2)}\n  .opt-lbl{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600}\n  .opt-sub{font-size:11.5px;color:var(--mut);margin:4px 0 10px;line-height:1.5}\n  .opt-row{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-bottom:8px;cursor:pointer;background:var(--panel)}\n  .opt-row:hover{background:var(--panel2)}\n  .opt-row.on{border-color:var(--acc);background:#eaf3ff}\n  .opt-row:has(input:disabled){opacity:.5;cursor:not-allowed}\n  .opt-row input[type=radio]{margin:2px 0 0;accent-color:var(--acc);flex:0 0 auto}\n  .opt-t{font-size:12.5px;font-weight:600;display:flex;align-items:center;gap:6px;flex-wrap:wrap}\n  .opt-d{font-size:11.5px;color:var(--mut);margin-top:3px;line-height:1.55}\n  .opt-inline{font-size:11.5px;color:var(--mut);margin-top:9px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}\n  .opt-inline.off{opacity:.4;pointer-events:none}\n  .opt-note{border:1px solid var(--line);background:var(--panel2);border-radius:10px;padding:9px 11px;font-size:11.5px;color:var(--mut);line-height:1.6;margin-top:12px}\n  .opt-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:2px}\n  .opt-fld{display:flex;flex-direction:column;gap:5px;border:1px solid var(--line);border-radius:9px;padding:8px 10px;background:var(--panel2)}\n  .opt-fld span{font-size:11px;color:var(--mut);font-weight:600;line-height:1.35}\n  .opt-fld i{font-style:normal;font-weight:400;opacity:.72}\n  .opt-fld input{width:100%;background:var(--panel)}\n  .opt-err{margin-top:9px;font-size:11.5px;color:var(--closing);font-weight:600}\n  .opt-hr{height:1px;background:var(--line);margin:17px 0 0}\n  .shareurl{margin-top:9px;padding:9px 10px;border-radius:8px;background:var(--panel);border:1px solid var(--acc);\n    font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--ink);word-break:break-all;user-select:all}\n`,\n  render(d2){\n    return `<div class=\"simroot\"><header id=\"topHeader\" class=\"hidden\">\n  <h1>SEC | Natura Geomarketing</h1>\n  <span class=\"pill live\" id=\"liveState\" role=\"button\" tabindex=\"0\" title=\"Clique pra atualizar o snapshot para o mais recente\">● iniciando</span>\n  <span class=\"status\" id=\"ds\"></span>\n  <div class=\"mac-spacer\"></div>\n  <select id=\"sessionPick\" style=\"min-width:200px\"></select>\n  <button class=\"ghost\" id=\"renameSession\" title=\"Renomear esta sessão\">✎</button>\n  <button class=\"ghost\" id=\"openOptions\" title=\"Configurações desta sessão\">⚙ Opções</button>\n  <button class=\"act\" id=\"saveSession\">Salvar sessão</button>\n  <button class=\"ghost\" id=\"dupSession\" title=\"Cria uma cópia independente desta sessão — a original fica intacta e a cópia passa a ser a sessão ativa\">⧉ Duplicar</button>\n  <button class=\"ghost\" id=\"openShare\" title=\"Publicar uma cópia congelada desta sessão em modo leitura\">⇪ Compartilhar</button>\n  <span class=\"status\" id=\"sessStatus\"></span>\n</header>\n\n<div class=\"rail hidden\" id=\"timelineRail\">\n  <div id=\"movementChips\"></div>\n  <div id=\"addMovementGroup\">\n    <button class=\"act\" id=\"openMove\">Adicionar movimentação</button>\n  </div>\n</div>\n\n<div class=\"card\" id=\"confirmCard\" style=\"margin:14px 20px\">\n  <h2 style=\"margin:0 0 9px\">Confirmar snapshot antes de simular</h2>\n  <div class=\"note\" id=\"confirmBody\">congelando snapshot…</div>\n  <button class=\"act\" id=\"confirmSnap\" style=\"margin-top:10px\">Confirmar snapshot ▶ ir pra simulação</button>\n  <div class=\"note\" style=\"margin-top:16px\">ou continue uma sessão salva:</div>\n  <select id=\"confirmSessionPick\" style=\"min-width:260px;margin-top:4px\"></select>\n  <div class=\"status\" id=\"confirmStatus\" style=\"margin-top:8px\"></div>\n</div>\n\n<div class=\"wrap hidden\" id=\"mainWrap\">\n  <div>\n    <div class=\"card\">\n      <div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:9px\">\n        <div class=\"toggle\" id=\"viewToggle\" title=\"ASIS = estrutura do setor antes desta etapa (já considerando etapas anteriores) · TOBE = após a redistribuição desta etapa\">\n          <button data-v=\"asis\">◧ ASIS (antes desta etapa)</button>\n          <button data-v=\"tobe\" class=\"on\">◨ TOBE (simulação)</button>\n        </div>\n      </div>\n      <div class=\"map-wrap\">\n        <svg id=\"map\" viewBox=\"0 0 700 500\" preserveAspectRatio=\"xMidYMid meet\"></svg>\n        <button class=\"osm-panel-icon hidden\" id=\"osmPanelIcon\" title=\"Mostrar camadas\">\n          <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n            <path d=\"M12 2 2 7l10 5 10-5-10-5Z\"/><path d=\"m2 17 10 5 10-5\"/><path d=\"m2 12 10 5 10-5\"/>\n          </svg>\n        </button>\n        <div class=\"osm-panel\" id=\"osmPanel\" title=\"Camadas exibidas no mapa, limitadas à área desta etapa\">\n          <div class=\"osm-panel-hd\">Camadas<button class=\"sheet-x osm-panel-x\" id=\"osmPanelX\" title=\"Recolher\">✕</button></div>\n          <div class=\"osm-group-hd\">Navegação</div>\n          <label class=\"osm-row\" title=\"Marca liga todos os níveis viários de uma vez; depois de carregado, esconda um nível por vez abaixo\"><input type=\"checkbox\" id=\"osmRodovias\"><span>Rodovias</span></label>\n          <label class=\"osm-row osm-sub\" title=\"highway=motorway\"><input type=\"checkbox\" id=\"osmMotorway\"><span>Rodovia expressa</span></label>\n          <label class=\"osm-row osm-sub\" title=\"highway=trunk\"><input type=\"checkbox\" id=\"osmTrunk\"><span>Via arterial</span></label>\n          <label class=\"osm-row osm-sub\" title=\"highway=primary\"><input type=\"checkbox\" id=\"osmPrimary\"><span>Rodovia principal</span></label>\n          <label class=\"osm-row osm-sub\" title=\"highway=secondary\"><input type=\"checkbox\" id=\"osmSecondary\"><span>Rodovia secundária</span></label>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"osmRivers\"><span>Rios</span></label>\n          <div class=\"osm-group-hd sep\">Outros</div>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"osmAirports\"><span>Aeroportos</span></label>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"muniOutline\"><span id=\"muniOutlineLbl\">Contorno de municípios</span></label>\n          <div class=\"osm-group-hd sep\">Legendas</div>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"legendMuni\"><span id=\"legendMuniLbl\">Nome de municípios</span></label>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"legendCtx\"><span>Código de setores adjacentes</span></label>\n          <div class=\"osm-status\" id=\"osmStatus\"></div>\n        </div>\n        <div class=\"zoom-pill\" id=\"mapZoom\">\n          <button type=\"button\" id=\"mapZoomOut\" title=\"Diminuir zoom\">−</button>\n          <span class=\"zoom-div\"></span>\n          <button type=\"button\" id=\"mapZoomIn\" title=\"Aumentar zoom\">+</button>\n        </div>\n      </div>\n      <div class=\"legend\" id=\"legend\"></div>\n      <div class=\"note\" id=\"mapLegendNote\">Coloridos = municípios do setor fechado nesta etapa (cor = destino). Ao redor, cada área é um <b>setor vizinho inteiro</b> com o código no meio — não municípios soltos: cor = setor destino, cinza = contexto, cinza tracejado = setor já fechado numa etapa anterior. Shapes reais IBGE/tbrugz.</div>\n      <div class=\"note\" id=\"mapIgnoredNote\"></div>\n    </div>\n\n    <div class=\"card\">\n      <h2 id=\"leadersTitle\">Líderes — destino e CBs deslocadas (nesta etapa)</h2>\n      <div class=\"note\" id=\"leadersNote\" style=\"margin:-4px 0 9px\"><b>Home</b> = setor que ficou com a maioria das CBs da líder. <b style=\"color:var(--closing)\">Deslocadas</b> = CBs dela que caíram noutro setor.</div>\n      <div class=\"scroll grow\"><table id=\"leaders\"></table></div>\n    </div>\n\n    <div class=\"card hidden\" id=\"leadersXferBlock\">\n      <h2 id=\"leadersXferTitle\">Líder da transferência manual</h2>\n      <div class=\"scroll grow\"><table id=\"leadersXfer\"></table></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"tabs\">\n        <button data-tab=\"matrix\" class=\"on\">Matriz ASIS</button>\n        <button data-tab=\"tobe\">Matriz TOBE</button>\n        <button data-tab=\"report\">Relatório de impacto (etapa)</button>\n        <button data-tab=\"cumulative\">Impacto acumulado (timeline)</button>\n        <button data-tab=\"sql\">Console SQL</button>\n      </div>\n      <div id=\"tab-matrix\"><div class=\"scroll grow\"><table id=\"matrix\"></table></div>\n        <div id=\"matrixXferBlock\" class=\"hidden\" style=\"margin-top:14px\">\n          <h2 id=\"matrixXferTitle\">Município transferido</h2>\n          <div class=\"scroll grow\"><table id=\"matrixXfer\"></table></div>\n        </div>\n      </div>\n      <div id=\"tab-tobe\" class=\"hidden\"><div class=\"scroll grow\"><table id=\"matrixTobe\"></table></div>\n        <div id=\"matrixTobeXferBlock\" class=\"hidden\" style=\"margin-top:14px\">\n          <h2 id=\"matrixTobeXferTitle\">Município transferido</h2>\n          <div class=\"scroll grow\"><table id=\"matrixTobeXfer\"></table></div>\n        </div>\n        <div class=\"note\" id=\"tobeNote\"></div></div>\n      <div id=\"tab-report\" class=\"hidden\"></div>\n      <div id=\"tab-cumulative\" class=\"hidden\"></div>\n      <div id=\"tab-sql\" class=\"hidden\">\n        <textarea class=\"sqlbox\" id=\"sqlIn\">SELECT DS_MUNICIPIO, COUNT(DISTINCT CD_CONSULTORA) qtd FROM product.perfil_cb.crm_perfil_cb_diaria_historico WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=3504 GROUP BY DS_MUNICIPIO ORDER BY qtd DESC</textarea>\n        <div class=\"toolbar\" style=\"margin:8px 0\"><button class=\"act\" id=\"runSql\">▶ Rodar</button><span class=\"status\" id=\"sqlStatus\"></span></div>\n        <div class=\"note\" style=\"margin:-4px 0 8px\">Roda contra o snapshot congelado desta sessão (não \"ao vivo\") — mesma coerência da timeline.</div>\n        <div class=\"scroll\"><table id=\"sqlOut\"></table></div>\n      </div>\n    </div>\n  </div>\n\n  <div>\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"margin-bottom:11px\">\n        <button class=\"ghost\" id=\"optim\" title=\"testa alocações que mantêm cada setor conectado e escolhe a que equilibra a carga entre setores + minimiza a quebra (evita empilhar tudo num setor)\">★ Modelo ÓTIMO (equilíbrio + quebra)</button>\n        <button class=\"ghost\" id=\"autoTobe\" title=\"preenche cada município pelo setor vizinho mais próximo (geografia)\">TOBE por adjacência</button>\n        <span class=\"badge\" id=\"optBadge\"></span>\n      </div>\n      <div class=\"note\" style=\"margin:-4px 0 10px\"><b style=\"color:var(--ink)\">★ ÓTIMO</b> = equilibra a carga entre os setores <b>e</b> minimiza a quebra (mantém o território conectado; não empilha tudo num setor). <b style=\"color:var(--ink)\">TOBE</b> = sugestão por vizinhança. Editar esta etapa recalcula automaticamente as etapas seguintes da timeline.</div>\n      <div class=\"note\" id=\"ruleNote\" style=\"margin:-6px 0 11px;padding:7px 9px;background:var(--panel2);border:1px solid var(--line);border-radius:8px\"></div>\n      <div class=\"kpis\" id=\"kpis\"></div>\n      <div id=\"contig\"></div>\n      <div id=\"spanRule\"></div>\n      <h2 id=\"spansHd\">Span por setor (CBs) — base + entrantes</h2>\n      <div id=\"spans\"></div>\n      <div id=\"policySection\">\n      <div style=\"display:flex;justify-content:space-between;align-items:center;margin:14px 0 7px\">\n        <h2 style=\"margin:0\">CBs deslocadas</h2>\n        <div class=\"toggle\" id=\"policy\">\n          <button data-p=\"fora\" class=\"on\">= fora do setor</button>\n          <button data-p=\"quebra\">= quebra de relação</button>\n        </div>\n      </div>\n      <div class=\"note\" id=\"policyNote\"></div>\n      </div>\n    </div>\n\n    <div class=\"card\">\n      <h2 id=\"muniListTitle\">Municípios → setor destino</h2>\n      <div class=\"note\" id=\"muniListNote\" style=\"margin:-4px 0 9px\">Clique no <b>código do setor</b> pra mandar o município <b>inteiro</b> pra lá (regra de negócio). A cor acompanha o mapa.</div>\n      <div id=\"muniList\"></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"justify-content:space-between\">\n        <h2 style=\"margin:0\">Plano de migração (etapa selecionada)</h2>\n        <div><button class=\"ghost\" id=\"copyPlan\">Copiar</button> <button class=\"ghost\" id=\"csvPlan\">CSV</button></div>\n      </div>\n      <div id=\"plan\" class=\"note\"></div>\n    </div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"shareBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"shareTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"shareTitle\">Compartilhar sessão</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"shareX\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\" id=\"shareBody\"></div>\n    <div class=\"sheet-ft\">\n      <span class=\"status\" id=\"shareStatus\"></span>\n      <div class=\"mac-spacer\"></div>\n      <button class=\"ghost hidden\" id=\"shareCopy\">Copiar link</button>\n      <button class=\"ghost hidden\" id=\"shareOpen\">Abrir</button>\n      <button class=\"ghost\" id=\"shareCancel\">Cancelar</button>\n      <button class=\"act\" id=\"shareGo\">Compartilhar</button>\n    </div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"optionsBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"optTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"optTitle\">Opções da sessão</h3><span class=\"badge\" id=\"optSessName\"></span><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"optClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-lbl\">Pré-requisitos do destino — tamanho em CBs</div>\n      <div class=\"opt-sub\">Vale pro tamanho de cada unidade receptora <b>depois</b> da redistribuição (base + entrantes) — uma escala por nível de fechamento. Em branco = desligado.</div>\n      <div class=\"opt-t\" style=\"margin-top:2px\">Setor</div>\n      <div class=\"opt-grid\">\n        <label class=\"opt-fld\"><span>Span alvo <i>(tamanho ideal)</i></span><input type=\"number\" id=\"optTargetSpan_setor\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"livre\"></label>\n        <label class=\"opt-fld\"><span>Span máximo <i>(nenhum setor acima)</i></span><input type=\"number\" id=\"optMaxSpan_setor\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n        <label class=\"opt-fld\"><span>Span mínimo <i>(nenhum setor abaixo)</i></span><input type=\"number\" id=\"optMinSpan_setor\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n      </div>\n      <div class=\"opt-t\" style=\"margin-top:12px\">GV</div>\n      <div class=\"opt-grid\">\n        <label class=\"opt-fld\"><span>Span alvo <i>(tamanho ideal)</i></span><input type=\"number\" id=\"optTargetSpan_gv\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"livre\"></label>\n        <label class=\"opt-fld\"><span>Span máximo <i>(nenhuma GV acima)</i></span><input type=\"number\" id=\"optMaxSpan_gv\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n        <label class=\"opt-fld\"><span>Span mínimo <i>(nenhuma GV abaixo)</i></span><input type=\"number\" id=\"optMinSpan_gv\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n      </div>\n      <div class=\"opt-hr\"></div>\n      <div class=\"opt-lbl\" style=\"margin-top:14px\">Conectividade de região</div>\n      <div class=\"opt-sub\">Vale para fechamento de Setor, GV e Mercado. Controla o que acontece quando um clique no mapa deixaria alguma unidade (município, setor ou GV, conforme o nível) com o território partido em pedaços desconectados — o aviso <b style=\"color:var(--closing)\">⚠ Território desconectado</b> abaixo do mapa.</div>\n      <label class=\"opt-row\"><input type=\"radio\" name=\"forceContig\" value=\"0\"><div><div class=\"opt-t\">Livre</div><div class=\"opt-d\">Clicar avança pro próximo destino da lista, mesmo que isso deixe o território de alguma unidade desconectado. Resolva depois com ★ ÓTIMO ou reassociando manualmente no mapa.</div></div></label>\n      <label class=\"opt-row\"><input type=\"radio\" name=\"forceContig\" value=\"1\"><div><div class=\"opt-t\">Forçar conectividade de território <i style=\"font-weight:400;color:var(--mut)\">(padrão)</i></div><div class=\"opt-d\">Clicar nunca deixa uma unidade desconectada: pula o(s) destino(s) da lista que quebrariam a conectividade e escolhe o próximo que mantém tudo conectado. Se nenhum destino for seguro, o clique não muda nada.</div></div></label>\n      <div class=\"opt-err hidden\" id=\"optSpanErr\"></div>\n      <div class=\"opt-note\" id=\"optPreview\"></div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"optStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"optCancel\">Cancelar</button><button class=\"act\" id=\"optApply\">Aplicar</button></div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"moveBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"moveTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"moveTitle\">Adicionar movimentação</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"moveClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-lbl\">Tipo de movimentação</div>\n      <div class=\"opt-sub\" id=\"moveLockNote\">Escolha o nível a fechar. Você pode fechar setores à vontade até fechar uma GV; aí só GV (ou mercado) daqui pra frente. Fechar um mercado trava a sessão em mercado.</div>\n      <label class=\"opt-row\" data-lvl=\"setor\"><input type=\"radio\" name=\"moveLevel\" value=\"setor\"><div><div class=\"opt-t\">Fechar Setor</div><div class=\"opt-d\">Redistribui os municípios do setor entre setores vizinhos da mesma GV.</div></div></label>\n      <label class=\"opt-row\" data-lvl=\"gv\"><input type=\"radio\" name=\"moveLevel\" value=\"gv\"><div><div class=\"opt-t\">Fechar GV</div><div class=\"opt-d\">Redistribui os setores (com municípios e CBs) da GV entre GVs vizinhas do mesmo mercado.</div></div></label>\n      <label class=\"opt-row\" data-lvl=\"mercado\"><input type=\"radio\" name=\"moveLevel\" value=\"mercado\"><div><div class=\"opt-t\">Fechar Mercado</div><div class=\"opt-d\">Redistribui as GVs (com setores, municípios e CBs) do mercado entre mercados vizinhos.</div></div></label>\n      <div class=\"opt-hr\"></div>\n      <div class=\"opt-lbl\" style=\"margin-top:15px\" id=\"moveCodeLbl\">Setor a fechar</div>\n      <div class=\"opt-inline\" id=\"moveCodeRow\">\n        <input id=\"moveCode\" list=\"moveCodeList\" placeholder=\"código\" autocomplete=\"off\" style=\"min-width:150px\">\n        <datalist id=\"moveCodeList\"></datalist>\n        <button class=\"ghost\" id=\"moveRand\" title=\"Aleatório\">🎲</button>\n      </div>\n      <div class=\"opt-err hidden\" id=\"moveErr\"></div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"moveStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"moveCancel\">Cancelar</button><button class=\"act\" id=\"moveGo\">Fechar ▶ (nova etapa)</button></div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"lockedBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"lockedTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"lockedTitle\">Sessão travada num snapshot antigo</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"lockedClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-sub\" id=\"lockedBody\">Esta sessão foi carregada a partir dos dados já calculados e congelados quando ela foi salva — nenhuma consulta rodou ainda. Atualizar o snapshot agora recalcula a timeline inteira contra os dados mais recentes, o que pode mudar os resultados desta simulação (destinos, spans, CBs deslocadas etc).</div>\n      <div class=\"opt-note\">Depois de atualizar, a sessão deixa de estar travada e você pode repetir a ação que tentou fazer.</div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"lockedStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"lockedCancel\">Cancelar</button><button class=\"act\" id=\"lockedGo\">Atualizar snapshot</button></div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"discardedBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"discardedTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"discardedTitle\">Simulações descartadas</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"discardedClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"note\" style=\"margin-bottom:8px\">Combinações de município(s) testadas durante a busca do fix, que não foram escolhidas.</div>\n      <div class=\"scroll grow\"><table><thead><tr><th>Município(s)</th><th>CBs</th><th>Motivo</th></tr></thead><tbody id=\"discardedBd\"></tbody></table></div>\n    </div>\n    <div class=\"sheet-ft\"><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"discardedCancel\">Fechar</button></div>\n  </div>\n</div>\n\n\n\n<div class=\"tip\" id=\"tip\"></div>\n<span id=\"ready\" style=\"display:none\"></span></div>`;\n  },\n  mounted(d2, root){\n\nconst $=s=>root.querySelector(s);\nconst fmt=n=>n==null?'–':Math.round(+n).toLocaleString('pt-BR');\nconst money=n=>{n=+n||0;return n>=1e6?'R$'+(n/1e6).toFixed(1)+'M':n>=1e3?'R$'+(n/1e3).toFixed(0)+'k':'R$'+Math.round(n)};\nconst norm=s=>(s||'').normalize('NFKD').replace(/[̀-ͯ]/g,'').toUpperCase().replace(/[^A-Z0-9]+/g,' ').trim();\nconst esc=s=>(s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\n// ---------- macOS-style dropdown ----------\n// A native <select>/<datalist> OPEN-LIST popup is drawn by the browser itself and cannot be\n// restyled on any engine — only the closed control can be skinned. Everything below repaints the\n// open list as a real DOM element (an actual AppKit-style popover) while the underlying\n// <select>/<input> stays the real state — same .value/.onchange/.options, every existing\n// read/write call site untouched.\nlet macGlobalCloseBound=false;\n// Panels used to be a plain child of their .macsel wrapper, positioned relative to it — fine until\n// one landed inside the movimentação modal (.sheet, overflow:hidden ⊃ .sheet-bd, overflow:auto),\n// which clipped it to the modal's own box the moment it tried to extend past it. A first fix (a\n// fixed-position portal still inside this shadow root) escaped the clipping but NOT the stacking\n// fight against the modal's own backdrop-filter layer — panels rendered behind it despite a far\n// higher z-index. macPortal() lives in document.body instead, the platform's own documented\n// pattern for any overlay a code component draws (see code-component-overlays): body content\n// stacks against the WHOLE page, immune to any shadow-DOM/backdrop-filter compositing quirk, and\n// isn't reachable by this shadow tree's scoped `css`, so its rules are injected as a plain <style>\n// right here instead — colours hardcoded since var(--ink) etc. only resolve inside :host.\nfunction macPortal(){\n  let p=document.getElementById('macPortal');\n  if(p) return p;\n  p=document.createElement('div'); p.id='macPortal';\n  // isolation:isolate + its own z-index, not just relying on DOM order/appendChild-puts-it-last:\n  // without this, #macPortal (z-index:auto) never forms its own stacking context, so its child\n  // .macsel-panel's z-index couldn't be trusted to outrank whatever ELSE the host page already has\n  // sitting at the same fixed/root level (verified empirically — the panel rendered fully invisible,\n  // behind an unrelated body-level layer belonging to the app's own chrome, despite a much higher\n  // z-index on the panel itself, until the portal was made its own isolated context here).\n  p.style.cssText='position:fixed;left:0;top:0;width:0;height:0;z-index:2147483000;isolation:isolate;';\n  const style=document.createElement('style');\n  style.textContent=`\n    #macPortal .macsel-panel{position:fixed;min-width:160px;max-width:min(420px,90vw);max-height:280px;overflow:auto;z-index:2147483000;\n      background:rgba(255,255,255,.96);backdrop-filter:blur(30px) saturate(180%);-webkit-backdrop-filter:blur(30px) saturate(180%);\n      border:1px solid rgba(0,0,0,.08);border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.20);padding:5px;\n      font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;color:#1c1c1e;-webkit-font-smoothing:antialiased}\n    #macPortal .macsel-panel.hidden{display:none}\n    #macPortal .macsel-opt{padding:6px 9px;border-radius:6px;cursor:pointer;font-size:13px;white-space:nowrap}\n    #macPortal .macsel-opt:hover{background:rgba(0,0,0,.05)}\n    #macPortal .macsel-opt.sel{background:#0064e1;color:#fff}\n    #macPortal .macsel-empty{padding:6px 9px;font-size:12px;color:#6e6e73}\n  `;\n  p.appendChild(style);\n  document.body.appendChild(p);\n  if(d2.onUnmount) d2.onUnmount(()=>p.remove());\n  return p;\n}\n// #loader lived inside .simroot (position:relative;height:100%;overflow:auto) as position:absolute;inset:0 —\n// anchored to .simroot's own (one-screenful) padding box, not to its taller scrollable CONTENT, so it only\n// ever dimmed the top screenful and never moved with scroll, leaving real content exposed below it once the\n// user scrolled past that first screen. Same class of bug macPortal() above already fixed for select panels:\n// portal it to document.body as a true position:fixed;inset:0 overlay, immune to any shadow-DOM containing\n// block or .simroot's own scrolling.\nfunction loaderPortal(){\n  let p=document.getElementById('loaderPortal');\n  if(p) return p;\n  p=document.createElement('div'); p.id='loaderPortal'; p.className='hidden';\n  p.style.cssText='position:fixed;inset:0;z-index:2147483000;';\n  const style=document.createElement('style');\n  style.textContent=`\n    #loaderPortal{background:rgba(245,245,247,.85);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;color:#007aff;font-weight:600;text-align:center;padding:20px}\n    #loaderPortal.hidden{display:none}\n  `;\n  document.body.appendChild(style);\n  document.body.appendChild(p);\n  if(d2.onUnmount) d2.onUnmount(()=>{ p.remove(); style.remove(); });\n  return p;\n}\nfunction ensureMacGlobalClose(){\n  if(macGlobalCloseBound) return; macGlobalCloseBound=true;\n  // e.target is retargeted to the shadow HOST for a listener bound on document for any click that\n  // originated inside the shadow tree (the trigger buttons/inputs still live there). composedPath()\n  // carries the real, un-retargeted ancestor chain through the shadow boundary, so checking it\n  // instead is what lets the click that OPENS a panel not immediately close it again on the very\n  // same tick. Panels live in macPortal() (document.body), detached from their trigger's DOM\n  // subtree entirely — panel and button are in different documents-worth of nesting, not\n  // parent/child — so p.closest('.macsel') can never answer \"was this click on the trigger\"; each\n  // panel keeps its trigger on ._macTrigger at creation time instead, checked against the same path.\n  document.addEventListener('click',e=>{\n    const path=e.composedPath();\n    document.querySelectorAll('#macPortal .macsel-panel').forEach(p=>{ if(!path.includes(p)&&!(p._macTrigger&&path.includes(p._macTrigger))) p.classList.add('hidden'); });\n  });\n}\nfunction closeAllMacSelects(){ document.querySelectorAll('#macPortal .macsel-panel').forEach(p=>p.classList.add('hidden')); }\nfunction positionMacPanel(trigger,panel){\n  const r=trigger.getBoundingClientRect();\n  const up=window.innerHeight-r.bottom<280 && r.top>280;\n  panel.classList.toggle('up', up);\n  panel.style.left=Math.round(r.left)+'px';\n  panel.style.minWidth=Math.round(r.width)+'px';\n  if(up){ panel.style.bottom=Math.round(window.innerHeight-r.top+4)+'px'; panel.style.top=''; }\n  else{ panel.style.top=Math.round(r.bottom+4)+'px'; panel.style.bottom=''; }\n}\nfunction buildMacPanel(sel,panel){\n  const opts=[...sel.options];\n  panel.innerHTML=opts.length?'':'<div class=\"macsel-empty\">nenhuma opção</div>';\n  opts.forEach(o=>{\n    const row=document.createElement('div'); row.className='macsel-opt'+(o.selected?' sel':''); row.setAttribute('role','option');\n    row.textContent=o.textContent;\n    row.onmousedown=e=>{ e.preventDefault(); sel.value=o.value; panel.classList.add('hidden'); syncMacSelect(sel); sel.dispatchEvent(new Event('change')); };\n    panel.appendChild(row);\n  });\n}\nfunction syncMacSelect(sel){\n  const wrap=sel.closest('.macsel'); if(!wrap) return;\n  const lbl=wrap.querySelector('.macsel-lbl'); if(!lbl) return;\n  const cur=sel.options[sel.selectedIndex];\n  lbl.textContent=cur?cur.textContent:'';\n}\nfunction initMacSelect(sel){\n  if(!sel) return;\n  if(sel.dataset.macInit){ syncMacSelect(sel); return; }\n  sel.dataset.macInit='1';\n  const wrap=document.createElement('div'); wrap.className='macsel';\n  wrap.style.cssText=sel.getAttribute('style')||'';\n  sel.parentNode.insertBefore(wrap,sel); wrap.appendChild(sel);\n  sel.style.display='none';\n  const btn=document.createElement('button'); btn.type='button'; btn.className='macsel-btn';\n  btn.innerHTML='<span class=\"macsel-lbl\"></span><span class=\"macsel-car\">▾</span>';\n  const panel=document.createElement('div'); panel.className='macsel-panel hidden'; panel.setAttribute('role','listbox');\n  panel._macTrigger=btn;\n  wrap.appendChild(btn); macPortal().appendChild(panel);\n  btn.onclick=()=>{\n    const willOpen=panel.classList.contains('hidden');\n    closeAllMacSelects();\n    if(!willOpen) return;\n    buildMacPanel(sel,panel); panel.classList.remove('hidden'); positionMacPanel(btn,panel);\n  };\n  ensureMacGlobalClose();\n  syncMacSelect(sel);\n}\nfunction initMacCombo(inp,dl){\n  if(!inp||!dl||inp.dataset.macInit) return;\n  inp.dataset.macInit='1';\n  inp.removeAttribute('list');\n  const wrap=document.createElement('div'); wrap.className='macsel'; wrap.style.display='inline-block';\n  inp.parentNode.insertBefore(wrap,inp); wrap.appendChild(inp);\n  const panel=document.createElement('div'); panel.className='macsel-panel hidden'; panel.setAttribute('role','listbox');\n  panel._macTrigger=inp;\n  macPortal().appendChild(panel);\n  const buildPanel=()=>{\n    const qv=norm(inp.value);\n    const opts=[...dl.options].filter(o=>!qv||norm(o.value+' '+o.textContent).indexOf(qv)>=0);\n    // Options come from an async load (ensureDominantes) that can take a moment on a cold cache —\n    // an empty <datalist> mid-load looked identical to a genuinely empty result set, so opening the\n    // combo right after the modal appeared always read \"nenhum resultado\" instead of \"ainda\n    // carregando\". dl.dataset.loading (set by loadMoveOptions) is what tells the two apart.\n    panel.innerHTML=opts.length?'':(dl.dataset.loading==='1'?'<div class=\"macsel-empty\">carregando…</div>':'<div class=\"macsel-empty\">nenhum resultado</div>');\n    opts.forEach(o=>{\n      const row=document.createElement('div'); row.className='macsel-opt'+(o.value===inp.value?' sel':'');\n      row.textContent=o.textContent;\n      // dispatchEvent runs synchronously — firing it BEFORE hiding meant our own 'input' listener\n      // (openFiltered) reopened the panel on the same tick, right after this handler had just\n      // closed it: picking a row looked like it never closed. Hiding last, after the dispatch has\n      // already run and reopened it, is what actually wins.\n      row.onmousedown=e=>{ e.preventDefault(); inp.value=o.value; inp.dispatchEvent(new Event('input')); panel.classList.add('hidden'); };\n      panel.appendChild(row);\n    });\n  };\n  const openFiltered=()=>{\n    // Defensive: nothing else in this file re-adds `list`, but this is the one control whose panel\n    // reopens on every keystroke — cheap insurance against the native popup ever winning a race\n    // against the attribute removal that runs once at init, above.\n    inp.removeAttribute('list');\n    buildPanel();\n    panel.classList.remove('hidden'); positionMacPanel(inp,panel);\n  };\n  inp.addEventListener('focus',openFiltered);\n  inp.addEventListener('input',openFiltered);\n  ensureMacGlobalClose();\n  // Lets an async option load (loadMoveOptions) push a refreshed panel once it lands, WITHOUT\n  // force-opening a panel the user never touched — only rebuilds when the panel is already visible.\n  inp._macRefresh=()=>{ if(!panel.classList.contains('hidden')) buildPanel(); };\n}\nconst blankM=()=>({cbs:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0,ativas:0});\n// ---------- município identity ----------\n// Brazil has many municipalities sharing a name (Bom Jesus alone exists in PI, RN, RS, SC, PB, GO).\n// A stage loads the dominant UF PLUS every bordering UF, so those collisions land in the SAME\n// feature set routinely: keyed by name alone the last shape loaded won, the map could draw a\n// município from the wrong state, buildAdj merged two unrelated municipalities into one node, and\n// GROUP BY DS_MUNICIPIO summed two real municipalities into one row. The key is NAME/UF everywhere\n// — SQL rows, GeoJSON features, the dominantes base, assignments, persisted sessions. norm() maps\n// every non-alphanumeric to a space, so '/' can never occur inside a name and the split is exact.\n// A missing UF degrades to a bare NAME key rather than inventing one.\n// A handful of municípios carry two spellings in the wild — an older one still used internally (CRM\n// export, this dominantes CSV) and the current official IBGE spelling used by the geodata mirror.\n// Left alone, the two spellings key to different places: the shape draws with no dominance entry\n// (\"setor não encontrado\") even though the base has it under the other spelling. Canonicalized here,\n// once, to whichever spelling the CRM base uses — so display stays consistent with every other\n// município name in this app, which comes from the CRM, never from IBGE.\n// Aliases base (embutidos, sempre valem mesmo se a tabela abaixo estiver vazia ou fora do ar).\n// A tabela Data2 (MUNI_ALIAS_DS) deixa o time acrescentar/corrigir grafias sem precisar mexer\n// neste componente — carregada uma vez no boot (loadMuniAliases) e mesclada por cima destes.\nlet MUNI_ALIAS={BRASOPOLIS:'BRAZOPOLIS','DONA EUSEBIA':'DONA EUZEBIA'};\nconst MUNI_ALIAS_DS='gb9Mu-5_9AcCDK-myWwSyDataSet';\n// This table's whole point is letting someone WITHOUT code access fix a município name mismatch —\n// a failure that only ever shows up in devtools defeats that. It has in fact sat silently broken\n// before (a policy with no linked auth strategy, fixed separately) with zero visible signal anywhere.\n// Kept module-scope so drawMap's mapIgnoredNote (below) can render it next to the other \"why is this\n// município missing/wrong\" notes, not just log it.\nlet MUNI_ALIAS_ERROR=null;\nasync function loadMuniAliases(){\n  // A freshly policy-gated DataSet's backend definition can take a moment to finish registering\n  // after the policy lands — d2.data() returns null until it does (\"Definition not found:\n  // UserApplicationLayer <ref>\" in the console — the SAME warning fires once for the Databricks\n  // rush listener too, which plainly works, because THAT one gets called repeatedly over a session\n  // and gets more than one shot at winning the race). This function is a single boot-time call with\n  // no natural second attempt, so it needs its own bounded retry to get the same fair shot.\n  let ds=null;\n  for(let attempt=0; attempt<5 && !ds; attempt++){\n    if(attempt) await new Promise(r=>setTimeout(r,1200));\n    ds=(d2.data('muniAliasDataset')||d2.data(MUNI_ALIAS_DS));\n  }\n  if(!ds){ MUNI_ALIAS_ERROR='dataset de correções não resolvido após 5 tentativas (muniAliasDataset/'+MUNI_ALIAS_DS+' indisponível nesta instância)'; console.warn('[muniAlias] '+MUNI_ALIAS_ERROR); return; }\n  try{\n    await ds.refresh();\n    const recs=await ds.loadAll();\n    recs.forEach(r=>{ const de=norm(r.de), pa=norm(r.para); if(de&&pa) MUNI_ALIAS[de]=pa; });\n    MUNI_ALIAS_ERROR=null;\n  }catch(e){\n    // tabela de aliases é um complemento opcional — falha aqui não trava o boot, mas também não pode\n    // desaparecer sem rastro: guardada pra aparecer no mapa em vez de só no console, que ninguém\n    // sem devtools abertos jamais vai olhar.\n    MUNI_ALIAS_ERROR=(e&&e.message)||'falha desconhecida ao carregar';\n    console.warn('[muniAlias] falha ao carregar tabela de correções de grafia:',e);\n  }\n}\nconst muniN=s=>{const n=norm(s);return MUNI_ALIAS[n]||n;};\nconst mkey=(name,uf)=>{const n=muniN(name),u=String(uf==null?'':uf).trim().toUpperCase();return u?n+'/'+u:n;};\nconst mName=k=>{const s=String(k==null?'':k),i=s.lastIndexOf('/');return i<0?s:s.slice(0,i);};\nconst mUf=k=>{const s=String(k==null?'':k),i=s.lastIndexOf('/');return i<0?'':s.slice(i+1);};\nconst mDisp=k=>{const u=mUf(k);return u?mName(k)+' ('+u+')':mName(k);};\n// The base CSV is the ONLY source of a município's UF now, so its column is accepted in whatever\n// shape the base is maintained in: sigla, IBGE numeric code, or full state name. Anything that does\n// not resolve to a real sigla is treated as UNKNOWN rather than passed through — a bogus UF would key\n// a município to a shape that does not exist and drop it off the map without a word.\nconst UF_OF_CODE={}; Object.keys(UF_CODE).forEach(k=>{ UF_OF_CODE[UF_CODE[k]]=k; });\nconst UF_OF_NAME={ACRE:'AC',ALAGOAS:'AL',AMAPA:'AP',AMAZONAS:'AM',BAHIA:'BA',CEARA:'CE','DISTRITO FEDERAL':'DF','ESPIRITO SANTO':'ES',GOIAS:'GO',MARANHAO:'MA','MATO GROSSO':'MT','MATO GROSSO DO SUL':'MS','MINAS GERAIS':'MG',PARA:'PA',PARAIBA:'PB',PARANA:'PR',PERNAMBUCO:'PE',PIAUI:'PI','RIO DE JANEIRO':'RJ','RIO GRANDE DO NORTE':'RN','RIO GRANDE DO SUL':'RS',RONDONIA:'RO',RORAIMA:'RR','SANTA CATARINA':'SC','SAO PAULO':'SP',SERGIPE:'SE',TOCANTINS:'TO'};\nconst ufNorm=v=>{const s=norm(v); if(!s) return '';\n  if(UF_CODE[s]) return s;\n  if(UF_OF_CODE[s]) return UF_OF_CODE[s];\n  return UF_OF_NAME[s]||'';};\nconst loader=(on,t)=>{const l=loaderPortal();l.classList.toggle('hidden',!on);if(t)l.textContent=t;const sr=$('.simroot');if(sr)sr.style.overflow=on?'hidden':'';};\nconst setError=(msg)=>{ const t=$('#ds'); if(t){t.textContent=msg;t.classList.add('err');} const c=$('#confirmStatus'); if(c){c.textContent=msg;c.classList.add('err');} };\n\n// ---------- base de municípios dominantes (CSV) ----------\n// WHICH municipalities belong to a sector is a business fact, not something to infer from CB volume:\n// it comes from a maintained CSV (one row per setor x município) linked through the `dominantesCsvUrl`\n// param. Anything the closing sector has CBs in but that the base does NOT assign to it is spillover:\n// not realocated, not drawn on the map, and its CBs follow their leader. Loaded once per URL and\n// cached for the whole session; a failure is thrown, never swallowed — a silent fallback here would\n// quietly change every number on the screen.\nconst DOM={url:null,promise:null,bySetor:null,ufBySetorMuni:null,rows:0,setores:0,hasUf:false,ufCol:null,badUf:0,clashes:0,headers:null,error:null};\nfunction parseCsv(text){\n  const out=[]; let row=[],cur='',q=false;\n  const push=()=>{ row.push(cur); cur=''; };\n  const endRow=()=>{ push(); if(row.length>1||row[0]!=='') out.push(row); row=[]; };\n  for(let i=0;i<text.length;i++){ const ch=text[i];\n    if(q){ if(ch==='\"'){ if(text[i+1]==='\"'){ cur+='\"'; i++; } else q=false; } else cur+=ch; }\n    else if(ch==='\"') q=true;\n    else if(ch===',') push();\n    else if(ch==='\\n') endRow();\n    else if(ch!=='\\r') cur+=ch;\n  }\n  if(cur!==''||row.length) endRow();\n  return out;\n}\n// `dominantesCsvUrl` is declared as a FileComponentUrlReference, so the instance points at the Drive\n// FILE, not at a URL literal — the Drive is policy-gated behind the app's auth strategy and a baked\n// string would be both stale and unauthorised. What the resolver hands back varies by where it is\n// resolved (URL string / file record / bare reference), so accept every shape instead of silently\n// reading undefined and falling through to the fallback.\n// The Drive is gated by the app's AuthenticationStrategy, so this read has to travel as the LOGGED-IN\n// USER — an anonymous CDN GET is exactly what the policy is there to refuse. `<system>.data2.media` is\n// only a cache in front of the SSP: on a cache miss it 302s to `<system>.data2apis.com`, which is where\n// the policy is actually evaluated.\n// COOKIES CANNOT CARRY THAT SESSION, which is why the previous `credentials:'include'` attempt could\n// never have worked: the app runs on <app>.<system>.data2.app and the SSP on <system>.data2apis.com —\n// cross-site — and the SSP answers `Access-Control-Allow-Origin: *` with no `Allow-Credentials`, so the\n// browser rejects a credentialed CORS read before it ever leaves the page. What the SSP actually reads\n// is `Authorization: Bearer <token>`: the runtime keeps that token in localStorage under\n// `__data2-session-<systemDomain>` (`__data2-session-data2` inside the builder preview) and replays it\n// on every dataset and rush call — same token, same header, same session, so the Drive policy sees the\n// logged-in user. Preflight passes because the SSP echoes whatever header the request asks for.\nfunction d2SessionToken(sysDomain){\n  const read=k=>{ try{ return (k&&window.localStorage.getItem(k))||''; }catch(e){ return ''; } };\n  return read(sysDomain?('__data2-session-'+sysDomain):'')||read('__data2-session-data2')||'';\n}\nfunction domCsvSystemDomain(url){\n  const m=/^https?:\\/\\/([^.\\/]+)\\.data2\\.(?:media|app)\\//i.exec(url)||/^https?:\\/\\/([^.\\/]+)\\.data2apis\\.com\\//i.exec(url);\n  return m?m[1]:'';\n}\nfunction domCsvCandidates(url){\n  const out=[];\n  const isMedia=/^https?:\\/\\/[^.\\/]+\\.data2\\.media\\//i.test(url);\n  const api=isMedia?url.replace(/^(https?:\\/\\/[^.\\/]+)\\.data2\\.media\\//i,'$1.data2apis.com/'):null;\n  const tok=d2SessionToken(domCsvSystemDomain(url));\n  const auth=tok?{authorization:'Bearer '+tok}:null;\n  // Authenticated origin read first — the only candidate a GATED Drive can actually answer.\n  if(api&&auth) out.push({url:api,headers:auth,label:'api+sessão'});\n  if(!api&&auth) out.push({url:url,headers:auth,label:'origem+sessão'});\n  // Anonymous last: only ever succeeds on an UNGATED Drive — or on a stale CDN cache hit, which is NOT\n  // proof the file is public (the origin keeps answering 401 once the edge entry expires).\n  if(api) out.push({url:api,headers:null,label:'api anônimo'});\n  out.push({url:url,headers:null,label:'origem anônima'});\n  return out;\n}\nfunction domCsvUrl(){\n  const pick=v=>{\n    if(!v) return '';\n    if(typeof v==='string') return v;\n    const q=v.params||{};\n    const ref=q.reference||v.reference;\n    return q.fileUrl||q.url||v.fileUrl||v.url||(ref?DOM_MEDIA_BASE+ref:'');\n  };\n  return String(pick(d2.param('dominantesCsvUrl'))||DOM_CSV_FALLBACK||'').trim();\n}\nasync function ensureDominantes(){\n  const url=domCsvUrl();\n  if(!url) throw new Error('base de municípios dominantes não configurada — ligue o arquivo CSV no parâmetro dominantesCsvUrl da instância do componente');\n  if(DOM.url===url && DOM.bySetor) return DOM.bySetor;\n  if(DOM.url===url && DOM.promise) return DOM.promise;\n  DOM.url=url; DOM.error=null;\n  DOM.promise=(async()=>{\n    // Self-diagnosing on purpose: every candidate reports its own outcome and the message names the\n    // session it was carrying. \"Unauthorized\" alone cannot distinguish \"not logged into THIS strategy\"\n    // from \"cookie not sent cross-site\" from \"param never bound, so it tried the fallback URL\" — and\n    // guessing between those from the outside costs a round trip each time.\n    let r=null; const tried=[];\n    for(const c of domCsvCandidates(url)){\n      let res=null;\n      try{ res=await fetch(c.url,{cache:'no-store',credentials:'omit',headers:c.headers||undefined}); }\n      catch(e){ tried.push(c.label+' → rede/CORS'); continue; }\n      if(res.ok){ r=res; break; }\n      tried.push(c.label+' → '+res.status);\n    }\n    if(!r){\n      let sess='sessão desconhecida';\n      try{\n        const a=d2.auth(AUTH_REF);\n        sess=(a&&a.isAuthed)?('logado como '+((a.user&&(a.user.email||a.user.nome))||'?')):('NÃO logado na estratégia '+AUTH_REF);\n      }catch(e){ sess='estratégia '+AUTH_REF+' não resolvida'; }\n      const tk=d2SessionToken(domCsvSystemDomain(url));\n      throw new Error('base de municípios dominantes: não foi possível ler o arquivo — '+sess+' · token de sessão '+(tk?'presente':'AUSENTE no localStorage')+'. Tentativas: '+tried.join(' · '));\n    }\n    const table=parseCsv(await r.text());\n    if(table.length<2) throw new Error('base de municípios dominantes vazia');\n    const head=table[0].map(h=>norm(h));\n    const iS=head.indexOf('CD SETOR'), iM=head.indexOf('MUNICIPIO');\n    if(iS<0||iM<0) throw new Error('base de municípios dominantes sem as colunas CD_SETOR / MUNICIPIO');\n    // CD_GV is the gerência de vendas the setor answers to, and it is the authority for the hard rule\n    // that a closing sector only ever hands territory to sectors of its OWN gerência. Its absence is\n    // fatal rather than a warning: with no GV there is no way to PROVE two sectors share one, and\n    // falling through unchecked is exactly the leak this column exists to close.\n    const iG=['CD GV','CDGV','GV','CD GERENCIA','CD GERENCIA VENDAS','CD GERENCIA DE VENDAS','GERENCIA DE VENDAS','GERENCIA'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    if(iG==null) throw new Error('base de municípios dominantes sem a coluna CD_GV — a regra de gerência de vendas não pode ser aplicada. Colunas lidas: '+head.slice(0,40).join(', '));\n    // MERCADO fica ACIMA de GV na hierarquia (mercado > GV > setor) — fechamento de Mercado e a regra\n    // \"GV só fecha pra GV do mesmo mercado\" dependem dela do mesmo jeito que a regra de GV depende\n    // de CD_GV, então a ausência é fatal pelo mesmo motivo.\n    const iK=['MERCADO','MERCADO REGIONAL','MKT','MARKET'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    if(iK==null) throw new Error('base de municípios dominantes sem a coluna MERCADO — fechamento de GV/Mercado precisa dela. Colunas lidas: '+head.slice(0,40).join(', '));\n    // Nomes de exibição — opcionais: a hierarquia funciona só com os códigos, mas o código sozinho\n    // não diz nada pro operador escolhendo o que fechar.\n    const iDS=['DC SETOR','NOME SETOR','DESCRICAO SETOR','NM SETOR'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    const iDG=['DC GV','NOME GV','DESCRICAO GV','NM GV','GV NOME'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    // The UF column is what the whole keying now rests on — SQL no longer carries DS_ESTADO — so its\n    // absence is a first-class finding, not a footnote. The header actually matched is reported on the\n    // base pill, and when nothing matches the pill lists every column read, because guessing a header\n    // name from the outside costs a round trip each time.\n    const iU=['UF','SIGLA UF','SG UF','CD UF','ESTADO','DS ESTADO','SIGLA','ESTADO SIGLA','UF MUNICIPIO'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    const by={},ufBy={},gvBy={},mkBySetor={},setorLabel={},gvLabel={}; let n=0,bad=0; const clash={},gvClash={},mkClash={};\n    for(let i=1;i<table.length;i++){\n      const line=table[i]; if(!line) continue;\n      const s=String(line[iS]==null?'':line[iS]).trim(), m=muniN(line[iM]);\n      if(!s||!m) continue;\n      // One setor spans many rows (one per município) and every one of them repeats its CD_GV. Two\n      // DIFFERENT values for the same setor means the base itself disagrees about which gerência owns\n      // it — counted and reported, never keyed to whichever row happened to be read last.\n      const gvv=String(line[iG]==null?'':line[iG]).trim();\n      if(gvv){ if(gvBy[s]!==undefined && gvBy[s]!==gvv) gvClash[s]=true; else gvBy[s]=gvv; }\n      const mkv=String(line[iK]==null?'':line[iK]).trim();\n      if(mkv){ if(mkBySetor[s]!==undefined && mkBySetor[s]!==mkv) mkClash[s]=true; else mkBySetor[s]=mkv; }\n      if(iDS!=null){ const lbl=String(line[iDS]==null?'':line[iDS]).trim(); if(lbl && !setorLabel[s]) setorLabel[s]=lbl; }\n      if(iDG!=null && gvv){ const lbl=String(line[iDG]==null?'':line[iDG]).trim(); if(lbl && !gvLabel[gvv]) gvLabel[gvv]=lbl; }\n      const raw=(iU==null)?'':line[iU];\n      const u=(iU==null)?'':ufNorm(raw);\n      if(iU!=null && String(raw==null?'':raw).trim() && !u) bad++;\n      // The same name twice in ONE setor under two UFs is the single case the reverted SQL cannot\n      // survive: mSQL groups by DS_MUNICIPIO alone, so both municipalities arrive summed into one row\n      // and no lookup can split them again. Counted and reported — never quietly keyed to whichever\n      // UF happened to be read last.\n      const kk=s+'|'+m;\n      // An UNRESOLVED uf must never outrank a resolved one. Reading a blank/bogus UF row first wrote ''\n      // here, and the next row carrying the real sigla was then rejected as a clash — so the key stayed\n      // falsy for good, ufFor() fell through, and the município degraded to a bare NAME key that matches\n      // no shape and no dominance entry. With DISTRITO/ZONA in the base a município spans several rows,\n      // so which row lands first is pure luck. A clash is only two RESOLVED siglas that disagree.\n      if(!u){ if(ufBy[kk]===undefined) ufBy[kk]=''; }\n      else if(!ufBy[kk]) ufBy[kk]=u;\n      else if(ufBy[kk]!==u) clash[kk]=true;\n      (by[s]=by[s]||new Set()).add(u?m+'/'+u:m); n++;\n    }\n    if(!n) throw new Error('base de municípios dominantes sem linhas válidas');\n    DOM.bySetor=by; DOM.ufBySetorMuni=ufBy; DOM.rows=n; DOM.setores=Object.keys(by).length;\n    // Reverse município→setor index (static, built once from the same CSV) — lets the GV-closing\n    // map gray out a geographically-touching GV with no live query, matching the \"GV/mercado\n    // hierarchy is organizational, read from the CSV only\" rule the rest of this level already follows.\n    { const revBy={},revAll={}; Object.entries(by).forEach(([s,ms])=>ms.forEach(m=>{ if(!revBy[m]) revBy[m]=s; (revAll[m]=revAll[m]||new Set()).add(s); })); DOM.setorByMuni=revBy; DOM.setoresByMuni=revAll; }\n    DOM.gvBySetor=gvBy; DOM.gvClashes=Object.keys(gvClash).length; DOM.gvCol=String(table[0][iG]||'').trim();\n    DOM.gvSetores=Object.keys(gvBy).length;\n    DOM.hasUf=(iU!=null); DOM.ufCol=(iU==null?null:String(table[0][iU]||'').trim());\n    DOM.badUf=bad; DOM.clashes=Object.keys(clash).length; DOM.headers=head.slice(0,40); DOM.error=null;\n    // ---- hierarquia: setor ⊂ GV ⊂ mercado, tudo derivado do MESMO csv que a regra de setor já\n    // confia — fechamento de GV/Mercado lê a mesma fonte, nunca uma segunda base paralela.\n    DOM.mercadoBySetor=mkBySetor; DOM.mercadoClashes=Object.keys(mkClash).length; DOM.mercadoCol=String(table[0][iK]||'').trim();\n    DOM.setorLabel=setorLabel; DOM.gvLabel=gvLabel;\n    const sectorsByGv={},sectorsByMercado={},gvsByMercado={},mercadoByGv={},gvMercClash={};\n    Object.keys(by).forEach(s=>{\n      const gv=gvBy[s], mk=mkBySetor[s];\n      if(gv) (sectorsByGv[gv]=sectorsByGv[gv]||new Set()).add(s);\n      if(mk) (sectorsByMercado[mk]=sectorsByMercado[mk]||new Set()).add(s);\n      if(gv&&mk){ if(mercadoByGv[gv]!==undefined && mercadoByGv[gv]!==mk) gvMercClash[gv]=true; else mercadoByGv[gv]=mk; }\n    });\n    Object.entries(mercadoByGv).forEach(([gv,mk])=>(gvsByMercado[mk]=gvsByMercado[mk]||new Set()).add(gv));\n    DOM.sectorsByGv=sectorsByGv; DOM.sectorsByMercado=sectorsByMercado; DOM.gvsByMercado=gvsByMercado;\n    DOM.mercadoByGv=mercadoByGv; DOM.gvMercadoClashes=Object.keys(gvMercClash).length;\n    return by;\n  })();\n  DOM.promise.catch(e=>{ DOM.promise=null; DOM.bySetor=null; DOM.error=e.message; renderAll(); });\n  return DOM.promise;\n}\n\nlet SESS={ snapshotRaw:null, snapshotSyncedAt:null, snapshotPinnedDate:null, snapLiteral:null, eloMaxCycle:null, timeline:[], selected:0,\n           sessionId:null, sessionLabel:null, sessionsList:[], sectorsList:[], sectorBaseCache:{}, stage:'loading', dirtyFrom:null, locked:false,\n           snapBusy:false, settings:{...DEFAULT_SETTINGS} };\n\nasync function q(sql){\n  let dbx,res;\n  try{\n    res=await d2.rush('AwW_11Nkg-dKksP7ic8V8').invoke({sql});\n  }catch(e){\n    throw new Error('Databricks indisponível (rush AwW_11Nkg-dKksP7ic8V8): '+e.message);\n  }\n  // The listener answers 200 with {sql} and NO `result` when its upstream probe times out: the\n  // handler builds `result: $result.data`, so an empty upstream simply drops the key rather than\n  // failing. Reading straight through to res.data.result.data reported \"Cannot read properties of\n  // undefined (reading 'data')\" — which reads like a bug in THIS component and sent a debugging\n  // session after the wrong thing. Name the layer that actually failed instead.\n  const payload=res&&res.data;\n  if(!payload) throw new Error('rush AwW_11Nkg-dKksP7ic8V8 não devolveu corpo — listener fora do ar?');\n  if(!payload.result) throw new Error('a sonda Databricks não respondeu: o listener AwW_11Nkg-dKksP7ic8V8 devolveu 200 sem `result`, o que normalmente é timeout do upstream (natura-x-app). O SQL não chegou a rodar.');\n  dbx=payload.result.data;\n  if(!dbx) throw new Error('sonda Databricks respondeu sem dados (result.data vazio).');\n  if(dbx.state && dbx.state!=='SUCCEEDED') throw new Error('Databricks '+dbx.state+': '+(dbx.error?.message||JSON.stringify(dbx.error)||'erro desconhecido'));\n  return dbx;\n}\nfunction rows(resp){const c=(resp.columns||[]).map(x=>x.name);\n  return (resp.rows||[]).map(a=>Object.fromEntries(c.map((k,i)=>[k,a[i]])));}\n// Stamps the UF onto every feature at load: the fetch is per-UF, so this is the ONLY point where a\n// shape's state is known for certain. Downstream the features are flattened into one array and the\n// origin would be unrecoverable. Persisted with the feature in saved sessions.\nasync function geoUF(uf){const code=UF_CODE[uf]||uf;const r=await fetch('https://raw.githubusercontent.com/tbrugz/geodata-br/master/geojson/geojs-'+code+'-mun.json');if(!r.ok)throw new Error('geo '+uf);\n  const g=await r.json(); const U=String(uf).toUpperCase();\n  (g.features||[]).forEach(f=>{ f.__uf=U; });\n  return g;}\n\nfunction buildAdj(features){\n  // Encaixe exato de vértice (grid ~111m) resolve vizinhança DENTRO de uma UF, porque todos os\n  // municípios de uma UF saem do MESMO arquivo geoJSON, com a mesma malha de pontos. Atravessando\n  // fronteira de UF, os dois lados vêm de arquivos buscados/digitalizados SEPARADAMENTE — a mesma\n  // linha real de fronteira quase nunca cai nos mesmos vértices nos dois arquivos, e alargar o grid\n  // pra compensar (tentativa anterior) só troca \"não encontra vizinho de UF diferente\" por \"pode\n  // grudar município que só está perto, dentro da própria UF\" — não é uma correção geométrica de\n  // verdade, só empurra o problema. A solução de verdade é uma segunda checagem, geométrica (menor\n  // distância real entre os dois contornos, em km), aplicada SÓ a pares de UF diferente que o\n  // encaixe exato não achou — dentro da mesma UF o encaixe exato continua sendo a única fonte,\n  // sem alteração de comportamento nem de peso de voto.\n  const pts=f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates],s=new Set();\n    ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>s.add(Math.round(p[0]*1000)/1000+','+Math.round(p[1]*1000)/1000))));return s;};\n  const rawPts=f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates],out=[];\n    ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>out.push(p))));return out;};\n  const feat={},idx={};\n  features.forEach(f=>{\n    const nm=mkey(f.properties.name||f.properties.NOME||f.properties.nome,f.__uf);\n    const raw=rawPts(f);\n    let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n    raw.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; });\n    feat[nm]={f,pts:pts(f),raw,bbox:{mnx,mny,mxx,mxy},uf:f.__uf};\n  });\n  for(const nm in feat) feat[nm].pts.forEach(p=>(idx[p]=idx[p]||[]).push(nm));\n  // Limiar de \"encosta de verdade\" cruzando UF: cobre a folga típica entre dois arquivos\n  // digitalizados separadamente pra MESMA linha real de fronteira, sem juntar município que só\n  // está na vizinhança geral mas não faz fronteira de fato.\n  const TOUCH_KM=1.2;\n  const nbrs=nm=>{\n    const A=feat[nm]; if(!A) return [];\n    const c={};\n    A.pts.forEach(p=>idx[p].forEach(o=>{if(o!==nm)c[o]=(c[o]||0)+1;}));\n    const exact=Object.entries(c).filter(([,n])=>n>=2).map(([o,n])=>({o,w:n}));\n    const seen=new Set(exact.map(e=>e.o));\n    const latK=Math.max(0.2,Math.cos(((A.bbox.mny+A.bbox.mxy)/2)*Math.PI/180));\n    const padLon=TOUCH_KM/111/latK, padLat=TOUCH_KM/111;\n    const extra=[];\n    for(const o in feat){\n      if(o===nm||seen.has(o)) continue;\n      const B=feat[o];\n      if(B.uf===A.uf) continue; // mesma UF: se o encaixe exato não achou, não está encostando\n      if(B.bbox.mxx<A.bbox.mnx-padLon||B.bbox.mnx>A.bbox.mxx+padLon||B.bbox.mxy<A.bbox.mny-padLat||B.bbox.mny>A.bbox.mxy+padLat) continue;\n      let best=Infinity;\n      outer: for(const p of A.raw){\n        for(const q of B.raw){\n          const d=Math.hypot((p[0]-q[0])*latK*111,(p[1]-q[1])*111);\n          if(d<best){ best=d; if(best<0.05) break outer; }\n        }\n      }\n      if(best<=TOUCH_KM) extra.push({o,w:1});\n    }\n    return exact.concat(extra);\n  };\n  return {feat,nbrs};\n}\nfunction centroidLL(f){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  ps.forEach(poly=>poly[0].forEach(p=>{x+=p[0];y+=p[1];n++;}));return[x/n,y/n];}\n\n// ---------- snapshot pinning ----------\nasync function resolveSnapshotPin(range){\n  // range: {from,to} (both optional YYYY-MM-DD strings, from the snapshot sheet's período search),\n  // or omitted entirely for the unconditional \"latest available\" (live) pin. This used to filter\n  // with CAST(DT_HISTORICO AS DATE)<=DATE'...' directly in SQL — and matched ZERO rows for every\n  // date tested. DT_HISTORICO's real underlying type is uncertain enough that even an EXACT\n  // equality match against it (the candidates/tests loop below) already has to empirically try\n  // three different literal forms rather than trust one; a CAST hit that same uncertainty, and\n  // Spark returns NULL — not an error — from a CAST it can't make sense of, so NULL<=anything\n  // silently matched nothing, for every date, with no exception to reveal why. Fixed by never\n  // comparing dates in SQL at all: SQL's own JSON serialization always normalizes a DATE/TIMESTAMP\n  // column's OUTPUT to an ISO-shaped string regardless of its real underlying type (exactly how\n  // `raw` already round-trips safely in the unconditional/live case below), so GROUP BY hands back\n  // every distinct snapshot with its row count, and the target is picked by comparing that string's\n  // first 10 characters (YYYY-MM-DD) in JS — no CAST, no SQL-side range comparison, no ambiguity.\n  const from=range&&range.from, to=range&&range.to;\n  let raw, liveCount, diag=null;\n  if(from||to){\n    // Direct SQL-side range filter, mirroring the SAME self-referential-subquery pattern the\n    // live/unconditional branch below already relies on for a safe exact-match count (comparing\n    // DT_HISTORICO against a SUBQUERY's own re-derived MAX, never against an externally re-injected\n    // literal for the COUNT step) — only the boundary comparison itself needs a literal, so that's\n    // the one spot the three candidate forms tried for equality elsewhere in this function get\n    // tried again. An earlier version used COUNT(*) over the WHOLE RANGE just to check \"did this\n    // literal flavor match anything\" — wrong, because that range-wide count then got compared\n    // downstream against a SINGLE exact date's row count, which a multi-week/month window will\n    // essentially never equal. Half-open interval on the upper bound (< first instant after `to`,\n    // not <= 'to 23:59:59.999') so a DT_HISTORICO with finer-than-millisecond precision can't\n    // silently land just outside an inclusive boundary.\n    const fromB=from?from+' 00:00:00.000':null;\n    const toExclusive=to?(()=>{ const d=new Date(to+'T00:00:00Z'); d.setUTCDate(d.getUTCDate()+1); return d.toISOString().slice(0,10)+' 00:00:00.000'; })():null;\n    const flavors=[v=>`TIMESTAMP'${v}'`, v=>`'${v}'`, v=>`DATE'${v.slice(0,10)}'`];\n    for(const mk of flavors){\n      const clauses=[]; if(fromB) clauses.push(`DT_HISTORICO>=${mk(fromB)}`); if(toExclusive) clauses.push(`DT_HISTORICO<${mk(toExclusive)}`);\n      const whereRange=' AND '+clauses.join(' AND ');\n      const maxR=await q(`SELECT MAX(DT_HISTORICO) snap FROM ${T} WHERE CD_PAIS=1${whereRange}`).then(rows).then(rs=>rs[0]).catch(()=>null);\n      const candRaw=maxR&&maxR.snap;\n      if(!candRaw) continue;\n      const cntR=await q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1${whereRange})`).then(rows).then(rs=>rs[0]).catch(()=>null);\n      const n=cntR?+cntR.n:0;\n      if(n>0){ raw=candRaw; liveCount=n; break; }\n    }\n    if(!raw){\n      // A clean, single-row aggregate (MIN/MAX/COUNT DISTINCT) to get the TRUE bounds of what's\n      // actually in the table, so a genuinely-empty period is distinguishable from a filter that\n      // silently matched nothing.\n      const b=await q(`SELECT MIN(DT_HISTORICO) mn, MAX(DT_HISTORICO) mx, COUNT(DISTINCT DT_HISTORICO) nd FROM ${T} WHERE CD_PAIS=1`).then(rows).then(rs=>rs[0]).catch(()=>null);\n      diag = b ? `base cobre de ${String(b.mn||'?').slice(0,10)} a ${String(b.mx||'?').slice(0,10)} (${b.nd||'?'} data(s) distinta(s))` : 'não foi possível checar o alcance da base';\n    }\n  } else {\n    const [rawR,liveCountR]=await Promise.all([\n      q(`SELECT MAX(DT_HISTORICO) snap FROM ${T} WHERE CD_PAIS=1`).then(rows),\n      q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)`).then(rows),\n    ]);\n    raw=rawR[0]&&rawR[0].snap; liveCount=+((liveCountR[0]||{}).n||0);\n  }\n  const eloR=await q(`SELECT MAX(operational_cycle) mx FROM ${ELO} WHERE country_code=1`).then(rows).catch(()=>[]);\n  const eloMax=+((eloR[0]||{}).mx||0);\n  if(!raw) return {snapshotRaw:null, snapLiteral:null, eloMaxCycle:eloMax, syncedAt:new Date().toISOString(), pinnedDate:null, diag};\n  const LIVE_SUBQ=`(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)`;\n  const dbTs=(raw||'').replace('T',' ').replace(/Z$/,'');\n  const candidates=[`TIMESTAMP'${dbTs}'`,`'${dbTs}'`,`DATE'${dbTs}'`];\n  const tests=await Promise.all(candidates.map(c=>\n    q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=${c}`).then(rows).then(r=>+((r[0]||{}).n||0)).catch(()=>-1)\n  ));\n  // LIVE_SUBQ (unconditionally re-deriving MAX) is only a safe fallback for the live/unconditional\n  // case — for a historical pin it would just silently re-resolve to today's snapshot, discarding\n  // the picked range, so the fallback there is the best-guess candidate literal instead.\n  let chosen=(from||to)?candidates[0]:LIVE_SUBQ;\n  for(let i=0;i<candidates.length;i++){ if(liveCount>0 && tests[i]===liveCount){ chosen=candidates[i]; break; } }\n  // eloMaxCycle deliberately stays unbounded by the picked range: product.elo_modelo_combinado\n  // keys by an opaque operational_cycle number this component has no established mapping from a\n  // calendar date to — bounding it wrong would silently mis-scope the \"últimos N ciclos\" trend\n  // chart instead of just leaving it exactly as accurate (or inaccurate) as before this feature.\n  return {snapshotRaw:raw, snapLiteral:chosen, eloMaxCycle:eloMax, syncedAt:new Date().toISOString(), pinnedDate:(from||to)?String(raw).slice(0,10):null};\n}\n// DISPLAY CONTRACT: the UI never prints the snapshot's own DT_HISTORICO (snapshotRaw). What the user\n// sees is `snapshotSyncedAt` — the date AND hour at which THIS instance last pulled/pinned the\n// snapshot it is querying. snapshotRaw is still resolved, still drives every SQL literal, and is\n// still persisted by saveSession/doShare; it is only kept off the screen.\nfunction fmtSyncedAt(iso){\n  if(!iso) return '—';\n  const d=new Date(iso);\n  if(isNaN(d.getTime())) return String(iso);\n  return d.toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});\n}\nasync function ensurePinned(){\n  if(SESS.snapLiteral) return;\n  const p=await resolveSnapshotPin();\n  SESS.snapshotRaw=p.snapshotRaw; SESS.snapLiteral=p.snapLiteral; SESS.eloMaxCycle=p.eloMaxCycle;\n  SESS.snapshotSyncedAt=p.syncedAt; SESS.snapshotPinnedDate=p.pinnedDate;\n}\n\n// ---------- world-state fold ----------\nfunction accMetrics(base,add){\n  const out=base?{...base}:blankM();\n  Object.keys(blankM()).forEach(k=>{ out[k]=(out[k]||0)+((add&&add[k])||0); });\n  return out;\n}\nfunction foldWorldState(timeline,uptoIdx){\n  const closedSectors=new Set(),muniOverride={},muniGeo={},muniRow={},muniLevel={},sectorInflow={},sectorOutflow={},sectorInflowLevel={},sectorOrphanRows={};\n  // GV/mercado: setores e GVs não se dissolvem quando o dono deles fecha — eles só passam a\n  // responder a um destino novo. Por isso o rastro do cascade mora numa camada PRÓPRIA\n  // (secOverride/gvOverride), separada da camada de município do fechamento de setor — a\n  // hierarquia trava a sessão num nível só, então nunca as duas camadas têm conteúdo ao mesmo tempo.\n  const closedGvs=new Set(),secOverride={},gvInflow={};\n  const closedMercados=new Set(),gvOverride={},mercadoInflow={};\n  for(let k=0;k<uptoIdx;k++){\n    const mv=timeline[k]; if(!mv) continue;\n    if(mv.kind==='transfer'){\n      muniOverride[mv.xferMuni]=mv.xferTo;\n      sectorInflow[mv.xferTo]=accMetrics(sectorInflow[mv.xferTo],mv.xferMetrics);\n      sectorOutflow[mv.xferFrom]=accMetrics(sectorOutflow[mv.xferFrom],mv.xferMetrics);\n      continue;\n    }\n    if(!mv.sector||!mv.munis) continue;\n    const level=mv.level||'setor';\n    if(level==='gv'){\n      closedGvs.add(String(mv.sector));\n      Object.keys(mv.munis).forEach(u=>{\n        if(u==='__OUTROS__') return;\n        const dest=mv.assign[u]; if(!dest) return;\n        secOverride[u]=String(dest);\n        gvInflow[dest]=accMetrics(gvInflow[dest],mv.munis[u]);\n      });\n      continue;\n    }\n    if(level==='mercado'){\n      closedMercados.add(String(mv.sector));\n      Object.keys(mv.munis).forEach(u=>{\n        if(u==='__OUTROS__') return;\n        const dest=mv.assign[u]; if(!dest) return;\n        gvOverride[u]=String(dest);\n        mercadoInflow[dest]=accMetrics(mercadoInflow[dest],mv.munis[u]);\n      });\n      continue;\n    }\n    closedSectors.add(String(mv.sector));\n    Object.keys(mv.munis).forEach(m=>{\n      if(m==='__OUTROS__') return;\n      const dest=mv.assign[m]; if(!dest) return;\n      muniOverride[m]=String(dest);\n      if(mv.geoFeat&&mv.geoFeat[m]) muniGeo[m]=mv.geoFeat[m];\n      const rws=(mv.matrix||[]).filter(r=>mkey(r.municipio,r.uf)===m);\n      muniRow[m]=(muniRow[m]||[]).concat(rws);\n      if(mv.levelByMuni&&mv.levelByMuni[m]) muniLevel[m]={...(muniLevel[m]||{}),...mv.levelByMuni[m]};\n      sectorInflow[dest]=accMetrics(sectorInflow[dest],mv.munis[m]);\n      const lv=(mv.levelByMuni&&mv.levelByMuni[m])||{};\n      sectorInflowLevel[dest]=sectorInflowLevel[dest]||{};\n      Object.entries(lv).forEach(([nv,c])=>{ sectorInflowLevel[dest][nv]=(sectorInflowLevel[dest][nv]||0)+c; });\n    });\n    // Orphan CBs follow their leader's home sector, so later etapas must inherit them the same way\n    // the allocated municipalities are inherited — otherwise a stage's span would not match the\n    // base carried into the next one.\n    if(mv.orphanMetricsByGroup && Object.keys(mv.orphanMetricsByGroup).length){\n      const d=computeDerived(mv);\n      d.leaders.forEach(L=>{\n        if(!L.home) return;\n        const om=mv.orphanMetricsByGroup[L.grupo];\n        if(om) sectorInflow[L.home]=accMetrics(sectorInflow[L.home],om);\n        // sectorInflow only reaches a sector while it is a DESTINATION (it lands in baseMetrics).\n        // Keep the rows as well, so the sector still carries these CBs on the day it is the one\n        // being closed — that read goes through mv.matrix, which baseMetrics never touches.\n        const orws=mv.orphanRowsByGroup&&mv.orphanRowsByGroup[L.grupo];\n        if(orws&&orws.length){\n          sectorOrphanRows[L.home]=(sectorOrphanRows[L.home]||[]).concat(orws);\n          // Orphan municípios never reach the muniOverride[m]=dest write above — aggregateMovement\n          // folds them into '__OUTROS__' in mv.munis, which that loop explicitly skips — so a later\n          // GV/mercado closure's resolveUnit() had no override to redirect them and kept resolving\n          // them back to THIS (now-closed) setor, via their still-live CD_SETOR. Recorded here too,\n          // keyed by the real município, so resolveUnit() redirects them the same as any other one.\n          orws.forEach(r=>{ const m=mkey(r.municipio,r.uf); if(!muniOverride[m]) muniOverride[m]=String(L.home); });\n        }\n        const ol=mv.orphanLevelByGroup&&mv.orphanLevelByGroup[L.grupo];\n        if(ol){ sectorInflowLevel[L.home]=sectorInflowLevel[L.home]||{};\n          Object.entries(ol).forEach(([nv,c])=>{ sectorInflowLevel[L.home][nv]=(sectorInflowLevel[L.home][nv]||0)+c; }); }\n      });\n    }\n  }\n  return {closedSectors,muniOverride,muniGeo,muniRow,muniLevel,sectorInflow,sectorOutflow,sectorInflowLevel,sectorOrphanRows,\n          closedGvs,secOverride,gvInflow,closedMercados,gvOverride,mercadoInflow};\n}\n\n// ---------- per-movement compute ----------\nfunction aggregateMovement(mv,world){\n  const level=mv.level||'setor';\n  const munis={},leaders={};\n  const exSet=new Set((mv.excludedMunis||[]).map(x=>x.muni));\n  const acc=(o,r)=>{o.cbs+=+r.cbs;o.receita+=+r.receita||0;o.rec_nat+=+r.rec_nat||0;o.rec_avon+=+r.rec_avon||0;o.pontos+=+r.pontos||0;o.pedidos+=+r.pedidos||0;o.ativas+=+r.ativas||0;};\n  // Orphans = CBs in municipalities outside the sector's dominance base. They are NOT allocated\n  // geographically; they follow their own leader's home sector. Kept per leader group so every\n  // consumer (span, cascade, impact report) can route them the same way. Só existe no nível setor —\n  // GV/mercado não têm spillover (todo setor pertence a exatamente uma GV/mercado na base).\n  mv.orphanByGroup={}; mv.orphanMetricsByGroup={}; mv.orphanRowsByGroup={};\n  // unidade que este nível redistribui: a própria município (setor fecha), o setor da linha (GV\n  // fecha), a GV do setor da linha (mercado fecha).\n  // Same redirect resolveUnit() already applies for geometry (mv.unitOfMuni, in computeMovement):\n  // a setor/GV that already closed EARLIER in this session no longer really owns this row — its\n  // live CD_SETOR just hasn't caught up (the CRM snapshot never learns about a simulated closure).\n  // Without this, the closed unit's own rows kept grouping under ITS OWN code here, in the MATRIX\n  // (mv.munis/mv.leaders — what the UI actually renders as a unit's row), reappearing as if it were\n  // still open, even though the map/resolveUnit already redirected it correctly.\n  const unitOf=r=>{\n    if(level==='setor') return null;\n    let sOf=String(r.setor);\n    const m=mkey(r.municipio,r.uf);\n    if(world&&world.closedSectors.has(sOf)){\n      // Closed AND unresolved (no override was ever recorded for this município — e.g. it had no\n      // shape yet during the setor's OWN closure, so it never entered mv.munis/mv.assign back then)\n      // must NOT fall through as sOf itself: that resurrects the closed setor as a live, assignable\n      // unit here. Empty instead, same as anything else aggregateMovement can't place — it falls to\n      // the '__OUTROS__' bucket below, already excluded from optimizeFor/autoAssignFor everywhere.\n      sOf=world.muniOverride[m]||'';\n    }\n    if(!sOf) return '';\n    if(level==='gv') return sOf;\n    return (world&&world.secOverride&&world.secOverride[sOf])||DOM.gvBySetor[sOf]||'';\n  };\n  mv.matrix.forEach(r=>{\n    const m=mkey(r.municipio,r.uf);\n    r._out=!mv.geoFeat[m];   // assign, never just set: absorbed rows are reused across etapas\n    const mm=level==='setor'?(r._out?'__OUTROS__':m):(unitOf(r)||'__OUTROS__');\n    munis[mm]=munis[mm]||{...blankM(),name:mm}; acc(munis[mm],r);\n    const g=String(r.grupo); leaders[g]=leaders[g]||{grupo:g,lider:r.lider,total:0,byMuni:{}};\n    leaders[g].total+=+r.cbs; leaders[g].byMuni[mm]=(leaders[g].byMuni[mm]||0)+ +r.cbs;\n    if(level==='setor' && r._out && exSet.has(m)){\n      mv.orphanByGroup[g]=(mv.orphanByGroup[g]||0)+ +r.cbs;\n      // The ROW too, not just the totals: when the leader's home sector is itself closed in a later\n      // etapa, that etapa rebuilds its matrix from SQL + absorbed municípios, and these CBs are in\n      // neither (their município never moved). Without the row they silently vanish from the sector\n      // they were just credited to — see sectorOrphanRows in foldWorldState.\n      (mv.orphanRowsByGroup[g]=mv.orphanRowsByGroup[g]||[]).push(r);\n      mv.orphanMetricsByGroup[g]=accMetrics(mv.orphanMetricsByGroup[g],\n        {cbs:+r.cbs,receita:+r.receita||0,rec_nat:+r.rec_nat||0,rec_avon:+r.rec_avon||0,pontos:+r.pontos||0,pedidos:+r.pedidos||0,ativas:+r.ativas||0});\n    }\n  });\n  // Level counts only exist per municipality, never per leader, so an excluded municipality's level\n  // mix is credited to the group holding most of its CBs (sub-threshold by definition, so usually one).\n  mv.orphanLevelByGroup={};\n  (mv.excludedMunis||[]).forEach(({muni})=>{\n    const lv=(mv.levelByMuni&&mv.levelByMuni[muni])||{}; if(!Object.keys(lv).length) return;\n    let bg=null,bc=-1;\n    mv.matrix.forEach(r=>{ if(mkey(r.municipio,r.uf)!==muni) return; const cc=+r.cbs; if(cc>bc){bc=cc;bg=String(r.grupo);} });\n    if(!bg) return;\n    mv.orphanLevelByGroup[bg]=mv.orphanLevelByGroup[bg]||{};\n    Object.entries(lv).forEach(([nv,cc])=>{ mv.orphanLevelByGroup[bg][nv]=(mv.orphanLevelByGroup[bg][nv]||0)+cc; });\n  });\n  mv.munis=munis;\n  // Todo município com geoFeat (inclusive os sem CB injetados acima, em withShape/closingNames) tem\n  // de aparecer em mv.munis — optimizeFor()/autoAssignFor() só enxergam Object.keys(mv.munis) pra\n  // decidir quem recebe destino. Sem isto, um município sem nenhuma linha em mv.matrix nunca entra\n  // em mv.assign, mesmo desenhado no mapa e votando por vizinhança — fica com destino colorido na\n  // legenda mas span/CBs realocadas/desequilíbrio tudo zerado, porque nada foi de fato atribuído.\n  if(level==='setor'){\n    Object.keys(mv.geoFeat||{}).forEach(n=>{ if(!munis[n]) munis[n]={...blankM(),name:n}; });\n  } else {\n    // Same guarantee, one level up: every UNIT (setor closing a GV, GV closing a mercado) that owns\n    // at least one drawn município has to appear in mv.munis even if every one of its municípios is\n    // zero-CB — mv.unitMunis already grouped them by unit; without a blank entry here that unit is\n    // invisible to optimizeFor()/autoAssignFor() and never gets a real mv.assign, exactly like the\n    // setor-level município case above.\n    Object.keys(mv.unitMunis||{}).forEach(u=>{ if(!munis[u]) munis[u]={...blankM(),name:u}; });\n  }\n  mv.leaders=Object.values(leaders).sort((a,b)=>b.total-a.total);\n}\nfunction autoAssignFor(mv){\n  const a={};\n  Object.keys(mv.munis).forEach(m=>{ if(m==='__OUTROS__')return;\n    const v=mv.allowed[m]||{}; const votes=Object.keys(v);\n    if(votes.length){ let best=votes[0],bw=-1; for(const s of votes) if(v[s]>bw){bw=v[s];best=s;} a[m]=best; }\n    else if(mv.dest.length){ const c=mv.muniCentroid[m]; let best=mv.dest[0],bd=Infinity;\n      for(const s of mv.dest){ const sc=mv.secCentroid[s]; if(!sc||!c)continue; const d=(c[0]-sc[0])**2+(c[1]-sc[1])**2; if(d<bd){bd=d;best=s;} }\n      a[m]=best; }\n  });\n  return a;\n}\nfunction deslocFor(mv,assign){\n  let d=0;\n  mv.leaders.forEach(L=>{const byS={};let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=assign[m];if(s){byS[s]=(byS[s]||0)+c;tot+=c;}}\n    const home=mv.dest.reduce((a,s)=>(byS[s]||0)>(byS[a]||0)?s:a,mv.dest[0]);\n    d+=tot-(byS[home]||0);});\n  return d;\n}\nfunction spanMapFor(mv,a){const inc={};mv.dest.forEach(s=>inc[s]=0);\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=a[m];if(s&&inc[s]!=null)inc[s]+=o.cbs;});\n  // Orphan CBs follow their leader's home sector, and that home depends on THIS candidate\n  // assignment — so they have to be counted here, or the optimizer would score and constrain a\n  // span that differs from the one the UI shows. Skipped entirely when there are no orphans.\n  if(mv.orphanByGroup&&Object.keys(mv.orphanByGroup).length){\n    mv.leaders.forEach(L=>{\n      const orphan=mv.orphanByGroup[L.grupo]||0; if(!orphan) return;\n      const byS={}; let tot=0;\n      for(const [m,c] of Object.entries(L.byMuni)){ if(m==='__OUTROS__') continue; const s=a[m]; if(s&&inc[s]!=null){ byS[s]=(byS[s]||0)+c; tot+=c; } }\n      if(!tot) return;   // no home to follow -> stranded, counted nowhere\n      const home=mv.dest.reduce((x,s)=>(byS[s]||0)>(byS[x]||0)?s:x,mv.dest[0]);\n      if(inc[home]!=null) inc[home]+=orphan;\n    });\n  }\n  const sp={};mv.dest.forEach(s=>sp[s]=(+(mv.baseMetrics[s]?.cbs||0))+inc[s]);return sp;}\nfunction maxSpanOfFor(mv,a){return Math.max(...Object.values(spanMapFor(mv,a)),0);}\nfunction mergedSpanAll(){\n  const st=(SESS.settings&&SESS.settings.span)||{};\n  return {\n    setor:{...DEFAULT_SETTINGS.span.setor,...(st.setor||{})},\n    gv:{...DEFAULT_SETTINGS.span.gv,...(st.gv||{})},\n    mercado:{...DEFAULT_SETTINGS.span.mercado,...(st.mercado||{})},\n  };\n}\nfunction spanShortAll(){\n  const sp=mergedSpanAll();\n  const parts=['setor','gv','mercado'].map(lvl=>{ const s=spanShort(sp[lvl]); return s==='livre'?null:(LEVELS[lvl].label+' '+s); }).filter(Boolean);\n  return parts.length?parts.join(' · '):'livre';\n}\n// Sessões salvas antes de existir escala por nível guardavam targetSpan/maxSpan/minSpan soltos no\n// topo de settings, e esses três campos sempre foram só do setor — migra pra span.setor uma vez,\n// preservando null explícito (desligado) em vez de recair no padrão 2700/3500/2200.\nfunction migrateSpanSettings(raw){\n  raw=raw||{};\n  if(raw.span) return {\n    setor:{...DEFAULT_SETTINGS.span.setor,...raw.span.setor},\n    gv:{...DEFAULT_SETTINGS.span.gv,...raw.span.gv},\n    mercado:{...DEFAULT_SETTINGS.span.mercado,...raw.span.mercado},\n  };\n  const legacy=('targetSpan' in raw)||('maxSpan' in raw)||('minSpan' in raw);\n  return {\n    setor: legacy?{targetSpan:raw.targetSpan??null,maxSpan:raw.maxSpan??null,minSpan:raw.minSpan??null}:{...DEFAULT_SETTINGS.span.setor},\n    gv:{...DEFAULT_SETTINGS.span.gv},\n    mercado:{...DEFAULT_SETTINGS.span.mercado},\n  };\n}\nfunction spanLimits(mv){\n  // Span alvo/máximo/mínimo é uma escala POR NÍVEL de fechamento — cada nível olha só a sua.\n  const lvl=(mv&&mv.level)||'setor';\n  const st=mergedSpanAll()[lvl];\n  return {target:numOrNull(st.targetSpan),max:numOrNull(st.maxSpan),min:numOrNull(st.minSpan)};}\nfunction spanLimitsOn(lim){return !!(lim.target||lim.max||lim.min);}\nfunction spanViolationsOf(sp,lim){const over=[],under=[];\n  Object.entries(sp).forEach(([s,v])=>{ if(lim.max&&v>lim.max)over.push(s); if(lim.min&&v<lim.min)under.push(s); });\n  return {over,under};}\nfunction spanViolationsFor(mv,a){return spanViolationsOf(spanMapFor(mv,a),spanLimits(mv));}\n// With a target span the objective becomes RMS distance from the ideal size (punishes too big AND\n// too small). With no target we keep the legacy objective: shave the biggest sector.\nfunction spanCostFor(mv,a){const lim=spanLimits(mv),vals=Object.values(spanMapFor(mv,a));\n  if(!vals.length) return 0;\n  if(lim.target) return Math.sqrt(vals.reduce((s,v)=>s+(v-lim.target)**2,0)/vals.length);\n  return Math.max(...vals);}\n// ---------- ÓTIMO: objetivo multi-critério ----------\n// Three normalised objectives, each 0..1, so the weights below are directly comparable. The old\n// score added a span cost measured in CBs to a desloc measured in CBs, so on a large sector the\n// balance term was numerically ~100x the desloc term and silently decided everything on its own.\n//   balance — RMS spread of the resulting spans (against the target span when one is set,\n//             otherwise against their own mean), over that reference. 0 = every destination\n//             sector ends up the same size.\n//   dist    — how far each município travels, CB-weighted: half from the share of its outer\n//             border that faces the sector it was given to, half from centroid distance. 0 =\n//             every município went to a sector it physically touches. Adjacency used to be a\n//             pure yes/no constraint (contiguity); this is what makes it a preference too.\n//   orphan  — stranded orphan CBs (leader left without a home sector, so they land nowhere at\n//             all) plus CBs split away from their leader's home, over the CBs at stake.\n//             Stranded ones count OPT_STRAND_K times: a stranded CB leaves the model entirely,\n//             a deslocated one merely changes hands.\nconst OPT_W={balance:1, dist:0.85, orphan:1, split:1};\nconst OPT_STRAND_K=3;\nconst OPT_SIMS=24;             // simultaneous simulations per run, scaled down on big stages\nconst OPT_PASSES=14;           // local-search sweeps inside one simulation\nconst OPT_EVAL_BUDGET=120000;  // total candidate evaluations across the whole portfolio\nconst OPT_EXHAUSTIVE_MAX=60000;\nfunction optDistPrep(mv){\n  let mx=0;\n  Object.keys(mv.munis).forEach(m=>{ if(m==='__OUTROS__') return; const c=mv.muniCentroid[m]; if(!c) return;\n    mv.dest.forEach(s=>{ const sc=mv.secCentroid[s]; if(!sc) return;\n      const d=Math.hypot(c[0]-sc[0],c[1]-sc[1]); if(d>mx) mx=d; }); });\n  return mx||1;\n}\nfunction optDistCost(mv,m,s,dmax){\n  const v=mv.allowed[m]||{}; let tot=0; for(const k in v) tot+=v[k];\n  const share=tot?((v[s]||0)/tot):0;\n  const c=mv.muniCentroid[m], sc=mv.secCentroid[s];\n  // Missing centroid on either side is neutral (0.5), never 0 — an unmeasurable move must not\n  // score as the cheapest one on the board.\n  const geo=(c&&sc)?Math.min(1,Math.hypot(c[0]-sc[0],c[1]-sc[1])/dmax):0.5;\n  return 0.5*(1-share)+0.5*geo;\n}\nfunction optEval(mv,a,dmax){\n  const lim=spanLimits(mv);\n  const sp=spanMapFor(mv,a), vals=Object.values(sp);\n  let balance=0;\n  if(vals.length){\n    const ref=lim.target||(vals.reduce((x,y)=>x+y,0)/vals.length);\n    if(ref) balance=Math.min(1,Math.sqrt(vals.reduce((s,v)=>s+(v-ref)**2,0)/vals.length)/ref);\n  }\n  let cbs=0,dist=0;\n  for(const m in a){ const o=mv.munis[m]; if(!o) continue; const w=+o.cbs||0; cbs+=w; dist+=w*optDistCost(mv,m,a[m],dmax); }\n  dist=cbs?(dist/cbs):0;\n  let stranded=0,desloc=0,tot=0;\n  mv.leaders.forEach(L=>{\n    const byS={}; let t=0;\n    for(const [m,c] of Object.entries(L.byMuni)){ if(m==='__OUTROS__') continue; const s=a[m]; if(s){ byS[s]=(byS[s]||0)+c; t+=c; } }\n    const orphan=(mv.orphanByGroup&&mv.orphanByGroup[L.grupo])||0;\n    tot+=t+orphan;\n    if(!t){ stranded+=orphan; return; }   // no allocated CBs -> no home -> these orphans land nowhere\n    const home=mv.dest.reduce((x,s)=>(byS[s]||0)>(byS[x]||0)?s:x,mv.dest[0]);\n    desloc+=t-(byS[home]||0);\n  });\n  const orphanTerm=tot?Math.min(1,(desloc+OPT_STRAND_K*stranded)/tot):0;\n  // split — a município that shares a real sector with ANOTHER live destination candidate (São\n  // Paulo etc.) should consolidate back onto that sibling rather than scatter to an unrelated\n  // sector: leaving it split across multiple sectors after this closure is a structural defect on\n  // its own, independent of balance/adjacência/orphan. Only counts a município where a sibling\n  // destination is actually reachable — nothing to prefer, nothing penalised, when it isn't.\n  let splitBad=0,splitElig=0;\n  for(const m in a){\n    const sib=mv.muniSiblings&&mv.muniSiblings[m]; if(!sib||!sib.length) continue;\n    const avail=sib.filter(s=>mv.dest.includes(s));\n    if(!avail.length) continue;\n    splitElig++;\n    if(!avail.includes(a[m])) splitBad++;\n  }\n  const splitTerm=splitElig?splitBad/splitElig:0;\n  return { cost:OPT_W.balance*balance+OPT_W.dist*dist+OPT_W.orphan*orphanTerm+OPT_W.split*splitTerm,\n           balance, dist, desloc, stranded, sp, viol:spanViolationsOf(sp,lim), splitTerm };\n}\nfunction optScoreFor(mv,a){ return optEval(mv,a,mv.__optDmax||optDistPrep(mv)).cost; }\n// Seeded PRNG on purpose. computeMovement runs the optimizer for EVERY stage and recomputeFrom\n// re-runs every stage downstream of an edit, so a Math.random() portfolio would hand back a\n// different allocation on each pass and a saved timeline would never reproduce.\nfunction optRng(seed){ let a=seed>>>0;\n  return ()=>{ a=(a+0x6D2B79F5)>>>0; let t=Math.imul(a^(a>>>15),1|a);\n    t=(t+Math.imul(t^(t>>>7),61|t))^t; return ((t^(t>>>14))>>>0)/4294967296; }; }\nfunction optSeed(mv,ms){ let h=2166136261>>>0;\n  const s=String(mv.sector)+'|'+ms.join(',')+'|'+mv.dest.join(',');\n  for(let i=0;i<s.length;i++) h=Math.imul(h^s.charCodeAt(i),16777619);\n  return h>>>0; }\n// Cor estável por código de setor — mesmo hash FNV-1a já usado em optSeed, só que aqui memoizado por\n// sessão inteira (não por etapa): o mesmo código sempre cai na mesma cor da PALETTE em QUALQUER\n// etapa do mapa, ao contrário de mv.color (que reindexa por etapa). É o que deixa um município\n// realocado pra um setor que não é candidato agora (ou já foi fechado numa etapa anterior) mostrar\n// uma cor de verdade em vez de cair em cinza \"sem info\".\nconst STABLE_SECTOR_COLOR={};\nfunction colorForSector(code){\n  const k=String(code);\n  if(STABLE_SECTOR_COLOR[k]) return STABLE_SECTOR_COLOR[k];\n  let h=2166136261>>>0;\n  for(let i=0;i<k.length;i++) h=Math.imul(h^k.charCodeAt(i),16777619);\n  const col=PALETTE[(h>>>0)%PALETTE.length];\n  STABLE_SECTOR_COLOR[k]=col;\n  return col;\n}\n// Assigns colors for ONE movement's destination list, guaranteeing no two of them share a color\n// when destList.length<=PALETTE.length. colorForSector alone can't promise that: it's a hash mod\n// PALETTE.length, so two different sector codes routinely land on the same slot (the reported bug —\n// two destinations in the same span/legend/map view painted the same color). First pass keeps each\n// dest's normal stable hash color where it's still free; only actual collisions get bumped to an\n// unused palette slot. Falls back to the stable hash color (repeats become unavoidable) once\n// destList has more entries than PALETTE has colors.\nfunction assignDestColors(destList){\n  const colors={}; const used=new Set();\n  destList.forEach(code=>{\n    const c=colorForSector(code);\n    if(!used.has(c)){ colors[code]=c; used.add(c); }\n  });\n  destList.forEach(code=>{\n    if(colors[code]) return;\n    const free=PALETTE.find(c=>!used.has(c));\n    const chosen=free||colorForSector(code);\n    colors[code]=chosen; used.add(chosen);\n  });\n  return colors;\n}\nfunction contigBadFor(mv,assign){\n  // Connectivity is enforced at every level now: a setor closing redistributes municípios, a GV\n  // closing redistributes setores, a mercado closing redistributes GVs — whichever grain, every\n  // unit sent to the same destination must reach it through mv.closeAdj without leaving the group,\n  // or that destination counts as broken. mv.closeAdj/mv.allowed are already built at unit grain\n  // for every level (see computeMovement), so the same BFS below just works unchanged.\n  const bySec={}; for(const m in assign){(bySec[assign[m]]=bySec[assign[m]]||[]).push(m);}\n  const bad=[];\n  for(const s in bySec){const grp=bySec[s],inGrp=new Set(grp);\n    // A município already split across several real sectors (mv.muniSiblings) is inherently\n    // \"connected\" to any of its own other real owners — sending more of an already-shared\n    // município to a sector that already owns part of it isn't fragmenting new territory, so it\n    // seeds (and never itself breaks) that destination's group even with zero same-sector\n    // closeAdj neighbours of its own. The general chain-connectivity rule below is meant for a\n    // whole, single-sector município moving as one piece; it was never meant to gate this case —\n    // without this, a click on a shared município's own slice (São Paulo etc.) could only ever\n    // land on whichever one destination happened to pass the general geographic-chain check.\n    const seeds=grp.filter(m=>(mv.allowed[m]||{})[s]||(mv.muniSiblings&&mv.muniSiblings[m]&&mv.muniSiblings[m].includes(s)));\n    if(!seeds.length){bad.push(s);continue;}\n    const seen=new Set(seeds),st=[...seeds];\n    while(st.length){const m=st.pop();(mv.closeAdj[m]||[]).forEach(o=>{if(inGrp.has(o)&&!seen.has(o)){seen.add(o);st.push(o);}});}\n    if(seen.size!==grp.length)bad.push(s);}\n  return bad;\n}\n// Usada pelo clique no mapa quando \"forçar conectividade de território\" está ligado: em vez de\n// avançar cegamente pro próximo destino da lista, pula candidatos que introduziriam uma quebra NOVA\n// (compara contra o baseBad de ANTES do clique, então uma quebra pré-existente alheia a este\n// município não trava o ciclo pra sempre). Sem candidato seguro, mantém o destino atual.\nfunction nextContigSafeDest(mv,m){\n  const n=mv.dest.length; if(!n) return mv.assign[m];\n  const cur=mv.assign[m];\n  const baseBad=new Set(contigBadFor(mv,mv.assign));\n  const i0=mv.dest.indexOf(cur);\n  for(let k=1;k<=n;k++){\n    const cand=mv.dest[((i0<0?0:i0)+k)%n];\n    if(cand===cur) continue;\n    const trial={...mv.assign,[m]:cand};\n    if(!contigBadFor(mv,trial).some(s=>!baseBad.has(s))) return cand;\n  }\n  return cur;\n}\nfunction greedyFor(mv,ms,opts){const lim=spanLimits(mv);const a={};\n  ms.forEach((m,i)=>{let best=opts[i][0],bd=Infinity;\n    for(const s of opts[i]){a[m]=s;\n      // over-max only grows as we keep assigning, so it is safe to penalise mid-build;\n      // under-min shrinks, so penalising it here would just chase noise.\n      const over=lim.max?spanViolationsOf(spanMapFor(mv,a),{max:lim.max,min:null}).over.length:0;\n      const d=optScoreFor(mv,a)+(contigBadFor(mv,a).length?1e9:0)+over*1e6;\n      if(d<bd){bd=d;best=s;}}\n    a[m]=best;});\n  return a;}\nfunction optimizeFor(mv){\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__');\n  if(!ms.length||!mv.dest.length){ mv.optBadge=mv.dest.length?'':'sem setor destino identificado'; return {}; }\n  const opts=ms.map(m=>{const al=(mv.reachable[m]&&mv.reachable[m].length)?mv.reachable[m]:mv.dest.slice();return al.length?al:mv.dest.slice();});\n  const combos=opts.reduce((a,o)=>a*o.length,1);\n  const lim=spanLimits(mv);\n  const dmax=optDistPrep(mv); mv.__optDmax=dmax;\n\n  // Feasibility ladder — same meaning as before, because renderSpanRule and the saved sessions\n  // both read mv.optTier: 0 = contíguo + max + min span, 1 = contíguo + max span, 2 = contíguo\n  // only. Every candidate any simulation produces is offered to all three tiers; we ship the\n  // strictest tier that got a solution, so an unreachable min/max relaxes loudly instead of\n  // stranding the stage with nothing.\n  const tier=[null,null,null],tSc=[Infinity,Infinity,Infinity],tEv=[null,null,null];\n  let feasible=0,evals=0;\n  let ctgBest=null,ctgCost=Infinity,ctgEv=null,ctgBad=null;\n  const offer=(a,ev)=>{\n    const badSectors=contigBadFor(mv,a);\n    if(badSectors.length){\n      if(ev.cost<ctgCost){ ctgCost=ev.cost; ctgBest={...a}; ctgEv=ev; ctgBad=badSectors; }\n      return false;\n    }\n    feasible++;\n    const put=t=>{ if(ev.cost<tSc[t]){ tSc[t]=ev.cost; tier[t]={...a}; tEv[t]=ev; } };\n    put(2);\n    if(!ev.viol.over.length){ put(1); if(!ev.viol.under.length) put(0); }\n    return true;\n  };\n  const evalOf=a=>{ evals++; return optEval(mv,a,dmax); };\n\n  // Small enough to settle the question outright: enumerate every contiguous allocation and the\n  // winner is provably the best one under this objective. No search needed, so none is run.\n  const exhaustive=combos<=OPT_EXHAUSTIVE_MAX;\n  let sims=0;\n  if(exhaustive){\n    const cur={};\n    const rec=i=>{ if(i===ms.length){ offer(cur,evalOf(cur)); return; }\n      for(const s of opts[i]){ cur[ms[i]]=s; rec(i+1); } };\n    rec(0);\n  } else {\n    // Too big to enumerate, so run a portfolio of independent simulations instead. Each one is a\n    // seed assignment, then hill-climbing on single-município reassignments, then one perturbation\n    // and another climb to escape whatever local optimum it settled into. Different seeds land in\n    // different basins; the ladder above keeps the best across all of them.\n    const improve=a=>{\n      let ev=evalOf(a); offer(a,ev);\n      for(let pass=0;pass<OPT_PASSES;pass++){\n        let moved=false;\n        for(let i=0;i<ms.length;i++){\n          const m=ms[i],cur=a[m]; let best=cur,bev=ev;\n          for(const s of opts[i]){\n            if(s===cur) continue;\n            a[m]=s;\n            const e=evalOf(a);\n            // contiguity is a hard constraint, and the cheaper test runs first on purpose\n            if(e.cost<bev.cost-1e-12 && !contigBadFor(mv,a).length){ best=s; bev=e; }\n          }\n          a[m]=best;\n          if(best!==cur){ ev=bev; moved=true; offer(a,ev); }\n        }\n        if(!moved) break;\n      }\n    };\n    const rng=optRng(optSeed(mv,ms));\n    const perSim=Math.max(1,ms.length*Math.max(1,mv.dest.length)*OPT_PASSES);\n    const budget=Math.max(4,Math.min(OPT_SIMS,Math.floor(OPT_EVAL_BUDGET/perSim)||4));\n    const seeds=[];\n    if(mv.assign&&ms.every(m=>mv.assign[m])) seeds.push({...mv.assign});   // never regress on what is already on screen\n    seeds.push(autoAssignFor(mv));                                        // adjacency-first seed\n    seeds.push(greedyFor(mv,ms,opts));                                    // balance-first seed\n    while(seeds.length<budget){\n      // randomised construction biased toward nearby sectors — the bias keeps each seed sane, the\n      // randomness is what sends the runs into different basins\n      const a={};\n      ms.forEach((m,i)=>{ let bs=opts[i][0],bw=-Infinity;\n        for(const s of opts[i]){ const w=(1-optDistCost(mv,m,s,dmax))+rng()*0.9; if(w>bw){bw=w;bs=s;} }\n        a[m]=bs; });\n      seeds.push(a);\n    }\n    seeds.forEach(seed=>{\n      const a={...seed};\n      // a restored or legacy assign can still name a sector this stage no longer offers\n      ms.forEach((m,i)=>{ if(!opts[i].includes(a[m])) a[m]=opts[i][0]; });\n      improve(a);\n      const kick=Math.max(1,Math.round(ms.length*0.25));\n      for(let k=0;k<kick;k++){ const i=Math.floor(rng()*ms.length); a[ms[i]]=opts[i][Math.floor(rng()*opts[i].length)]; }\n      improve(a);\n    });\n    sims=seeds.length;\n  }\n\n  const t=tier.findIndex(x=>x);\n  if(t<0){\n    mv.optTier=-1; mv.optBadge='sem solução conectada — heurística';\n    mv.optAlternatives={ tiers:[], contigBroken: ctgBest?{assign:ctgBest, ev:ctgEv, badSectors:ctgBad}:null };\n    return greedyFor(mv,ms,opts);\n  }\n  const best=tier[t], ev=tEv[t]; mv.optTier=t;\n  const relax=[];\n  if(t>0 && lim.min) relax.push('span mínimo '+fmt(lim.min));\n  if(t>1 && lim.max) relax.push('span máximo '+fmt(lim.max));\n  mv.optStats={balance:ev.balance,dist:ev.dist,desloc:ev.desloc,stranded:ev.stranded,sims,evals,feasible,combos,exhaustive,split:ev.splitTerm};\n  const hasSplitMunis=Object.keys(mv.muniSiblings||{}).some(m=>(mv.muniSiblings[m]||[]).some(s=>mv.dest.includes(s)));\n  mv.optBadge=`ótimo · ${exhaustive?`espaço completo (${fmt(combos)})`:`${fmt(sims)} simulações`}`\n    +` · equilíbrio ±${Math.round(ev.balance*100)}% · adjacência ${Math.round((1-ev.dist)*100)}%`\n    +` · quebra ${fmt(ev.desloc)} CBs · ${fmt(ev.stranded)} fora do setor sem destino`\n    +(hasSplitMunis?` · consolidação de município compartilhado ${Math.round((1-ev.splitTerm)*100)}%`:'')\n    +` · pico ${fmt(maxSpanOfFor(mv,best))} CBs`\n    +(lim.target?` · alvo ${fmt(lim.target)}`:'')\n    +(relax.length?` · ⚠ inatingível: ${relax.join(' + ')}`:(spanLimitsOn(lim)?' · limites ok':''));\n  mv.optAlternatives={\n    tiers:[0,1,2].filter(i=>i!==t && tier[i]).map(i=>({assign:tier[i], ev:tEv[i], tierIndex:i})),\n    contigBroken: ctgBest?{assign:ctgBest, ev:ctgEv, badSectors:ctgBad}:null,\n  };\n  return best;\n}\nfunction computeDerived(mv){\n  const inc={},gained={},orph={};mv.dest.forEach(s=>{inc[s]=0;gained[s]=0;orph[s]=0;});\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(s&&inc[s]!=null)inc[s]+=o.cbs;});\n  let desloc=0,orphanTotal=0,orphanStranded=0;\n  const L2=mv.leaders.map(L=>{const byS={};mv.dest.forEach(s=>byS[s]=0);let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=mv.assign[m];if(s&&byS[s]!=null){byS[s]+=c;tot+=c;}}\n    // no allocated CBs => no home. Picking dest[0] by default would silently dump this leader's\n    // orphan CBs into an arbitrary sector, so we leave it null and report them as stranded.\n    const home=(tot>0&&mv.dest.length)?mv.dest.reduce((a,s)=>byS[s]>byS[a]?s:a,mv.dest[0]):null;\n    const dl=tot-(home?byS[home]:0);desloc+=dl;if(home)gained[home]++;\n    const orphan=(mv.orphanByGroup&&mv.orphanByGroup[L.grupo])||0;\n    orphanTotal+=orphan;\n    if(orphan){ if(home&&orph[home]!=null) orph[home]+=orphan; else orphanStranded+=orphan; }\n    return {...L,byS,home,desloc:dl,assignedTotal:tot,orphan,orphanHome:(orphan&&home)?home:null};});\n  const span={};mv.dest.forEach(s=>span[s]=(+(mv.baseMetrics[s]?.cbs||0))+inc[s]+orph[s]);\n  return {inc,gained,desloc,leaders:L2,span,orph,orphanTotal,orphanStranded};\n}\n\nasync function computeMovement(code,level,world){\n  level=level||'setor';\n  code=String(code);\n  const mv={sector:code,level,policy:'fora',view:'tobe',selMuni:null,optBadge:''};\n  const SNAP=SESS.snapLiteral;\n  // GV/mercado precisam da base ANTES de montar a query (pra saber quais CD_SETOR entram no IN);\n  // setor não precisa — mantém o timing original (a query dispara sem esperar o CSV).\n  let domBase=level==='setor'?null:await ensureDominantes();\n  // GV/mercado destinations are ORGANIZATIONAL siblings (same mercado / any mercado at all), never\n  // discovered by geographic adjacency the way setor destinations are — a sibling can sit UFs away\n  // from the closing unit's own footprint, in territory the geo-fetch below (scoped to the CLOSING\n  // unit's own UFs, see the comment further down) never requests a shape for. Resolved here, early,\n  // because everything it needs (DOM.*, world) is already available before any SQL/geo fetch runs —\n  // folded into ufSet alongside the closing unit's own UFs so a valid sibling's territory always has\n  // a shape to draw instead of leaving a hole exactly where it sits.\n  const extraDestUFs=new Set();\n  if(level!=='setor' && domBase){\n    const ownParentEarly=parentOf(level,code);\n    const siblingsEarly = level==='gv' ? [...(DOM.gvsByMercado[ownParentEarly]||[])] : Object.keys(DOM.sectorsByMercado||{});\n    siblingsEarly.filter(s=>s!==code).forEach(destCode=>{\n      memberSectorsOf(level,destCode,world).forEach(s=>{\n        (domBase[String(s)]||new Set()).forEach(entry=>{ const u=mUf(entry); if(u) extraDestUFs.add(u); });\n      });\n    });\n  }\n  const memberSectors=level==='setor'?[code]:memberSectorsOf(level,code,world);\n  if(!memberSectors.length) throw new Error(`${LEVELS[level].label} ${code} não existe na base de municípios dominantes (CSV) — atualize a base antes de fechar`);\n  const secList=memberSectors.join(',');\n\n  const mSQL=`SELECT DS_MUNICIPIO municipio, CD_SETOR setor, CD_GRUPO grupo, MAX(DS_LEADER_NAME) lider,\n     COUNT(DISTINCT CD_CONSULTORA) cbs, ROUND(SUM(VL_PEDIDOS_CICLO),0) receita,\n     ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat, ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon,\n     ROUND(SUM(QT_PONTOS_CICLO),0) pontos, ROUND(SUM(QT_PEDIDOS_CICLO),0) pedidos,\n     SUM(CASE WHEN ST_FEZ_PED_CICLO_ATUAL THEN 1 ELSE 0 END) ativas\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${secList}) AND DT_HISTORICO=${SNAP}\n    GROUP BY DS_MUNICIPIO, CD_SETOR, CD_GRUPO`;\n  const ufSQL=`SELECT DS_ESTADO uf, COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${secList}) AND DT_HISTORICO=${SNAP} GROUP BY DS_ESTADO ORDER BY n DESC`;\n  const trendSQL=`SELECT operational_cycle ciclo, COUNT(DISTINCT person_code) cbs,\n     COUNT(DISTINCT CASE WHEN registered_bc_ind=1 THEN person_code END) ativas,\n     ROUND(SUM(revenue_nat_ss_amt),0) receita, ROUND(SUM(points_nat_ss_amt),0) pontos\n    FROM ${ELO} WHERE country_code=1 AND sector_code IN (${secList}) AND operational_cycle<=${SESS.eloMaxCycle||999999} GROUP BY operational_cycle ORDER BY operational_cycle DESC LIMIT 8`;\n  const lvlSQL=`SELECT DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs,\n     ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat, ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${secList}) AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY DS_NVL_PLAN_CRESC ORDER BY cbs DESC`;\n  const lvlMuniSQL=`SELECT DS_MUNICIPIO municipio, CD_SETOR setor, DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${secList}) AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY DS_MUNICIPIO, CD_SETOR, DS_NVL_PLAN_CRESC`;\n  const [mR,ufR,tR,lR,lmR]=await Promise.all([q(mSQL),q(ufSQL),q(trendSQL),q(lvlSQL),q(lvlMuniSQL)]);\n\n  const ownMatrix=rows(mR).filter(r=>+r.cbs>0);\n  mv.pureOwnMetrics=blankM();\n  ownMatrix.forEach(r=>{ mv.pureOwnMetrics.cbs+=+r.cbs; mv.pureOwnMetrics.receita+=+r.receita||0; mv.pureOwnMetrics.rec_nat+=+r.rec_nat||0; mv.pureOwnMetrics.rec_avon+=+r.rec_avon||0; mv.pureOwnMetrics.pontos+=+r.pontos||0; mv.pureOwnMetrics.pedidos+=+r.pedidos||0; mv.pureOwnMetrics.ativas+=+r.ativas||0; });\n\n  mv.levelByMuni={};\n  if(level==='setor') Object.entries(world.muniLevel||{}).forEach(([m,lv])=>{ if(world.muniOverride[m]===code) mv.levelByMuni[m]={...lv}; });\n\n  mv.trend=rows(tR).map(r=>({...r,cbs:+r.cbs,ativas:+r.ativas,receita:+r.receita,pontos:+r.pontos})).filter(r=>r.cbs>10).reverse();\n  mv.levels=rows(lR);\n\n  let absorbedRows=[],inheritedOrphanRows=[];\n  if(level==='setor'){\n    Object.entries(world.muniOverride).forEach(([m,destSec])=>{ if(destSec===code && world.muniRow[m]) absorbedRows.push(...world.muniRow[m]); });\n    inheritedOrphanRows=(world.sectorOrphanRows&&world.sectorOrphanRows[code])||[];\n  }\n  mv.matrix=ownMatrix.concat(absorbedRows,inheritedOrphanRows);\n\n  const ufList=rows(ufR).map(r=>r.uf).filter(Boolean);\n  const domUF=ufList[0]||'PR';\n  mv.domUF=domUF;\n  domBase=domBase||await ensureDominantes();\n  // Uma GV ou mercado pode espalhar por mais UFs do que \"a UF dominante + suas vizinhas\" cobre —\n  // toda UF que aparece na base de dominantes de QUALQUER setor-membro entra na janela de shapes.\n  const memberUFs=new Set([domUF]);\n  memberSectors.forEach(s=>{ (domBase[String(s)]||new Set()).forEach(entry=>{ const u=mUf(entry); if(u) memberUFs.add(u); }); });\n  const ufSet=new Set(memberUFs);\n  memberUFs.forEach(u=>(UF_BORDERS[u]||[]).forEach(b=>ufSet.add(b)));\n  extraDestUFs.forEach(u=>ufSet.add(u));\n  mv.ufList=[...ufSet];\n  const geos=await Promise.all([...ufSet].map(u=>geoUF(u).catch(()=>null)));\n  const allFeat=geos.filter(Boolean).flatMap(g=>g.features);\n  const adj=buildAdj(allFeat);\n\n  // A UF só vem da base de dominantes (setor real da linha × município), nunca de \"esse nome só\n  // apareceu numa UF carregada\" ou de um chute pra UF dominante do fechamento inteiro. Os dois\n  // eram seguros quando só 1-3 UFs entravam na janela (fechamento de setor); GV e principalmente\n  // mercado carregam dezenas de UFs de uma vez, então \"único entre as UFs carregadas\" deixa de\n  // significar \"único no Brasil\" — era exatamente esse chute que colocava um homônimo de outra UF\n  // espalhado longe do resto do mapa. Sem entrada na base, o município fica sem UF (mesmo\n  // tratamento de \"sem shape\" que já existe) em vez de arriscar aparecer no lugar errado.\n  const ufGuessed={};\n  const ufFor=(setorOf,muni)=>{const nm=norm(muni);\n    return (DOM.ufBySetorMuni&&DOM.ufBySetorMuni[String(setorOf)+'|'+nm])||'';};\n  mv.matrix.forEach(r=>{ if(!r.uf) r.uf=ufFor(r.setor,r.municipio); });\n  rows(lmR).forEach(r=>{ const k=mkey(r.municipio,ufFor(r.setor,r.municipio)); (mv.levelByMuni[k]=mv.levelByMuni[k]||{})[r.nivel]=+r.cbs; });\n\n  const totBy={}; mv.matrix.forEach(r=>{const n=mkey(r.municipio,r.uf); totBy[n]=(totBy[n]||0)+ +r.cbs;});\n  const footprint=new Set(Object.entries(totBy).filter(([,c])=>c>0).map(([m])=>m));\n  const withShape=[...footprint].filter(n=>adj.feat[n]||world.muniGeo[n]);\n  mv.cbsByMuni={...totBy}; mv.shapeMunis=withShape.slice();\n  mv.noShapeMunis=[...footprint].filter(n=>!(adj.feat[n]||world.muniGeo[n])).map(n=>({muni:n,cbs:totBy[n]||0})).sort((a,b)=>b.cbs-a.cbs);\n  mv.noShapeCbs=mv.noShapeMunis.reduce((a,x)=>a+x.cbs,0);\n\n  // ---- RULE: quais municípios pertencem a este fechamento ----\n  // setor: exatamente a regra de hoje. GV/mercado: união da base de TODOS os setores-membro — a\n  // base já garante que um setor pertence a uma única GV/mercado, então não existe aqui o spillover\n  // fuzzy que município tem contra setor (por isso o orphan-handling abaixo é pulado nesses níveis).\n  let domOwn;\n  if(level==='setor'){\n    domOwn=domBase[code]||new Set();\n  } else {\n    domOwn=new Set();\n    memberSectors.forEach(s=>(domBase[String(s)]||new Set()).forEach(n=>domOwn.add(n)));\n  }\n  const inherited=level==='setor'?new Set(Object.keys(world.muniOverride||{}).filter(m=>world.muniOverride[m]===code)):new Set();\n  if(!domOwn.size && !inherited.size) throw new Error(`${LEVELS[level].label} ${code} não existe na base de municípios dominantes (CSV) — atualize a base antes de fechar`);\n  const belongs=n=>domOwn.has(n)||(!DOM.hasUf&&domOwn.has(mName(n)))||inherited.has(n);\n  mv.domCsvMunis=domOwn.size;\n  // KPI \"Municípios fora do setor\" (matriz ASIS/TOBE): CBs por grupo cujo município — direto da\n  // linha crua do CRM em mv.matrix, sem passar por nenhuma camada intermediária — não está listado\n  // na base de dominantes (dominantesCsvUrl) para ESTE setor. Checagem direta contra domOwn/\n  // inherited, independente de já ter (ou não) shape IBGE desenhado — deliberadamente NÃO reaproveita\n  // a marcação `_out`/geoFeat de mv.orphanByGroup (aggregateMovement), que existe pra decidir pra\n  // onde a CB vai na redistribuição (mecânica de simulação, restrita a quem tem shape), não pra\n  // reportar \"está fora da base ou não\" — um município sem shape na UF carregada também conta aqui.\n  mv.foraDoSetorByGrupo={};\n  if(level==='setor'){\n    mv.matrix.forEach(r=>{\n      const m=mkey(r.municipio,r.uf);\n      if(belongs(m)) return;\n      const g=String(r.grupo);\n      mv.foraDoSetorByGrupo[g]=(mv.foraDoSetorByGrupo[g]||0)+ +r.cbs;\n    });\n  }\n  // Município da base de dominantes sem NENHUM CB neste snapshot ainda pertence ao setor — a base é\n  // a fonte de verdade de posse, não o CRM do ciclo. Sem isto ele nunca entra em closingNames, nunca\n  // recebe destino em mv.assign (foldWorldState pula município sem mv.assign[m] ao dobrar o mundo) e\n  // continua aparecendo pra sempre como se o setor dissolvido ainda existisse (.closed-ctx) em\n  // qualquer etapa futura da timeline, mesmo depois desta etapa já ter decidido pra onde ele vai.\n  // zeroCbNames marca esses municípios pra excluir do VOTO de adjacência mais abaixo — sem CB pra\n  // realocar, um município isolado (ou geograficamente distante do miolo real do setor, um problema\n  // comum de qualidade da base CSV que ficava invisível enquanto ele tinha 0 CBs e nunca era\n  // desenhado) não pode sozinho puxar um destino novo e desconexo pro mv.dest; ele ainda é desenhado\n  // e ainda recebe um destino real, via o fallback de optimizeFor pro mv.dest já estabelecido pelos\n  // votos reais.\n  // Belongs to EVERY level now, not just setor: a GV/mercado closure needs the exact same treatment\n  // for a município whose owning setor has zero live CBs right now — the base (CSV) is still the\n  // source of truth for territory, not the current cycle's CRM snapshot. Left setor-gated, this\n  // município (and everything drawn from it — mv.geoFeat, mv.unitMunis, the whole coloured area) simply\n  // never entered the map at GV/mercado grain: a real hole exactly where real territory is.\n  const zeroCbNames=new Set();\n  domOwn.forEach(n=>{\n    if(footprint.has(n)) return;\n    const feat=adj.feat[n]||world.muniGeo[n]; if(!feat) return;\n    totBy[n]=0; mv.cbsByMuni[n]=0; footprint.add(n); withShape.push(n); zeroCbNames.add(n);\n  });\n  const dropped=level==='setor'?withShape.filter(n=>!belongs(n)):[];\n  if(withShape.length && dropped.length===withShape.length) throw new Error(`${LEVELS[level].label} ${code}: nenhum dos municípios com CBs está na base de dominantes — nada pra realocar`);\n  const droppedSet=new Set(dropped);\n  mv.excludedMunis=dropped.map(n=>({muni:n,cbs:totBy[n]||0})).sort((a,b)=>b.cbs-a.cbs);\n  mv.excludedCbs=dropped.reduce((a,n)=>a+(totBy[n]||0),0);\n\n  const closingNames=withShape.filter(n=>!droppedSet.has(n));\n  const closingSet=new Set(closingNames);\n  mv.geoFeat={}; closingNames.forEach(n=>{ mv.geoFeat[n]=(adj.feat[n]&&adj.feat[n].f)||world.muniGeo[n]; });\n\n  // ---- unidade de realocação: a geometria é sempre município; a UNIDADE que troca de dono é o\n  // próprio município (setor fecha), o setor inteiro (GV fecha) ou a GV inteira (mercado fecha).\n  const municipioSetor={}; mv.matrix.forEach(r=>{ const n=mkey(r.municipio,r.uf); if(!municipioSetor[n]) municipioSetor[n]=String(r.setor); });\n  const resolveUnit=n=>{\n    if(level==='setor') return n;\n    // A zero-CB município (injected above from the CSV) has no mv.matrix row at all, so\n    // municipioSetor never learns its setor from the live snapshot — fall back to the same CSV base\n    // everything else here already trusts (DOM.setorByMuni), instead of losing it from its unit.\n    let sOf=municipioSetor[n]||DOM.setorByMuni[n]; if(!sOf) return '';\n    // A setor that already closed EARLIER in this session (a setor-level etapa before this GV/mercado\n    // one) no longer really owns n — n's live CD_SETOR still says otherwise (the CRM snapshot never\n    // learns about a simulated closure), so without this n kept showing up as belonging to a setor\n    // that's supposed to be gone, as if that etapa never happened. world.muniOverride already carries\n    // the fully-resolved (chain-collapsed through any number of prior setor closures) current owner.\n    if(world.closedSectors.has(sOf) && world.muniOverride[n]) sOf=world.muniOverride[n];\n    if(level==='gv') return sOf;\n    // mercado: same idea one level up — this setor's GV may have already closed (a gv-level etapa\n    // earlier in this session) and sent it to a sibling GV. world.secOverride carries that redirect\n    // (built the same way foldWorldState builds muniOverride, just one level up).\n    return (world.secOverride && world.secOverride[sOf]) || DOM.gvBySetor[sOf] || '';\n  };\n  mv.unitOfMuni={}; closingNames.forEach(n=>{ mv.unitOfMuni[n]=resolveUnit(n); });\n  mv.unitMunis={}; closingNames.forEach(n=>{ const u=mv.unitOfMuni[n]; if(!u) return; (mv.unitMunis[u]=mv.unitMunis[u]||[]).push(n); });\n  const units=level==='setor'?closingNames:Object.keys(mv.unitMunis);\n\n  const drawnCsv=new Set(closingNames.map(n=>DOM.hasUf?n:mName(n)));\n  const footKeyOf=k=>DOM.hasUf?(footprint.has(k)?k:null):([...footprint].find(x=>mName(x)===k)||null);\n  mv.csvMunisTotal=domOwn.size;\n  mv.ufGuessed={...ufGuessed};\n  if(level==='setor'){\n    mv.missingMunis=[...domOwn].filter(k=>!drawnCsv.has(k)).map(k=>{\n      const fk=footKeyOf(k);\n      const hasShape=!!(adj.feat[fk||k]||world.muniGeo[fk||k]);\n      const alias=[...footprint].filter(x=>x!==k&&mName(x)===mName(k));\n      let reason;\n      if(!fk&&alias.length) reason=`UF divergente — as CBs entraram na chave ${alias.join(' / ')} e a base diz ${k}`;\n      else if(!fk) reason=hasShape?'sem CBs neste setor no snapshot — desenhado como vizinho':'sem CBs neste setor no snapshot';\n      else if(!hasShape) reason='sem shape nas UFs carregadas';\n      else reason='chave não casou com a base de dominantes';\n      return {muni:k,cbs:(fk?totBy[fk]:alias.reduce((a,x)=>a+(totBy[x]||0),0))||0,reason:reason};\n    });\n  } else {\n    mv.missingMunis=[];\n  }\n\n  // ---- HARD RULE: destino tem de compartilhar a fronteira certa acima do que está fechando ----\n  // setor → mesma GV; GV → mesmo mercado; mercado → nada acima, qualquer vizinho vale.\n  const ownParent=parentOf(level,code);\n  if(level!=='mercado' && !ownParent) throw new Error(`${LEVELS[level].label} ${code} não tem ${level==='setor'?'CD_GV':'MERCADO'} na base de municípios dominantes — sem essa fronteira não há como garantir que os destinos ficam do lado certo da hierarquia`);\n  const sameParent=destCode=>!ownParent||parentOf(level,destCode)===ownParent;\n  mv.gv=ownParent;\n  const alreadyClosed=destCode=>level==='setor'?world.closedSectors.has(destCode):(level==='gv'?(world.closedGvs||new Set()).has(destCode):(world.closedMercados||new Set()).has(destCode));\n\n  // ---- município compartilhado por vários setores (São Paulo etc.): o setor fechando é dono só de\n  // UMA fatia dele, não da cidade inteira — sem isto a camada `.muni` interativa pintava (e deixava\n  // clicar) o município INTEIRO com a cor do destino escolhido, mesmo a maior parte dele pertencendo\n  // a outros setores sem nenhuma relação com este fechamento. Movido pra DEPOIS de mv.gv/sameParent/\n  // alreadyClosed existirem (não era mais logo após mv.geoFeat) porque a fatia de cada sibling agora\n  // depende de quem é candidato válido — só dá pra saber isso com a GV do fechamento já resolvida.\n  // O pedaço que fecha mantém o tamanho de sempre (1 de N reais, N = todos os siblings de verdade);\n  // só o RESTANTE é refatiado, e agora só entre quem qualifica (mesma GV, ainda não fechado, setor\n  // real) — sem isso, cada sibling inválido continuava ocupando sozinho a largura original da sua\n  // fatia em cinza, em vez dessa largura ir pros siblings válidos de verdade (mesma regra \"só corta\n  // pelos válidos\" que a camada de vizinhos, em drawMap, já aplica). mv.sharedSiblingCodes guarda\n  // TODO sibling real visto aqui (válido ou não) — o bookkeeping de \"rejeitado por GV errada\" mais\n  // abaixo lê daqui, não mais de mv.sharedMuniSlices, porque esse array agora só carrega os já-\n  // válidos (ou uma fatia única com sector:null, quando nenhum sibling qualifica).\n  mv.sharedMuniSlices=[];\n  mv.sharedSiblingCodes=new Set();\n  // Real sector-sharing per município (independent of geometry/geoFeat) — feeds the ★ ÓTIMO \"split\"\n  // objective below: a município left split across multiple real sectors after this closure is a\n  // structural defect on its own, so the optimizer is biased toward routing a shared município back\n  // to whichever OTHER real sector already owns a piece of it, consolidating instead of scattering it\n  // to an unrelated destination.\n  mv.muniSiblings={};\n  if(level==='setor'){\n    const isValidSibling=s=>SESS.sectorsList.some(x=>x.setor===s)&&!alreadyClosed(s)&&sameParent(s);\n    closingNames.forEach(n=>{\n      const realOwners=DOM.setoresByMuni&&DOM.setoresByMuni[n];\n      // A município that's only here via `inherited` (this sector received it whole, via muniOverride,\n      // from an earlier etapa's closure) never had `code` as one of its REAL (CSV) owners — the raw\n      // base still lists whatever sector really owned it. Without this guard, that static list (minus\n      // `code`, which was never in it to begin with) reads as \"other real siblings\" and this município\n      // gets sliced/hatched as if genuinely shared, even though it belongs to `code` wholly and only.\n      if(!realOwners||!realOwners.has(code)) return;\n      const siblings=[...realOwners].filter(s=>s!==code);\n      if(siblings.length) mv.muniSiblings[n]=siblings;\n      if(!siblings.length||!mv.geoFeat[n]) return;\n      siblings.forEach(s=>mv.sharedSiblingCodes.add(s));\n      const orig=mv.geoFeat[n];\n      // Stashed before mv.geoFeat[n] below gets overwritten with just this sector's own slice —\n      // drawMap's multi-owner outline (multiBorderFeats) wants the TRUE, single, un-cut polygon for\n      // a município shared with the closing sector itself, same as it already gets for a shared\n      // município that's merely a bordering neighbour (mv.nbrFeat, never sliced to begin with).\n      mv.muniFullShape=mv.muniFullShape||{}; mv.muniFullShape[n]=orig;\n      const owners=[code,...siblings].sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      const slices=sliceFeatureEqual(orig,owners.length);\n      const mine=slices[owners.indexOf(code)];\n      if(mine&&!mine.__empty) mv.geoFeat[n]=mine;\n      const remParts=slices.filter((sl,i)=>owners[i]!==code&&sl&&!sl.__empty).flatMap(sl=>sl.geometry.coordinates);\n      if(!remParts.length) return;\n      const remainder={type:'Feature',properties:orig.properties,__uf:orig.__uf,geometry:{type:'MultiPolygon',coordinates:remParts}};\n      const valid=siblings.filter(isValidSibling).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      sliceFeatureEqual(remainder,valid.length||1).forEach((sl,i)=>{\n        if(sl.__empty) return;\n        mv.sharedMuniSlices.push({feature:sl,sector:valid.length?valid[i]:null,muni:n});\n      });\n    });\n  }\n\n  mv.allowed={}; mv.allowedRaw={};\n  units.forEach(u=>{ mv.allowed[u]={}; mv.allowedRaw[u]={}; });\n  mv.rejectedCodes=new Set(); mv.rejectedInfo={};\n\n  const extVotes={}; const nbrNames=new Set(); let domSecOf={};\n  if(level==='setor'){\n    // setor: destino descoberto por ADJACÊNCIA GEOGRÁFICA de município — uma query ao vivo por\n    // vizinho, mas o perímetro de UM setor é pequeno o bastante pra valer a pena.\n    closingNames.forEach(n=>{\n      if(!adj.feat[n]) return;\n      if(zeroCbNames.has(n)) return;\n      adj.nbrs(n).forEach(({o,w})=>{ if(!closingSet.has(o) && !droppedSet.has(o)){ nbrNames.add(o); (extVotes[n]=extVotes[n]||[]).push({o,w}); } });\n    });\n    // Setor dominante de cada vizinho vem da base ESTÁTICA (DOM.setorByMuni), não de uma consulta ao\n    // vivo por contagem de CBs: a consulta antiga só devolvia linha pro município com CB ativo NESTE\n    // snapshot — um vizinho geograficamente real mas sem CB agora simplesmente não aparecia, então\n    // nunca virava rejeitado nem contexto (sumia do mapa em vez de aparecer cinza). A base de\n    // dominantes cobre todo município, com ou sem CB no momento, então nenhum vizinho fica de fora.\n    nbrNames.forEach(n=>{ domSecOf[n]=DOM.setorByMuni[n]; });\n    nbrNames.forEach(n=>{ if(world.muniOverride && world.muniOverride[n]) domSecOf[n]=world.muniOverride[n]; });\n\n    mv.nbrCurrentSector={};\n    mv.gvRejected=[];\n    const gvHidden=new Set();\n    nbrNames.forEach(n=>{\n      const destCode=destUnitOfSector(level,domSecOf[n],world);\n      if(destCode && !sameParent(destCode)){\n        gvHidden.add(n);\n        mv.nbrCurrentSector[n]=destCode;\n        mv.rejectedCodes.add(destCode);\n        if(!mv.rejectedInfo[destCode]) mv.rejectedInfo[destCode]={code:destCode,parent:parentOf('setor',destCode)};\n        if(mv.gvRejected.indexOf(destCode)<0) mv.gvRejected.push(destCode);\n        return;\n      }\n      if(domSecOf[n]) mv.nbrCurrentSector[n]=destCode;\n    });\n    // Rejected neighbours KEEP their nbrNames/nbrCurrentSector entry — only their votes are stripped\n    // below, never their geometry — so the map can gray them in with a hover explaining the mismatch\n    // instead of just erasing them from the world.\n    Object.keys(extVotes).forEach(n=>{ extVotes[n]=extVotes[n].filter(v=>!gvHidden.has(v.o)); });\n\n    // Shared-município siblings get the SAME hard GV rule as any other destination candidate — a\n    // São Paulo sibling owned by a different-GV setor is rejected context, never a silently-available\n    // destination. Reads mv.sharedSiblingCodes (every real sibling seen, valid or not), not\n    // mv.sharedMuniSlices — that array now only carries already-valid siblings (or a null-sector flat\n    // piece), so a cross-GV sibling would never surface here reading straight from it.\n    (mv.sharedSiblingCodes||new Set()).forEach(s=>{\n      if(!sameParent(s)){\n        mv.rejectedCodes.add(s);\n        if(!mv.rejectedInfo[s]) mv.rejectedInfo[s]={code:s,parent:parentOf('setor',s)};\n        if(mv.gvRejected.indexOf(s)<0) mv.gvRejected.push(s);\n      }\n    });\n\n    mv.bridgeMunis=closingNames.slice();\n    mv.ignoredBridges=[];\n\n    // Setor referenciado pela base de dominantes mas que não existe MAIS no CRM (código renumerado/\n    // retirado, erro de digitação na base) não pode virar destino de verdade — sem isto ele entra\n    // como destino \"válido\" com span sempre 0 (a query real nunca devolve linha nenhuma pra ele),\n    // indistinguível na tela de um setor genuinamente vazio. SESS.sectorsList é a MESMA checagem de\n    // existência que addMovement já usa pra recusar fechar um código que não existe no snapshot.\n    const validSectorCodes=new Set((SESS.sectorsList||[]).map(s=>s.setor));\n    const wAny={};\n    closingNames.forEach(n=>{\n      const u=n;\n      if(!mv.allowed[u]) return;\n      (extVotes[n]||[]).forEach(({o,w})=>{\n        const destCode=mv.nbrCurrentSector[o];\n        if(!destCode || destCode===code || alreadyClosed(destCode) || !validSectorCodes.has(destCode)) return;\n        mv.allowed[u][destCode]=(mv.allowed[u][destCode]||0)+w;\n        mv.allowedRaw[u][destCode]=(mv.allowedRaw[u][destCode]||0)+w;\n        wAny[destCode]=(wAny[destCode]||0)+w;\n      });\n    });\n    // A shared-território SIBLING (this closure literally owns a slice of the same município as\n    // destCode — mv.sharedSiblingCodes, populated above) can never earn a vote in the scan just\n    // above: it explicitly excludes the closing sector's OWN municípios as voters\n    // (`!closingSet.has(o)`, back where extVotes is built), and a shared município IS one of them —\n    // correctly, it must not vote for itself. But that left the closing sector's single most\n    // locally-relevant destination (literally the same município, split two ways) structurally\n    // unable to ever reach mv.dest, while muniTipHTML's isValidSiblingTarget tells the map hover\n    // it's \"válido\" on organisational grounds alone (same GV, exists, not closed — no geography\n    // required). Given a guaranteed slot here instead of merely entering a vote race it cannot win —\n    // ranked first, so it only bumps a weaker normal candidate out of the top-6 when genuinely out\n    // of room, never the other way around.\n    const validSiblings=[...(mv.sharedSiblingCodes||new Set())].filter(s=>validSectorCodes.has(s)&&!alreadyClosed(s)&&sameParent(s));\n    const votedRanked=Object.keys(wAny).sort((a,b)=>wAny[b]-wAny[a]);\n    mv.dest=[...new Set([...validSiblings,...votedRanked])].slice(0,6);\n    if(!mv.dest.length){\n      mv.dest=[...new Set(Object.values(mv.nbrCurrentSector))].filter(destCode=>destCode&&destCode!==code&&!alreadyClosed(destCode)&&sameParent(destCode)&&validSectorCodes.has(destCode)).slice(0,6);\n      units.forEach(u=>{ if(mv.allowed[u]&&!Object.keys(mv.allowed[u]).length){ mv.dest.forEach(destCode=>mv.allowed[u][destCode]=1); } });\n    }\n  } else {\n    // GV/mercado: destino é SEMPRE todo irmão válido na hierarquia — NUNCA descoberto por\n    // adjacência geográfica de município. Uma GV/mercado pode ter território espalhado por\n    // dezenas de UFs sem qualquer conexão física entre as partes (a hierarquia aqui é\n    // organizacional, não geográfica) — \"vizinho por fronteira de município\" não é só caro (a\n    // query ao vivo vira um IN-list de centenas de municípios e estoura timeout: foi exatamente\n    // isso que quebrou o fechamento de um mercado espalhado) — é a pergunta errada. A fronteira que\n    // já importa (mesma GV / mesmo mercado) é imposta pela regra de parentesco logo acima; dentro\n    // dela QUALQUER irmão é candidato, e o otimizador decide pelo equilíbrio de span e continuidade\n    // de líder, nunca por proximidade física — geografia não pesa aqui.\n    mv.nbrCurrentSector={};\n    mv.gvRejected=[];\n    mv.bridgeMunis=closingNames.slice();\n    mv.ignoredBridges=[];\n    const siblings=level==='gv'\n      ? [...(DOM.gvsByMercado[ownParent]||[])]\n      : Object.keys(DOM.sectorsByMercado||{});\n    mv.dest=siblings.filter(destCode=>destCode!==code && !alreadyClosed(destCode) && sameParent(destCode));\n    units.forEach(u=>{ mv.dest.forEach(destCode=>{ mv.allowed[u][destCode]=1; mv.allowedRaw[u][destCode]=1; }); });\n\n    // Geographically-touching GVs that fail the hierarchy rule (different mercado) — grayed on the\n    // map with a hover explaining why, using ONLY the static dominantes CSV (DOM.setorByMuni /\n    // DOM.gvBySetor / DOM.mercadoByGv, already in memory) — no live query, so the timeout that broke\n    // geographic discovery for a spread-out mercado closing (comment above) never re-enters here.\n    // nGv MUST resolve through destUnitOfSector (world-aware), not a raw DOM.gvBySetor lookup: a\n    // previous GV closure THIS SESSION may already have cascaded sOf to a new GV via secOverride,\n    // and DOM.gvBySetor only ever knows the static/original one. Without this, a neighbour município\n    // whose setor was reassigned earlier in the session rendered under the GV that closure CLOSED\n    // (still \"rejected\"/wrong colour) instead of its real current GV — and since nbrNames already\n    // held it from here, the correct territoryOf() pass for the real GV never got a chance to fix it.\n    if(level==='gv'){\n      closingNames.forEach(n=>{\n        if(!adj.feat[n]) return;\n        adj.nbrs(n).forEach(({o})=>{\n          if(closingSet.has(o) || nbrNames.has(o)) return;\n          const sOf=DOM.setorByMuni[o]; if(!sOf) return;\n          const nGv=destUnitOfSector(level,sOf,world); if(!nGv || nGv===code || sameParent(nGv)) return;\n          nbrNames.add(o);\n          mv.nbrCurrentSector[o]=nGv;\n          mv.rejectedCodes.add(nGv);\n          if(!mv.rejectedInfo[nGv]) mv.rejectedInfo[nGv]={code:nGv,parent:DOM.mercadoByGv[nGv]||''};\n          if(mv.gvRejected.indexOf(nGv)<0) mv.gvRejected.push(nGv);\n        });\n      });\n    }\n  }\n  mv.dest=mv.dest.filter(sameParent);\n\n  // ---- O QUE É DESENHADO: território COMPLETO de cada destino (não só a borda), sempre lido da\n  // mesma base de dominantes já usada pro lado que fecha.\n  const territoryOf=destCode=>{\n    const secs=memberSectorsOf(level,destCode,world);\n    const out=new Set();\n    secs.forEach(s=>(domBase[String(s)]||new Set()).forEach(n=>out.add(n)));\n    return out;\n  };\n  mv.muniOwners={};\n  mv.dest.forEach(destCode=>{\n    territoryOf(destCode).forEach(n=>{\n      // A município that's part of the CLOSING sector itself is already drawn (and clicked) via\n      // mv.geoFeat/unitAssign, further down — it isn't up-for-grabs context. Without this guard, a\n      // multi-sector city (São Paulo etc.) that also happens to be one of the closing sector's OWN\n      // municípios got its single \"current assignment\" fill painted right over the sliced context\n      // layer underneath — same IBGE polygon, and the closing layer draws on top.\n      if(closingSet.has(n)) return;\n      (mv.muniOwners[n]=mv.muniOwners[n]||[]).push(destCode);\n      if(nbrNames.has(n)) return;\n      nbrNames.add(n);\n      mv.nbrCurrentSector[n]=(level==='setor'&&world.muniOverride&&world.muniOverride[n])?world.muniOverride[n]:destCode;\n    });\n  });\n  // Draw every município we actually resolved a current sector for — not just the top-voted\n  // mv.dest candidates + rejectedCodes. A real same-GV neighbour sector that lost the vote race\n  // (more than 6 distinct neighbours, or simply outvoted) still gets classified further down as\n  // 'plain' context; filtering it out HERE instead left it entirely undrawn — a blank gap on the\n  // map exactly where a real, valid neighbouring sector is, instead of the grey context it should be.\n  // A neighbour whose current sector never resolved (no CRM row for it in this snapshot, a name\n  // mismatch, whatever) used to be dropped here entirely — invisible, no way to tell it apart from\n  // \"there's genuinely nothing there\". It still keeps its real shape; the render step below tags it\n  // 'unresolved' and shows the actual município name on hover instead, which is what you want when\n  // debugging why something didn't resolve.\n  // Um setor/GV rejeitado só entrava no mapa pelos municípios que sobreviveram à varredura de\n  // adjacência (os poucos que realmente encostam no que está fechando) — o resto do território dele,\n  // segundo a base de dominantes, nunca aparecia. Mesma expansão que mv.dest já recebe logo acima:\n  // território COMPLETO do setor/GV rejeitado, não só a fatia que faz fronteira.\n  mv.rejectedCodes.forEach(destCode=>{\n    territoryOf(destCode).forEach(n=>{\n      if(closingSet.has(n)) return;\n      if(nbrNames.has(n)) return;\n      nbrNames.add(n);\n      mv.nbrCurrentSector[n]=(level==='setor'&&world.muniOverride&&world.muniOverride[n])?world.muniOverride[n]:destCode;\n    });\n  });\n  // Setor fechando: um vizinho da MESMA GV que perdeu a votação (mais de 6 candidatos, ou simplesmente\n  // menos votado) nunca vira mv.dest nem mv.rejectedCodes — fica só como o único município que de fato\n  // encosta no fechamento, sem a expansão de território, e o resto dele sobra como buraco no mapa\n  // (nem colorido, nem cinza, nem contexto — simplesmente ausente). mv.allowedRaw carrega TODO\n  // candidato válido já descoberto, não só o top-6, então dá pra expandir os que sobraram também.\n  const plainCandidates=new Set();\n  units.forEach(u=>Object.keys(mv.allowedRaw[u]||{}).forEach(dc=>plainCandidates.add(dc)));\n  // Persisted so drawMap can test \"is this real sector a valid destination candidate anywhere in\n  // this closure\" without the top-6 mv.dest cap — adjacent + same GV/mercado + not already closed is\n  // enough to count as valid for the multi-sector-município striping/hover, even if it lost the vote.\n  mv.validCandidates=plainCandidates;\n  plainCandidates.forEach(destCode=>{\n    if(mv.dest.includes(destCode)) return;\n    territoryOf(destCode).forEach(n=>{\n      if(closingSet.has(n)) return;\n      if(nbrNames.has(n)) return;\n      nbrNames.add(n);\n      mv.nbrCurrentSector[n]=(level==='setor'&&world.muniOverride&&world.muniOverride[n])?world.muniOverride[n]:destCode;\n    });\n  });\n  mv.nbrFeat=[...nbrNames].map(n=>adj.feat[n]&&adj.feat[n].f).filter(Boolean);\n  units.forEach(u=>{ Object.keys(mv.allowed[u]||{}).forEach(destCode=>{ if(!mv.dest.includes(destCode)) delete mv.allowed[u][destCode]; }); });\n  mv.color=assignDestColors(mv.dest);\n\n  const accC={}; Object.entries(mv.nbrCurrentSector).forEach(([nb,destCode])=>{ if(!mv.dest.includes(destCode))return; const f=(adj.feat[nb]&&adj.feat[nb].f)||world.muniGeo[nb]; if(!f)return; const c=centroidLL(f); accC[destCode]=accC[destCode]||[0,0,0]; accC[destCode][0]+=c[0]; accC[destCode][1]+=c[1]; accC[destCode][2]++; });\n  mv.secCentroid={}; mv.dest.forEach(destCode=>{ if(accC[destCode]) mv.secCentroid[destCode]=[accC[destCode][0]/accC[destCode][2],accC[destCode][1]/accC[destCode][2]]; });\n\n  if(level==='setor'){\n    mv.muniCentroid={}; closingNames.forEach(n=>{ if(mv.geoFeat[n]) mv.muniCentroid[n]=centroidLL(mv.geoFeat[n]); });\n    mv.closeAdj={}; closingNames.forEach(n=>{ mv.closeAdj[n]=adj.feat[n]?adj.nbrs(n).filter(({o})=>closingSet.has(o)).map(({o})=>o):[]; });\n  } else {\n    mv.muniCentroid={};\n    Object.entries(mv.unitMunis).forEach(([u,ms])=>{\n      const cs=ms.map(n=>mv.geoFeat[n]&&centroidLL(mv.geoFeat[n])).filter(Boolean);\n      if(cs.length) mv.muniCentroid[u]=[cs.reduce((a,c)=>a+c[0],0)/cs.length,cs.reduce((a,c)=>a+c[1],0)/cs.length];\n    });\n    mv.closeAdj={};\n    const unitSet=new Set(units);\n    Object.entries(mv.unitMunis).forEach(([u,ms])=>{\n      const touch=new Set();\n      ms.forEach(n=>{ if(!adj.feat[n]) return; adj.nbrs(n).forEach(({o})=>{ const ou=mv.unitOfMuni[o]; if(ou&&ou!==u&&unitSet.has(ou)) touch.add(ou); }); });\n      mv.closeAdj[u]=[...touch];\n    });\n  }\n  const comp={}; let ci=0; units.forEach(n=>{ if(comp[n]!=null)return; const st=[n]; comp[n]=ci; while(st.length){ const x=st.pop(); (mv.closeAdj[x]||[]).forEach(o=>{ if(comp[o]==null){ comp[o]=ci; st.push(o); } }); } ci++; });\n  const compAllowed={}; units.forEach(n=>{ const cid=comp[n]; (compAllowed[cid]=compAllowed[cid]||new Set()); Object.keys(mv.allowed[n]||{}).forEach(s=>compAllowed[cid].add(s)); });\n  mv.reachable={}; units.forEach(n=>{ mv.reachable[n]=[...(compAllowed[comp[n]]||[])].filter(s=>mv.dest.includes(s)); });\n\n  if(mv.dest.length){\n    const destSectorMap={}; mv.dest.forEach(destCode=>{ destSectorMap[destCode]=memberSectorsOf(level,destCode,world); });\n    const allDestSecs=[...new Set(Object.values(destSectorMap).flat())];\n    const rList=allDestSecs.join(',');\n    const bSQL=`SELECT CD_SETOR setor, COUNT(DISTINCT CD_CONSULTORA) cbs,\n       SUM(CASE WHEN ST_FEZ_PED_CICLO_ATUAL THEN 1 ELSE 0 END) ativas,\n       ROUND(SUM(VL_PEDIDOS_CICLO),0) receita, ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat,\n       ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon, ROUND(SUM(QT_PONTOS_CICLO),0) pontos, ROUND(SUM(QT_PEDIDOS_CICLO),0) pedidos\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${rList}) AND DT_HISTORICO=${SNAP} GROUP BY CD_SETOR`;\n    const blSQL=`SELECT CD_SETOR setor, DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CD_CONSULTORA) cbs\n      FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${rList}) AND DS_NVL_PLAN_CRESC IS NOT NULL AND DT_HISTORICO=${SNAP} GROUP BY CD_SETOR, DS_NVL_PLAN_CRESC`;\n    const [bR,blR]=await Promise.all([q(bSQL),q(blSQL)]);\n    const secMetrics={}; rows(bR).forEach(r=>{ secMetrics[String(r.setor)]=r; if(!SESS.sectorBaseCache[String(r.setor)]) SESS.sectorBaseCache[String(r.setor)]=r; });\n    const secLevel={}; rows(blR).forEach(r=>{ const s=String(r.setor); (secLevel[s]=secLevel[s]||{})[r.nivel]=+r.cbs; });\n    mv.baseMetrics={}; mv.baseLevel={};\n    mv.dest.forEach(destCode=>{\n      const secs=destSectorMap[destCode];\n      const acc=blankM(); acc.setor=destCode;\n      secs.forEach(s=>{ const r=secMetrics[String(s)]; if(!r) return; Object.keys(blankM()).forEach(k=>{ acc[k]=(+acc[k]||0)+(+r[k]||0); }); });\n      mv.baseMetrics[destCode]=acc;\n      const lv={}; secs.forEach(s=>{ const l=secLevel[String(s)]; if(!l) return; Object.entries(l).forEach(([nv,c])=>{ lv[nv]=(lv[nv]||0)+c; }); });\n      mv.baseLevel[destCode]=lv;\n    });\n  } else { mv.baseMetrics={}; mv.baseLevel={}; }\n\n  mv.dest.forEach(destCode=>{\n    if(!mv.baseMetrics[destCode]) mv.baseMetrics[destCode]={setor:destCode,cbs:0,ativas:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0};\n    const inflowSrc=level==='setor'?world.sectorInflow:(level==='gv'?world.gvInflow:world.mercadoInflow);\n    const infl=inflowSrc&&inflowSrc[destCode];\n    if(infl) Object.keys(blankM()).forEach(k=>{ mv.baseMetrics[destCode][k]=(+mv.baseMetrics[destCode][k]||0)+(infl[k]||0); });\n    if(level==='setor'){\n      const inflLv=world.sectorInflowLevel&&world.sectorInflowLevel[destCode];\n      if(inflLv){ mv.baseLevel[destCode]=mv.baseLevel[destCode]||{}; Object.entries(inflLv).forEach(([nv,c])=>{ mv.baseLevel[destCode][nv]=(mv.baseLevel[destCode][nv]||0)+c; }); }\n      // Manual município transfers (see foldWorldState) subtract from whichever sector LOST the\n      // município — the only outflow this engine has, since a closed sector never needs it (its\n      // whole base is moot once dissolved).\n      const outfl=world.sectorOutflow&&world.sectorOutflow[destCode];\n      if(outfl) Object.keys(blankM()).forEach(k=>{ mv.baseMetrics[destCode][k]=Math.max(0,(+mv.baseMetrics[destCode][k]||0)-(outfl[k]||0)); });\n    }\n  });\n\n  aggregateMovement(mv,world);\n  mv.assign=optimizeFor(mv);\n  return mv;\n}\n\n// Reconstructs a timeline entry from a FROZEN saved etapa (saveSession's trimmed schema) instead of\n// recomputing it against Databricks — the whole point of the new session format. Every business-data\n// field is copied verbatim (this IS the point: no recompute, no drift from what was on screen when\n// saved); only map geometry (geoFeat/nbrFeat/muniCentroid/secCentroid/sharedMuniSlices) is rebuilt,\n// always live, from ufList/closingKeys/nbrKeys against whatever shapes are current — per the rule that\n// geometry and Overpass/OSM data are never frozen.\nasync function hydrateFromFrozen(i,saved){\n  const isSetor=(saved.level||'setor')==='setor';\n  const mv={\n    sector:String(saved.sector), level:saved.level||'setor', policy:saved.policy||'fora', view:'tobe', selMuni:null, optBadge:saved.optBadge||'',\n    assign:{...(saved.assign||{})}, dest:[...(saved.dest||[])],\n    munis:saved.munis||{}, leaders:saved.leaders||[],\n    baseMetrics:saved.baseMetrics||{}, baseLevel:saved.baseLevel||{},\n    trend:saved.trend||[], pureOwnMetrics:saved.pureOwnMetrics||blankM(),\n    muniOwners:saved.muniOwners||{}, rejectedCodes:new Set(saved.rejectedCodes||[]), rejectedInfo:saved.rejectedInfo||{},\n    nbrCurrentSector:saved.nbrCurrentSector||{}, validCandidates:new Set(saved.validCandidates||[]),\n    gvRejected:saved.gvRejected||[], unitOfMuni:saved.unitOfMuni||{}, unitMunis:saved.unitMunis||{},\n    closeAdj:saved.closeAdj||{}, csvMunisTotal:saved.csvMunisTotal||0, domCsvMunis:saved.domCsvMunis||0,\n    excludedMunis:saved.excludedMunis||[], excludedCbs:saved.excludedCbs||0,\n    noShapeMunis:saved.noShapeMunis||[], noShapeCbs:saved.noShapeCbs||0,\n    cbsByMuni:saved.cbsByMuni||{}, gv:saved.gv||'', domUF:saved.domUF||'', optTier:saved.optTier==null?0:saved.optTier,\n    matrix:isSetor?(saved.matrix||[]).map(r=>({...r})):[], levelByMuni:isSetor?(saved.levelByMuni||{}):{},\n    allowed:isSetor?(saved.allowed||{}):{}, muniSiblings:isSetor?(saved.muniSiblings||{}):{},\n    sharedSiblingCodes:new Set(isSetor?(saved.sharedSiblingCodes||[]):[]),\n    missingMunis:isSetor?(saved.missingMunis||[]):[],\n    orphanByGroup:isSetor?(saved.orphanByGroup||{}):{}, orphanMetricsByGroup:isSetor?(saved.orphanMetricsByGroup||{}):{},\n    orphanLevelByGroup:isSetor?(saved.orphanLevelByGroup||{}):{},\n    foraDoSetorByGrupo:isSetor?(saved.foraDoSetorByGrupo||{}):{},\n  };\n  mv.reachable=saved.reachable||{};\n  if(saved.kind==='transfer'){\n    mv.kind='transfer'; mv.xferFrom=String(saved.xferFrom); mv.xferTo=String(saved.xferTo);\n    mv.xferMuni=saved.xferMuni; mv.xferMetrics=saved.xferMetrics||blankM(); mv.xferRows=saved.xferRows||[];\n  }\n  mv.color=assignDestColors(mv.dest);\n\n  const ufList=(saved.ufList&&saved.ufList.length)?saved.ufList:[mv.domUF].filter(Boolean);\n  const geos=await Promise.all(ufList.map(u=>geoUF(u).catch(()=>null)));\n  const adj=buildAdj(geos.filter(Boolean).flatMap(g=>g.features));\n  mv.geoFeat={}; (saved.closingKeys||[]).forEach(n=>{ mv.geoFeat[n]=adj.feat[n]&&adj.feat[n].f; });\n  mv.nbrFeat=(saved.nbrKeys||[]).map(n=>adj.feat[n]&&adj.feat[n].f).filter(Boolean);\n\n  const accC={}; Object.entries(mv.nbrCurrentSector).forEach(([nb,destCode])=>{ if(!mv.dest.includes(destCode)) return; const f=adj.feat[nb]&&adj.feat[nb].f; if(!f) return; const c=centroidLL(f); accC[destCode]=accC[destCode]||[0,0,0]; accC[destCode][0]+=c[0]; accC[destCode][1]+=c[1]; accC[destCode][2]++; });\n  mv.secCentroid={}; mv.dest.forEach(destCode=>{ if(accC[destCode]) mv.secCentroid[destCode]=[accC[destCode][0]/accC[destCode][2],accC[destCode][1]/accC[destCode][2]]; });\n\n  if(isSetor){\n    mv.muniCentroid={}; Object.keys(mv.geoFeat).forEach(n=>{ if(mv.geoFeat[n]) mv.muniCentroid[n]=centroidLL(mv.geoFeat[n]); });\n  } else {\n    mv.muniCentroid={};\n    Object.entries(mv.unitMunis).forEach(([u,ms])=>{\n      const cs=ms.map(n=>mv.geoFeat[n]&&centroidLL(mv.geoFeat[n])).filter(Boolean);\n      if(cs.length) mv.muniCentroid[u]=[cs.reduce((a,c)=>a+c[0],0)/cs.length,cs.reduce((a,c)=>a+c[1],0)/cs.length];\n    });\n  }\n\n  mv.sharedMuniSlices=[];\n  if(isSetor){\n    Object.entries(mv.muniSiblings).forEach(([n,siblings])=>{\n      const orig=mv.geoFeat[n]; if(!orig||!siblings.length) return;\n      const owners=[mv.sector,...siblings].sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      const slices=sliceFeatureEqual(orig,owners.length);\n      const mine=slices[owners.indexOf(mv.sector)];\n      if(mine&&!mine.__empty) mv.geoFeat[n]=mine;\n      const remParts=slices.filter((sl,idx)=>owners[idx]!==mv.sector&&sl&&!sl.__empty).flatMap(sl=>sl.geometry.coordinates);\n      if(!remParts.length) return;\n      const remainder={type:'Feature',properties:orig.properties,__uf:orig.__uf,geometry:{type:'MultiPolygon',coordinates:remParts}};\n      const valid=siblings.filter(s=>mv.sharedSiblingCodes.has(s)).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      sliceFeatureEqual(remainder,valid.length||1).forEach((sl,idx)=>{ if(sl.__empty) return; mv.sharedMuniSlices.push({feature:sl,sector:valid.length?valid[idx]:null,muni:n}); });\n    });\n  }\n\n  SESS.timeline[i]=mv;\n}\n\n// ---------- manual município transfer (rule-violation \"exception\" fix) ----------\n// A lightweight sibling of computeMovement: moves ONE município's CBs from an open sector straight\n// to another open sector, with neither dissolving — the one case ★ ÓTIMO's own redistribution can\n// never reach, because it only ever touches municípios that belong to whichever sector is CLOSING.\n// Metrics are snapshotted here and stored verbatim on the transfer record (recipe-replay style, same\n// as everything else a session persists) — no geometry captured: the map keeps drawing this\n// município from whatever context layer already loaded it, which degrades gracefully rather than\n// needing its own fetch.\nasync function computeTransfer(from,to,muniKey){\n  await ensurePinned();\n  const SNAP=SESS.snapLiteral;\n  const sql=`SELECT DS_MUNICIPIO municipio, CD_SETOR setor, CD_GRUPO grupo, MAX(DS_LEADER_NAME) lider,\n     COUNT(DISTINCT CD_CONSULTORA) cbs, ROUND(SUM(VL_PEDIDOS_CICLO),0) receita,\n     ROUND(SUM(VL_PEDIDOS_NATURA_CICLO),0) rec_nat, ROUND(SUM(VL_PEDIDOS_AVON_CICLO),0) rec_avon,\n     ROUND(SUM(QT_PONTOS_CICLO),0) pontos, ROUND(SUM(QT_PEDIDOS_CICLO),0) pedidos,\n     SUM(CASE WHEN ST_FEZ_PED_CICLO_ATUAL THEN 1 ELSE 0 END) ativas\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${from} AND DT_HISTORICO=${SNAP}\n    GROUP BY DS_MUNICIPIO, CD_SETOR, CD_GRUPO`;\n  const recs=rows(await q(sql));\n  const uf=mUf(muniKey);\n  const metrics=blankM();\n  const byGroup={};\n  recs.forEach(r=>{\n    const ru=uf||DOM.ufBySetorMuni[String(from)+'|'+muniN(r.municipio)]||'';\n    if(mkey(r.municipio,ru)!==muniKey) return;\n    metrics.cbs+=+r.cbs; metrics.receita+=+r.receita||0; metrics.rec_nat+=+r.rec_nat||0; metrics.rec_avon+=+r.rec_avon||0; metrics.pontos+=+r.pontos||0; metrics.pedidos+=+r.pedidos||0; metrics.ativas+=+r.ativas||0;\n    const g=String(r.grupo);\n    const row=byGroup[g]=byGroup[g]||{grupo:g,lider:r.lider,...blankM()};\n    row.cbs+=+r.cbs; row.receita+=+r.receita||0; row.rec_nat+=+r.rec_nat||0; row.rec_avon+=+r.rec_avon||0; row.pontos+=+r.pontos||0; row.pedidos+=+r.pedidos||0; row.ativas+=+r.ativas||0;\n  });\n  return {metrics, xferRows:Object.values(byGroup)};\n}\n// A transfer-movement stage never changes mv.munis/mv.assign structurally — the reassigned município\n// belongs to one of the PARENT stage's own destination sectors (mv.dest), never to mv.munis (only the\n// dissolving sector's own children live there — see suggestMuniMoves, which sources donor municípios\n// from DOM.bySetor[donor], donor∈mv.dest). What genuinely changes is baseMetrics[from]/baseMetrics[to]\n// — the same two fields computeMovement's own tail already patches from world.sectorInflow/\n// sectorOutflow for a LATER stage. computeDerived(mv) picks the new spans up automatically at render\n// time from baseMetrics, exactly like every other render already re-derives everything from mv fresh.\nasync function computeTransferMovement(i){\n  const t=SESS.timeline[i];\n  const prev=SESS.timeline[i-1]; // i is never 0 — applyTransfer always inserts after an existing stage\n  const mv={...prev};\n  mv.kind='transfer'; mv.xferFrom=t.xferFrom; mv.xferTo=t.xferTo; mv.xferMuni=t.xferMuni;\n  // Own copy — NOT because this function writes to it, but because editSelected's map-click/segmented-\n  // button mutators write into mv.assign IN PLACE (m2.assign[m]=s), and sharing the object with prev\n  // would let an edit made while viewing THIS stage silently corrupt the parent stage's own assignment.\n  mv.assign={...prev.assign};\n  mv.baseMetrics={...prev.baseMetrics};\n  const {metrics,xferRows}=await computeTransfer(t.xferFrom,t.xferTo,t.xferMuni); // live re-query — from's numbers\n  mv.xferMetrics=metrics; mv.xferRows=xferRows;                         // may have shifted upstream\n  const patchBase=(code,sign)=>{\n    const cur=mv.baseMetrics[code]||{setor:code,cbs:0,ativas:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0};\n    const next={...cur};\n    Object.keys(blankM()).forEach(k=>{ next[k]=Math.max(0,(+next[k]||0)+sign*(metrics[k]||0)); });\n    mv.baseMetrics[code]=next;\n  };\n  patchBase(t.xferTo,+1);\n  patchBase(t.xferFrom,-1);\n  if(mv.nbrCurrentSector && (t.xferMuni in mv.nbrCurrentSector)){\n    mv.nbrCurrentSector={...mv.nbrCurrentSector, [t.xferMuni]:t.xferTo};\n  }\n  SESS.timeline[i]=mv;\n}\n// Pins a new transfer-movement stage right after the etapa it was applied against — see\n// computeTransferMovement, which does the actual clone+patch work through the normal computeAt\n// dispatch/dirty-cascade, the same path any other edit already uses.\nasync function applyTransfer(from,to,muniKey){\n  if(guardLocked()) return;\n  const parent=SESS.timeline[SESS.selected]; if(!parent) return;\n  const insertAt=SESS.selected+1;\n  SESS.timeline.splice(insertAt,0,{sector:parent.sector, level:parent.level||'setor', kind:'transfer', xferFrom:String(from), xferTo:String(to), xferMuni:muniKey});\n  SESS.dirtyFrom = SESS.dirtyFrom==null ? insertAt : Math.min(SESS.dirtyFrom, insertAt);\n  SESS.selected=insertAt;\n}\n// Retargets an ALREADY-inserted transfer stage in place (never inserts a new one — that's\n// applyTransfer's job). Dirty-marked at the stage's OWN index, not +1 like editSelected: a\n// transfer stage's baseMetrics/xferMetrics/xferRows/nbrCurrentSector are precomputed snapshots\n// (unlike mv.assign, which every render re-derives live), so the stage itself must be recomputed —\n// computeAt's existing kind==='transfer' branch already does that correctly via computeTransferMovement.\nasync function setTransferTarget(code){\n  if(guardLocked()) return;\n  const mv=SESS.timeline[SESS.selected];\n  if(!mv||mv.kind!=='transfer'||!mv.dest||!mv.dest.includes(code)||mv.xferTo===code) return;\n  mv.xferTo=code;\n  SESS.dirtyFrom = SESS.dirtyFrom==null ? SESS.selected : Math.min(SESS.dirtyFrom, SESS.selected);\n  await flushDirty();\n  renderAll();\n}\n// Plain wraparound through mv.dest, deliberately including xferFrom as a natural \"revert\" stop —\n// mirrors how the normal município click-cycle (drawMap) never excludes the current assignment\n// either. No contiguity guard: nextContigSafeDest reasons about the CLOSING sector's own dissolving\n// territory staying connected, which has no meaning for a transfer between two already-open sectors.\nasync function cycleTransferTarget(){\n  const mv=SESS.timeline[SESS.selected];\n  if(!mv||mv.kind!=='transfer'||!mv.dest||!mv.dest.length) return;\n  await setTransferTarget(mv.dest[(mv.dest.indexOf(mv.xferTo)+1)%mv.dest.length]);\n}\n// Geo adjacency for an OPEN sector's own territory — cached by UF-set key, since the same pair of\n// sectors tends to get re-checked across several fix cards in one session.\nconst OPEN_SECTOR_ADJ_CACHE={};\nasync function openSectorAdj(ufSet){\n  const key=[...ufSet].sort().join(',');\n  if(OPEN_SECTOR_ADJ_CACHE[key]) return OPEN_SECTOR_ADJ_CACHE[key];\n  const geos=await Promise.all([...ufSet].map(u=>geoUF(u).catch(()=>null)));\n  const adj=buildAdj(geos.filter(Boolean).flatMap(g=>g.features));\n  OPEN_SECTOR_ADJ_CACHE[key]=adj;\n  return adj;\n}\n// Candidate município moves for the span-rule fix card — scoped to the ONE violation the clicked\n// button represents (`code`), tested against mv's own REAL, current span map (sp), never a\n// hypothetical \"what if a different sector closes\" preview. Only ever offered as a MINIMAL plan —\n// at most 2 municípios — because reallocating an established sector's territory is a manual\n// calibration a person has to eyeball on the map; a plan that shuffles four+ municípios around to\n// plug one gap reads as arbitrary busywork, not as something anyone would actually click. If\n// nothing that small covers the whole gap while staying geometrically safe, no plan is offered at\n// all — the \"fechar\" card right next to it (closeWork) is the fallback for that case.\n// Two geometric guards, not one: a candidate must not be the only thing holding the DONOR's\n// remaining territory together (as before), AND it must not leave the RECEIVER's resulting\n// territory in disconnected pieces either — a município that borders neither the donor's rest nor\n// the receiver's existing footprint would dangle as its own island wherever it lands.\nasync function suggestMuniMoves(mv,sp,lim,code,isOver){\n  if((mv.level||'setor')!=='setor') return null;\n  const others=mv.dest.filter(d=>d!==code);\n  if(!others.length) return null;\n  const gapNeeded=isOver?(lim.max?Math.max(0,sp[code]-lim.max):0):(lim.min?Math.max(0,lim.min-sp[code]):0);\n  if(!gapNeeded) return null;\n  const donorPool=isOver?[code]:others;\n  const receiverPool=isOver?others:[code];\n\n  // Single connected-component test, shared by both guards below: a donor's REMAINING territory\n  // after losing its combo members, and a receiver's RESULTING territory after gaining them. One\n  // node is trivially connected — nothing left to disconnect it from.\n  const connected=(nodes,adj)=>{\n    const arr=[...nodes]; if(arr.length<=1) return true;\n    const set=new Set(arr);\n    const seen=new Set([arr[0]]),st=[arr[0]];\n    while(st.length){ const cur=st.pop(); adj.nbrs(cur).forEach(({o})=>{ if(set.has(o)&&!seen.has(o)){ seen.add(o); st.push(o); } }); }\n    return seen.size===arr.length;\n  };\n\n  const ownByDonor={},adjByDonor={};\n  let pool=[];\n  for(const dn of donorPool){\n    const ownMunis=DOM.bySetor[dn]||new Set();\n    if(ownMunis.size<2) continue;   // nothing left to give away without emptying the sector\n    const sql=`SELECT DS_MUNICIPIO municipio, DS_ESTADO uf, COUNT(DISTINCT CD_CONSULTORA) cbs FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=${dn} AND DT_HISTORICO=${SESS.snapLiteral} GROUP BY DS_MUNICIPIO, DS_ESTADO ORDER BY cbs ASC`;\n    let recs; try{ recs=rows(await q(sql)); }catch(e){ continue; }\n    const cands=[];\n    recs.forEach(r=>{\n      const ru=r.uf||DOM.ufBySetorMuni[String(dn)+'|'+muniN(r.municipio)]||'';\n      const k=mkey(r.municipio,ru);\n      if(ownMunis.has(k)&&+r.cbs>0) cands.push({muni:k,cbs:+r.cbs,donor:dn});\n    });\n    if(!cands.length) continue;\n    const ufSet=new Set();\n    ownMunis.forEach(m=>{ const u=mUf(m); if(u) ufSet.add(u); });\n    mv.dest.forEach(d=>(DOM.bySetor[d]||new Set()).forEach(m=>{ const u=mUf(m); if(u) ufSet.add(u); }));\n    ufSet.forEach(u=>(UF_BORDERS[u]||[]).forEach(b=>ufSet.add(b)));\n    let adj; try{ adj=await openSectorAdj(ufSet); }catch(e){ continue; }\n    ownByDonor[dn]=ownMunis; adjByDonor[dn]=adj;\n    pool=pool.concat(cands.filter(c=>adj.feat[c.muni]));\n  }\n  if(!pool.length) return null;\n\n  // Receivers' CURRENT (pre-plan) territory — the baseline every geometry check below grows on top\n  // of, same for a 1- or a 2-município combo.\n  const recvBase={}; receiverPool.forEach(rc=>{ recvBase[rc]=DOM.bySetor[rc]||new Set(); });\n\n  // Does this combo (1 or 2 candidates, each already carrying its chosen receiver in `.rc`) hold up\n  // geometrically? Donor side: group by donor, remove the combo's members, whatever is left must\n  // stay one region. Receiver side: group by receiver, add the combo's members to its existing\n  // territory, old + new must stay one region too.\n  // Receiver-side guard is deliberately NOT a full re-verification that the WHOLE existing\n  // territory (often dozens of towns) is itself one connected blob in our loaded shapes — that's a\n  // pre-existing fact this fix doesn't own, and a real sector routinely has a stray missing shape or\n  // CSV alias gap somewhere far from anything being added here (see the UF/alias handling elsewhere\n  // in this file). It only asks the LOCAL question that actually matters: does the incoming piece\n  // touch the receiver's existing footprint — directly, or via its sibling in this same plan?\n  const attaches=(newMunis,existing,adj)=>{\n    const touches=m=>[...adj.nbrs(m)].some(({o})=>existing.has(o));\n    const rest=new Set(newMunis);\n    const linked=new Set([...rest].filter(touches));\n    if(!linked.size) return false;\n    let grew=true;\n    while(grew){\n      grew=false;\n      for(const m of rest){\n        if(linked.has(m)) continue;\n        if([...adj.nbrs(m)].some(({o})=>linked.has(o))){ linked.add(m); grew=true; }\n      }\n    }\n    return linked.size===rest.size;\n  };\n  const geomOk=combo=>{\n    const byDonor={}; combo.forEach(c=>(byDonor[c.donor]=byDonor[c.donor]||[]).push(c.muni));\n    for(const dn in byDonor){\n      const remaining=[...ownByDonor[dn]].filter(m=>!byDonor[dn].includes(m));\n      if(!remaining.length||!connected(remaining,adjByDonor[dn])) return false;\n    }\n    const byRecv={}; combo.forEach(c=>(byRecv[c.rc]=byRecv[c.rc]||[]).push(c.muni));\n    for(const rc in byRecv){\n      const adj=adjByDonor[combo.find(c=>c.rc===rc).donor];\n      if(!attaches(byRecv[rc],recvBase[rc],adj)) return false;\n    }\n    return true;\n  };\n  const roomOk=combo=>{\n    const used={}; combo.forEach(c=>{ used[c.rc]=(used[c.rc]||0)+c.cbs; });\n    return Object.entries(used).every(([rc,cbs])=>cbs<=(lim.max?lim.max:Infinity)-sp[rc]);\n  };\n  const planFrom=combo=>{\n    const spNow={...sp};\n    combo.forEach(c=>{ spNow[c.donor]-=c.cbs; spNow[c.rc]+=c.cbs; });\n    return {moves:combo.map(c=>({from:c.donor,to:c.rc,muni:c.muni,cbs:c.cbs})), resolved:true, spNow};\n  };\n\n  // 1 município: every (candidate × receiver) pairing that alone covers the whole gap. Smallest\n  // cbs wins — the least overshoot, the least disruptive single move.\n  const rejected=[];\n  let best1=null;\n  for(const c of pool){\n    if(c.cbs<gapNeeded){\n      let viableReceiver=null;\n      for(const rc of receiverPool){\n        if(rc===c.donor) continue;\n        const combo=[{...c,rc}];\n        if(roomOk(combo)&&geomOk(combo)){ viableReceiver=rc; break; }\n      }\n      const entry={munis:[c.muni],cbs:c.cbs,donor:c.donor,motivo:'não cobre a carga necessária sozinho'};\n      if(viableReceiver){ entry.receiver=viableReceiver; entry.moves=[{from:c.donor,to:viableReceiver,muni:c.muni,cbs:c.cbs}]; }\n      rejected.push(entry);\n      continue;\n    }\n    for(const rc of receiverPool){\n      if(rc===c.donor) continue;\n      const combo=[{...c,rc}];\n      if(!roomOk(combo)){ rejected.push({munis:[c.muni],cbs:c.cbs,donor:c.donor,receiver:rc,motivo:'deixaria o setor receptor acima do limite',moves:[{from:c.donor,to:rc,muni:c.muni,cbs:c.cbs}]}); continue; }\n      if(!geomOk(combo)){ continue; }\n      if(!best1||c.cbs<best1[0].cbs) best1=combo;\n      else rejected.push({munis:[c.muni],cbs:c.cbs,donor:c.donor,receiver:rc,motivo:'resolveria, mas desloca mais CBs que a opção já encontrada',moves:[{from:c.donor,to:rc,muni:c.muni,cbs:c.cbs}]});\n    }\n  }\n  if(best1) return {...planFrom(best1), rejected};\n\n  // 2 municípios: cheapest combined-cbs pair (any donor/receiver split) that together cover the\n  // gap — the plan someone would actually pick over closing a whole sector.\n  let best2=null,best2Cost=Infinity;\n  for(let i=0;i<pool.length;i++){\n    for(let j=i+1;j<pool.length;j++){\n      const c1=pool[i],c2=pool[j];\n      if(c1.muni===c2.muni) continue;\n      const total=c1.cbs+c2.cbs;\n      if(total<gapNeeded||total>=best2Cost) continue;\n      for(const rc1 of receiverPool){\n        if(rc1===c1.donor) continue;\n        for(const rc2 of receiverPool){\n          if(rc2===c2.donor) continue;\n          const combo=[{...c1,rc:rc1},{...c2,rc:rc2}];\n          if(!roomOk(combo)){ rejected.push({munis:[c1.muni,c2.muni],cbs:total,motivo:'deixaria um setor receptor acima do limite',moves:[{from:c1.donor,to:rc1,muni:c1.muni,cbs:c1.cbs},{from:c2.donor,to:rc2,muni:c2.muni,cbs:c2.cbs}]}); continue; }\n          if(!geomOk(combo)){ continue; }\n          best2=combo; best2Cost=total;\n        }\n      }\n    }\n  }\n  if(best2) return {...planFrom(best2), rejected};\n\n  return {moves:null, resolved:false, rejected};\n}\n\n// ---------- cascade engine ----------\nasync function computeAt(i){\n  await ensurePinned();\n  const cur=SESS.timeline[i];\n  if(cur && cur.kind==='transfer'){ await computeTransferMovement(i); return; }\n  const world=foldWorldState(SESS.timeline,i);\n  const sec=SESS.timeline[i].sector;\n  const level=SESS.timeline[i].level||'setor';\n  const mv=await computeMovement(sec,level,world);\n  SESS.timeline[i]=mv;\n}\nasync function recomputeFrom(i){\n  for(let k=i;k<SESS.timeline.length;k++){\n    loader(true,`recalculando etapa ${k+1} de ${SESS.timeline.length}…`);\n    try{ await computeAt(k); }\n    catch(e){\n      // A stage that failed to recompute still holds its PREVIOUS result, so the timeline would\n      // silently mix pre- and post-change etapas. Flag this stage and everything after it as stale\n      // (dashed chip + ⟳) so the state is visible and flushDirty retries from here.\n      SESS.dirtyFrom = SESS.dirtyFrom==null ? k : Math.min(SESS.dirtyFrom,k);\n      throw e;\n    }\n  }\n}\nasync function flushDirty(){\n  if(SESS.dirtyFrom==null) return;\n  const from=SESS.dirtyFrom; SESS.dirtyFrom=null;\n  loader(true,'recalculando etapas seguintes…');\n  try{ await recomputeFrom(from); }\n  catch(e){ $('#ds').textContent='erro ao recalcular cadeia: '+e.message; $('#ds').classList.add('err'); }\n  finally{ loader(false); }\n}\nasync function addMovement(level,sec){\n  if(guardLocked()) return;\n  level=level||'setor';\n  sec=(sec==null?'':String(sec)).trim();\n  if(!sec) return;\n  if(!movementAvailable(level)){ $('#ds').textContent=`Fechar ${LEVELS[level].label} ainda não está disponível.`; $('#ds').classList.add('err'); return; }\n  if(!hierarchyAllows(level)){ const minLabel=LEVELS[Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===sessionMinRank())].label; $('#ds').textContent=`esta sessão já fechou um nível mais alto — só \"${minLabel}\" (ou acima) fica disponível.`; $('#ds').classList.add('err'); return; }\n  const closedNow=new Set(SESS.timeline.filter(mv=>mv.kind!=='transfer' && (mv.level||'setor')===level).map(mv=>mv.sector));\n  if(closedNow.has(sec)){ $('#ds').textContent=LEVELS[level].label+' '+sec+' já fechado(a) nesta timeline.'; $('#ds').classList.add('err'); return; }\n  if(level==='setor' && !SESS.sectorsList.some(s=>s.setor===sec)){ $('#ds').textContent='setor \"'+sec+'\" não existe no snapshot congelado.'; $('#ds').classList.add('err'); return; }\n  await flushDirty();\n  loader(true,`fechando ${lc(LEVELS[level].label)} `+sec+' — puxando dado do snapshot congelado…');\n  $('#liveState').classList.remove('err');\n  try{\n    SESS.timeline.push({sector:String(sec),level:level});\n    await computeAt(SESS.timeline.length-1);\n    SESS.selected=SESS.timeline.length-1;\n    $('#ds').classList.remove('err'); $('#ds').textContent='';\n  }catch(e){\n    SESS.timeline.pop();\n    $('#ds').textContent=`erro ao fechar ${lc(LEVELS[level].label)} `+sec+': '+e.message; $('#ds').classList.add('err');\n  } finally { loader(false); renderAll(); }\n}\nfunction removeFrom(i){\n  if(guardLocked()) return;\n  DISC_PAINT=null;\n  SESS.timeline.length=i;\n  SESS.selected=Math.min(SESS.selected,Math.max(0,SESS.timeline.length-1));\n  if(SESS.dirtyFrom!=null && SESS.dirtyFrom>=SESS.timeline.length) SESS.dirtyFrom=null;\n  renderAll();\n}\nasync function editSelected(mutator){\n  if(guardLocked()) return;\n  const mv=SESS.timeline[SESS.selected]; if(!mv) return;\n  mutator(mv);\n  FIX_CACHE.get(mv)?.clear();\n  DISC_PAINT=null;\n  if(SESS.selected<SESS.timeline.length-1){\n    SESS.dirtyFrom = SESS.dirtyFrom==null ? SESS.selected+1 : Math.min(SESS.dirtyFrom, SESS.selected+1);\n  }\n  renderAll();\n}\n\n// ---------- sectors list / picker ----------\nasync function loadSectors(){\n  const sql=`SELECT CD_SETOR setor, MAX(DS_ESTADO) uf, COUNT(DISTINCT CD_CONSULTORA) cbs, COUNT(DISTINCT DS_MUNICIPIO) munis\n    FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR>0 AND DT_HISTORICO=(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1) GROUP BY CD_SETOR ORDER BY CD_SETOR`;\n  SESS.sectorsList=rows(await q(sql)).map(r=>({setor:String(r.setor),uf:r.uf,cbs:+r.cbs,munis:+r.munis}));\n}\nfunction renderSectorPicker(){\n  const ready=SESS.stage==='main';\n  const ob=$('#openMove'); if(ob) ob.disabled=!ready;\n}\n\n// ---------- movement modal (setor / GV / mercado) ----------\n// Hierarquia mercado > GV > setor: a sessão trava no nível da primeira movimentação. Misturar\n// níveis quebraria a leitura de \"antes/depois\" da cadeia — fechar uma GV já redistribui setores\n// inteiros pra outras GVs; fechar um setor isolado depois não bate mais com o mundo que sobrou.\n// de/a/no: contrações de \"de\"/\"a\"/\"em\" com o artigo de cada nível — GV é feminina (a Gerência de\n// Vendas), setor e mercado são masculinos. Errar o gênero aqui é o tipo de coisa que grita \"isto foi\n// feito pra setor e só remendado pros outros níveis\", exatamente o que este objeto existe pra evitar.\nconst LEVELS={\n  setor:{label:'Setor',codeLbl:'Setor a fechar',placeholder:'código do setor',childLabel:'Município',destLabel:'Setor',de:'do',a:'ao',no:'no',parentLabel:'GV',parentPhrase:'da mesma GV',parentWord:'gerência'},\n  gv:{label:'GV',codeLbl:'GV a fechar',placeholder:'código da GV',childLabel:'Setor',destLabel:'GV',de:'da',a:'à',no:'na',parentLabel:'Mercado',parentPhrase:'do mesmo mercado',parentWord:'mercado'},\n  mercado:{label:'Mercado',codeLbl:'Mercado a fechar',placeholder:'nome do mercado',childLabel:'GV',destLabel:'Mercado',de:'do',a:'ao',no:'no',parentLabel:null,parentPhrase:null,parentWord:null},\n};\n// GV é sigla — .toLowerCase() a transformaria em \"gv\", errado em qualquer posição da frase. Palavras\n// normais (Setor/Mercado/Município) minúsculam à vontade no meio de uma frase; GV nunca.\nconst LOWER={Setor:'setor',Mercado:'mercado',Município:'município',GV:'GV'};\nconst lc=w=>LOWER[w]||w.toLowerCase();\n// Hierarquia é uma catraca, não um cadeado: mercado(0) > GV(1) > setor(2). Fechar um nível mais\n// alto (número menor) trava PRA SEMPRE nesta sessão qualquer nível mais baixo (número maior) —\n// mas o próprio nível, ou um ainda mais alto, continua liberado. É assim que \"posso fechar setores\n// até fechar uma GV; posso fechar GVs até fechar um mercado\" funciona: nunca destrava de volta.\nconst LEVEL_RANK={mercado:0,gv:1,setor:2};\nfunction sessionMinRank(){\n  let r=LEVEL_RANK.setor;\n  SESS.timeline.forEach(mv=>{ const rk=LEVEL_RANK[mv.level||'setor']; if(rk<r) r=rk; });\n  return r;\n}\nfunction hierarchyAllows(lvl){ return LEVEL_RANK[lvl]<=sessionMinRank(); }\nfunction movementAvailable(lvl){ return lvl==='setor'||lvl==='gv'||lvl==='mercado'; }\n// ---------- hierarquia: setor ⊂ GV ⊂ mercado — tudo derivado da base de dominantes (DOM) ----------\n// memberSectors: o escopo de CD_SETOR que a query no CRM precisa cobrir pra fechar este código, em\n// qualquer nível — um setor é ele mesmo; uma GV é todo setor que a base lhe atribui; um mercado é\n// todo setor de toda GV que a base atribui a ele.\n// membros ATUAIS de um GV/mercado, um nível abaixo (setores de uma GV; GVs de um mercado) — nunca\n// só a foto estática do CSV: uma etapa anterior NESTA MESMA sessão pode já ter movido um filho pra\n// dentro ou pra fora deste código, e um fechamento seguinte tem de enxergar o mundo como ele está\n// agora, não como o CSV descreveu antes de qualquer movimentação.\nfunction currentMembersOf(level,code,world){\n  code=String(code);\n  // \"gv\": setores da GV (DOM.sectorsByGv). \"mercado\": GVs do mercado (DOM.gvsByMercado) — NUNCA\n  // sectorsByMercado, que pula a camada de GV inteira e faz memberSectorsOf tratar códigos de\n  // setor como se fossem códigos de GV (não batem em DOM.sectorsByGv, e o fechamento morre com\n  // \"não existe na base\" mesmo quando o mercado está lá).\n  const staticMembers=level==='gv'?(DOM.sectorsByGv[code]||new Set()):(DOM.gvsByMercado[code]||new Set());\n  const overrideMap=level==='gv'?(world&&world.secOverride):(world&&world.gvOverride);\n  const out=new Set(staticMembers);\n  if(overrideMap){\n    Object.entries(overrideMap).forEach(([child,dest])=>{\n      if(dest===code) out.add(child);\n      else if(staticMembers.has(child)) out.delete(child);\n    });\n  }\n  return [...out];\n}\n// setores que a query no CRM precisa cobrir pra fechar este código, em qualquer nível — setor é\n// ele mesmo; GV é seus setores ATUAIS; mercado é todo setor de toda GV ATUALMENTE sua (setor não\n// troca de GV sozinho numa sessão de mercado — só GV inteira troca de mercado).\nfunction memberSectorsOf(level,code,world){\n  code=String(code);\n  if(level==='setor') return [code];\n  if(level==='gv') return currentMembersOf('gv',code,world);\n  const gvs=currentMembersOf('mercado',code,world);\n  const out=new Set();\n  gvs.forEach(gv=>(DOM.sectorsByGv[gv]||new Set()).forEach(s=>out.add(s)));\n  return [...out];\n}\n// fronteira que um destino tem de respeitar: GV do setor que fecha, mercado da GV que fecha —\n// mercado não tem nada acima, então não existe regra pra ele. Estática de propósito: um setor não\n// troca de GV nem uma GV troca de mercado só por existirem etapas anteriores nesta sessão — quem\n// muda de dono é o NÍVEL ABAIXO do que está fechando, nunca o próprio código sendo avaliado aqui.\nfunction parentOf(level,code){\n  code=String(code);\n  if(level==='setor') return DOM.gvBySetor[code]||'';\n  if(level==='gv') return DOM.mercadoByGv[code]||'';\n  return '';\n}\n// dado o setor que uma município vizinha atualmente pertence (fato ao vivo do CRM, nunca muda de\n// verdade), a que CANDIDATO A DESTINO isso corresponde neste nível — considerando que uma etapa\n// anterior desta sessão pode já ter movido esse setor (GV fechando) ou essa GV (mercado fechando)\n// pra outro dono, o que a foto estática do CSV sozinha não enxergaria.\nfunction destUnitOfSector(level,setorCode,world){\n  // setorCode is undefined when the live neighbour query found no row for that município (e.g. zero\n  // CBs in this snapshot) — String(undefined) would silently turn that into the literal text\n  // \"undefined\" and the caller would treat it as a real (bogus) sector: shown as a fake rejected\n  // sector on the map, tooltip and all. Bail out to '' — no resolvable sector — instead.\n  if(setorCode==null||setorCode==='') return '';\n  setorCode=String(setorCode);\n  if(level==='setor') return setorCode;\n  if(level==='gv'){ const ov=world&&world.secOverride&&world.secOverride[setorCode]; return ov||DOM.gvBySetor[setorCode]||''; }\n  const gv=DOM.gvBySetor[setorCode]||'';\n  const ov=world&&world.gvOverride&&world.gvOverride[gv];\n  return ov||DOM.mercadoBySetor[setorCode]||'';\n}\nfunction currentMoveLevel(){ const r=root.querySelector('input[name=\"moveLevel\"]:checked'); return (r&&r.value)||'setor'; }\nfunction openMove(){\n  const minRank=sessionMinRank();\n  const lastLevel=SESS.timeline.length?(SESS.timeline[SESS.timeline.length-1].level||'setor'):'setor';\n  root.querySelectorAll('input[name=\"moveLevel\"]').forEach(r=>{\n    const lvl=r.value;\n    r.disabled=!movementAvailable(lvl)||!hierarchyAllows(lvl);\n    r.checked=hierarchyAllows(lastLevel)?lvl===lastLevel:lvl===Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===minRank);\n  });\n  const note=$('#moveLockNote');\n  const minLabel=LEVELS[Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===minRank)].label;\n  if(note) note.textContent=minRank<LEVEL_RANK.setor\n    ? `Esta sessão já fechou um nível mais alto na hierarquia — só \"${minLabel}\" (ou acima) continua disponível daqui pra frente.`\n    : 'Escolha o nível a fechar. Você pode fechar setores à vontade até fechar uma GV; aí só GV (ou mercado) daqui pra frente. Fechar um mercado trava a sessão em mercado.';\n  $('#moveCode').value='';\n  const err=$('#moveErr'); if(err){ err.textContent=''; err.classList.add('hidden'); }\n  const st=$('#moveStatus'); if(st) st.textContent='';\n  $('#moveBg').classList.remove('hidden');\n  renderMoveForm();\n  const c=$('#moveCancel'); if(c) c.focus();\n}\nfunction closeMove(){ const b=$('#moveBg'); if(b) b.classList.add('hidden');\n  // #moveCode's dropdown panel lives in #macPortal, appended straight to document.body — a\n  // separate DOM subtree from this modal, so hiding #moveBg alone never touches it. It normally\n  // closes on a document-level click (ensureMacGlobalClose), but submitMove() via Enter never\n  // fires one, so the panel was left floating on screen after the modal itself had already closed.\n  closeAllMacSelects();\n}\nfunction renderMoveForm(){\n  root.querySelectorAll('#moveBg .opt-row').forEach(r=>r.classList.toggle('on',r.dataset.lvl===currentMoveLevel()));\n  const lvl=LEVELS[currentMoveLevel()];\n  $('#moveCodeLbl').textContent=lvl.codeLbl;\n  $('#moveCode').placeholder=lvl.placeholder;\n  loadMoveOptions();\n}\nasync function loadMoveOptions(){\n  const lvl=currentMoveLevel();\n  const dl=$('#moveCodeList'); if(!dl) return;\n  const closedSet=new Set(SESS.timeline.filter(mv=>mv.kind!=='transfer' && (mv.level||'setor')===lvl).map(mv=>mv.sector));\n  dl.innerHTML='';\n  // Marks the window during which the datalist is empty because it's still loading, not because\n  // there are genuinely no options — initMacCombo's panel reads this to show \"carregando…\" instead\n  // of misreporting \"nenhum resultado\" while ensureDominantes() (the CSV fetch, can be slow cold) is\n  // still in flight.\n  dl.dataset.loading='1';\n  const inp=$('#moveCode'); if(inp&&inp._macRefresh) inp._macRefresh();\n  try{\n    try{ await ensureDominantes(); }catch(e){ return; }\n    if(lvl==='setor'){\n      // Setor picker reads STRICTLY from the dominantes CSV (DOM.bySetor) now, never from a live CRM\n      // query — código/UF/município count are all derivable from the same base already loaded for the\n      // hierarchy rules, so this list shouldn't cost a Databricks round trip on top of it. CB count is\n      // dropped along with it: it only ever came from that same CRM query.\n      const codes=Object.keys(DOM.bySetor||{}).filter(s=>!closedSet.has(s)).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      dl.innerHTML=codes.map(s=>{\n        const ms=DOM.bySetor[s]||new Set();\n        const ufCount={};\n        ms.forEach(m=>{ const u=mUf(m); if(u) ufCount[u]=(ufCount[u]||0)+1; });\n        const uf=Object.keys(ufCount).sort((a,b)=>ufCount[b]-ufCount[a])[0];\n        return `<option value=\"${s}\">${s}${uf?' · '+uf:''} · ${ms.size} munic.</option>`;\n      }).join('');\n    } else if(lvl==='gv'){\n      const gvs=Object.keys(DOM.sectorsByGv||{}).filter(g=>!closedSet.has(g)).sort();\n      dl.innerHTML=gvs.map(g=>`<option value=\"${g}\">${g}${DOM.gvLabel[g]?' · '+esc(DOM.gvLabel[g]):''} · ${DOM.sectorsByGv[g].size} setor(es)</option>`).join('');\n    } else if(lvl==='mercado'){\n      const mks=Object.keys(DOM.sectorsByMercado||{}).filter(m=>!closedSet.has(m)).sort();\n      dl.innerHTML=mks.map(m=>`<option value=\"${m}\">${m} · ${(DOM.gvsByMercado[m]||new Set()).size} GV(s) · ${DOM.sectorsByMercado[m].size} setor(es)</option>`).join('');\n    }\n  } finally {\n    dl.dataset.loading='0';\n    if(inp&&inp._macRefresh) inp._macRefresh();\n  }\n}\nfunction moveRandPick(){\n  const opts=[...($('#moveCodeList').options||[])].map(o=>o.value);\n  if(!opts.length) return;\n  $('#moveCode').value=opts[Math.floor(Math.random()*opts.length)];\n}\nasync function submitMove(){\n  const lvl=currentMoveLevel();\n  const code=($('#moveCode').value||'').trim();\n  const err=$('#moveErr');\n  const fail=msg=>{ if(err){ err.textContent=msg; err.classList.remove('hidden'); } };\n  if(err){ err.textContent=''; err.classList.add('hidden'); }\n  if(!movementAvailable(lvl)) return fail(`Fechar ${LEVELS[lvl].label} ainda não está disponível.`);\n  if(!hierarchyAllows(lvl)){ const minLabel=LEVELS[Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===sessionMinRank())].label; return fail(`Esta sessão já fechou um nível mais alto — só \"${minLabel}\" (ou acima) fica disponível.`); }\n  if(!code) return fail('Informe um código.');\n  closeMove();\n  await addMovement(lvl,code);\n}\nfunction renderStage(){\n  const isMain=SESS.stage==='main';\n  const cc=$('#confirmCard'), mw=$('#mainWrap');\n  const hdr=$('#topHeader'), rl=$('#timelineRail');\n  if(hdr) hdr.classList.toggle('hidden', !isMain);\n  if(rl) rl.classList.toggle('hidden', !isMain);\n  if(cc) cc.classList.toggle('hidden', isMain);\n  if(mw) mw.classList.toggle('hidden', !isMain || !SESS.timeline.length);\n  const cb=$('#confirmBody');\n  if(cb && !isMain) cb.innerHTML = SESS.snapshotRaw\n    ? (SESS.snapshotPinnedDate\n        ? `📌 Snapshot histórico congelado · fixado em <b>${fmtSyncedAt(SESS.snapshotRaw)}</b> · <b>${SESS.sectorsList.length}</b> setor(es) disponível(eis) pra fechar. Nenhuma consulta de detalhe roda até você confirmar e escolher o primeiro setor.`\n        : `Snapshot congelado · atualizado em <b>${fmtSyncedAt(SESS.snapshotSyncedAt)}</b> · <b>${SESS.sectorsList.length}</b> setor(es) disponível(eis) pra fechar. Nenhuma consulta de detalhe roda até você confirmar e escolher o primeiro setor.`)\n    : 'congelando snapshot…';\n}\n\n// ---------- session persistence ----------\n// SESSIONS_DS (`geo-timeline-sessions`) and SHARED_DS (`timeline-shared-sessions`) are now TWO\n// SEPARATE tables — they used to be the same DataSet wearing two row shapes, but that let a\n// write-once shared snapshot show up next to editable working sessions in the same store:\n//   · working session  — carries `movements`; lives in SESSIONS_DS; saveSession updates it in place\n//   · shared snapshot  — carries `sharedAt` + `stages`; lives in SHARED_DS; doShare CREATES it and\n//     nothing ever updates it\n// listSessions still excludes any row carrying `sharedAt` as a defensive guard (belt-and-braces\n// now that the tables are split, load-bearing back when they were one).\n// Both lookups (`d2.data('sessionsDataset')||d2.data(SESSIONS_DS)`) stay because a policy-gated\n// DataSet's Process resolves late; same race the reader component polls for. SESSIONS_DS lives at\n// module scope so no call site can hit it in the TDZ.\nasync function listSessions(){\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS)); if(!ds) return [];\n  await ds.refresh();\n  const recs=ds.records()||[];\n  return [...recs].filter(r=>!r.sharedAt).sort((a,b)=>new Date(b.updatedAt||0)-new Date(a.updatedAt||0));\n}\nasync function refreshSessionPicker(){\n  const list=await listSessions().catch(()=>[]);\n  SESS.sessionsList=list;\n  const opts=list.map(s=>`<option value=\"${s._id}\">${esc(s.label||'sem nome')} · ${fmt((s.movements||[]).length)} etapa(s)</option>`).join('')+`<option value=\"\">+ Nova sessão</option>`;\n  const sel=$('#sessionPick'); if(sel){ sel.innerHTML=opts; sel.value=SESS.sessionId||''; initMacSelect(sel); }\n  const csel=$('#confirmSessionPick'); if(csel){ csel.innerHTML=opts; csel.value=SESS.sessionId||''; initMacSelect(csel); }\n}\nasync function saveSession(){\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS));\n  if(!ds){ $('#sessStatus').textContent='dataset de sessões indisponível'; return; }\n  if(!SESS.timeline.length){ $('#sessStatus').textContent='nada pra salvar ainda'; return; }\n  await flushDirty();\n  const label=SESS.sessionLabel||('Sessão '+new Date().toLocaleString('pt-BR'));\n  const nowIso=new Date().toISOString();\n  const payload={\n    label,\n    settings:{...DEFAULT_SETTINGS,...(SESS.settings||{})},\n    snapshotRaw:SESS.snapshotRaw, snapshotSyncedAt:SESS.snapshotSyncedAt, snapshotPinnedDate:SESS.snapshotPinnedDate, snapLiteral:SESS.snapLiteral, eloMaxCycle:SESS.eloMaxCycle,\n    // A session now freezes the RENDERED business data per etapa (trimmed to what the render layer\n    // actually reads), not just the recipe — loading no longer re-queries Databricks. Map geometry\n    // (geoFeat/nbrFeat/muniCentroid/secCentroid/sharedMuniSlices) and anything from Overpass/OSM are\n    // deliberately NEVER frozen — hydrateFromFrozen() rebuilds those live from ufList/closingKeys/\n    // nbrKeys against whatever shapes are current. GV/mercado etapas skip the setor-only fields\n    // (matrix/levelByMuni/allowed/muniSiblings/sharedSiblingCodes/orphan*) since renderMatrix* never\n    // shows them at those levels — this is also what keeps a large GV/mercado closure's payload\n    // bounded, after an earlier full-state freeze blew the write-size limit (tens of MB for one).\n    movements:SESS.timeline.map(mv=>{\n      const isSetor=(mv.level||'setor')==='setor';\n      return {\n        sector:mv.sector, level:mv.level||'setor', assign:mv.assign, policy:mv.policy, optBadge:mv.optBadge,\n        dest:mv.dest, munis:mv.munis, leaders:mv.leaders, baseMetrics:mv.baseMetrics, baseLevel:mv.baseLevel,\n        trend:mv.trend, pureOwnMetrics:mv.pureOwnMetrics, muniOwners:mv.muniOwners,\n        rejectedCodes:[...(mv.rejectedCodes||[])], rejectedInfo:mv.rejectedInfo, nbrCurrentSector:mv.nbrCurrentSector,\n        validCandidates:[...(mv.validCandidates||[])], gvRejected:mv.gvRejected, unitOfMuni:mv.unitOfMuni, unitMunis:mv.unitMunis,\n        closeAdj:mv.closeAdj, csvMunisTotal:mv.csvMunisTotal, domCsvMunis:mv.domCsvMunis,\n        excludedMunis:mv.excludedMunis, excludedCbs:mv.excludedCbs, noShapeMunis:mv.noShapeMunis, noShapeCbs:mv.noShapeCbs,\n        cbsByMuni:mv.cbsByMuni, gv:mv.gv, domUF:mv.domUF, ufList:mv.ufList, closingKeys:Object.keys(mv.geoFeat||{}),\n        nbrKeys:(mv.nbrFeat||[]).map(f=>mkey(f.properties.name||f.properties.NOME||f.properties.nome,f.__uf)),\n        optTier:mv.optTier, reachable:mv.reachable||{},\n        ...(mv.kind==='transfer'?{kind:'transfer',xferFrom:mv.xferFrom,xferTo:mv.xferTo,xferMuni:mv.xferMuni,xferMetrics:mv.xferMetrics,xferRows:mv.xferRows}:{}),\n        ...(isSetor?{\n          matrix:(mv.matrix||[]).map(r=>({municipio:r.municipio,uf:r.uf,setor:r.setor,grupo:r.grupo,cbs:r.cbs})),\n          levelByMuni:mv.levelByMuni, allowed:mv.allowed, muniSiblings:mv.muniSiblings,\n          sharedSiblingCodes:[...(mv.sharedSiblingCodes||[])], missingMunis:mv.missingMunis,\n          orphanByGroup:mv.orphanByGroup, orphanMetricsByGroup:mv.orphanMetricsByGroup, orphanLevelByGroup:mv.orphanLevelByGroup,\n          foraDoSetorByGrupo:mv.foraDoSetorByGrupo,\n        }:{}),\n      };\n    }),\n    sectorBaseCache:{...SESS.sectorBaseCache},\n    updatedAt:nowIso,\n  };\n  $('#sessStatus').textContent='salvando…';\n  try{\n    if(SESS.sessionId){\n      await ds.update({_id:SESS.sessionId,...payload});\n    } else {\n      payload.createdAt=nowIso;\n      const created=await ds.create(payload);\n      SESS.sessionId=(created&&created._id)||(created&&created.data&&created.data._id)||(ds.selected&&ds.selected()&&ds.selected()._id)||null;\n    }\n    SESS.sessionLabel=label;\n    await refreshSessionPicker();\n    $('#sessStatus').textContent='salvo ✓';\n  }catch(e){ $('#sessStatus').textContent='erro ao salvar: '+e.message; }\n  setTimeout(()=>{ if($('#sessStatus')) $('#sessStatus').textContent=''; },2200);\n}\n// Duplicate = drop sessionId so saveSession takes its CREATE branch instead of its update branch.\n// The source record is never written to, the copy gets a fresh _id and its own createdAt, and the\n// copy becomes the active session (so the next Salvar edits the copy, not the original).\nasync function renameSession(){\n  const next=prompt('Novo nome da sessão:', SESS.sessionLabel||'');\n  if(next==null) return;\n  const trimmed=next.trim(); if(!trimmed) return;\n  SESS.sessionLabel=trimmed;\n  if(!SESS.sessionId) return;   // ainda não salva — o novo nome só pega valer no próximo \"Salvar sessão\"\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS)); if(!ds) return;\n  if($('#sessStatus')) $('#sessStatus').textContent='renomeando…';\n  try{\n    await ds.update({_id:SESS.sessionId, label:trimmed, updatedAt:new Date().toISOString()});\n    await refreshSessionPicker();\n    if($('#sessStatus')) $('#sessStatus').textContent='renomeado ✓';\n  }catch(e){ if($('#sessStatus')) $('#sessStatus').textContent='erro ao renomear: '+e.message; }\n  setTimeout(()=>{ const s=$('#sessStatus'); if(s&&(s.textContent==='renomeado ✓')) s.textContent=''; }, 2000);\n}\nasync function duplicateSession(){\n  if(!SESS.timeline.length){ $('#sessStatus').textContent='nada pra duplicar ainda'; return; }\n  const prevId=SESS.sessionId, prevLabel=SESS.sessionLabel;\n  SESS.sessionId=null;\n  SESS.sessionLabel=(SESS.sessionLabel||'Sessão')+' (cópia)';\n  await saveSession();\n  if(!SESS.sessionId){\n    // saveSession swallows its own errors into #sessStatus, so a still-null id after the await is\n    // the only failure signal we get. Put the original identity back — otherwise a failed copy\n    // silently leaves the session detached, and the next Salvar would fork a new record instead\n    // of updating the one the user thought they were editing.\n    SESS.sessionId=prevId; SESS.sessionLabel=prevLabel;\n    await refreshSessionPicker().catch(()=>{});\n    return;\n  }\n  if($('#sessStatus')) $('#sessStatus').textContent='duplicado ✓';\n}\nasync function loadSession(id){\n  if(!id) return newSession();\n  const list=(SESS.sessionsList&&SESS.sessionsList.length)?SESS.sessionsList:await listSessions().catch(()=>[]);\n  const rec=list.find(s=>String(s._id)===String(id));\n  if(!rec) return newSession();\n  loader(true,'carregando sessão…');\n  SESS.sessionId=rec._id; SESS.sessionLabel=rec.label;\n  SESS.snapshotRaw=rec.snapshotRaw; SESS.snapshotSyncedAt=rec.snapshotSyncedAt||null; SESS.snapLiteral=rec.snapLiteral; SESS.eloMaxCycle=rec.eloMaxCycle;\n  SESS.snapshotPinnedDate=rec.snapshotPinnedDate||null;\n  SESS.settings={...DEFAULT_SETTINGS,...(rec.settings||{}),span:migrateSpanSettings(rec.settings)};\n  SESS.timeline=(rec.movements||[]).map(m=>({sector:String(m.sector),level:m.level||'setor'}));\n  SESS.sectorBaseCache={...(rec.sectorBaseCache||{})};\n  SESS.selected=0; SESS.stage='main'; SESS.dirtyFrom=null;\n  try{\n    // A saved session now freezes the RENDERED business data per etapa (hydrateFromFrozen), not just\n    // the recipe — loading no longer re-queries Databricks. Whether the session ends up LOCKED depends\n    // on comparing its frozen snapshot against the current live one: identical means nothing is stale,\n    // so it loads as a normal editable session; different means the frozen data is behind, so it's\n    // locked until refreshSnapshot() recomputes against the live snapshot. A failed check defaults to\n    // locked (safe: gate mutations rather than silently assume the frozen data is still current).\n    try{\n      const liveP=await resolveSnapshotPin();\n      SESS.locked = !liveP.snapshotRaw || String(liveP.snapshotRaw)!==String(rec.snapshotRaw);\n    }catch(e){ SESS.locked=true; }\n    // suggestMuniMoves/computeMovement assume DOM.bySetor is already populated — true in the live\n    // editing flow (loadMoveOptions calls ensureDominantes before any movement can exist), but a\n    // loaded session can have etapas from the moment it lands, without ever visiting that flow. Kicked\n    // off here (parallel with hydration/geometry fetches) so it's ready by the time the UI is\n    // interactive; a failure is surfaced but doesn't block the frozen data from displaying.\n    const domP=ensureDominantes().catch(e=>e);\n    for(let i=0;i<SESS.timeline.length;i++){\n      const saved=(rec.movements||[])[i]||{};\n      loader(true,`carregando etapa ${i+1}/${SESS.timeline.length} — setor ${SESS.timeline[i].sector}…`);\n      await hydrateFromFrozen(i,saved);\n    }\n    await loadSectors();\n    const domRes=await domP;\n    if(domRes instanceof Error){ $('#ds').textContent='sessão carregada, mas a base de municípios dominantes falhou: '+domRes.message+' — sugestões de correção e \"Adicionar movimentação\" ficam indisponíveis até recarregar.'; $('#ds').classList.add('err'); }\n  }catch(e){ $('#ds').textContent='erro ao carregar sessão: '+e.message; $('#ds').classList.add('err'); }\n  finally{ loader(false); }\n  renderAll();\n}\nfunction newSession(){\n  SESS.sessionId=null; SESS.sessionLabel=null; SESS.timeline=[]; SESS.selected=0; SESS.sectorBaseCache={};\n  SESS.settings={...DEFAULT_SETTINGS};\n  SESS.snapshotRaw=null; SESS.snapshotSyncedAt=null; SESS.snapshotPinnedDate=null; SESS.snapLiteral=null; SESS.eloMaxCycle=null; SESS.stage='loading'; SESS.dirtyFrom=null; SESS.locked=false;\n  (async()=>{\n    loader(true,'iniciando nova sessão…');\n    try{ await Promise.all([ensurePinned(), loadSectors()]); SESS.stage='confirm'; }\n    catch(e){ setError('erro init: '+e.message); }\n    finally{ loader(false); renderAll(); }\n  })();\n}\n\n// ---------- snapshot refresh ----------\n// Deliberately breaks the frozen-snapshot guarantee, on demand: re-resolves DT_HISTORICO and\n// replays the whole timeline against the new one. Skips the replay when the snapshot has not\n// actually moved, so a stray click on a long timeline costs one cheap probe instead of ~8 queries\n// per etapa.\nasync function refreshSnapshot(){\n  if((SESS.stage!=='main' && SESS.stage!=='confirm') || SESS.snapBusy) return;\n  SESS.snapBusy=true;\n  const prevRaw=SESS.snapshotRaw;\n  const pill=$('#liveState'); if(pill){ pill.classList.add('busy'); pill.textContent='◌ atualizando snapshot…'; }\n  const sst0=$('#snapStatus'); if(sst0) sst0.textContent='buscando o snapshot mais recente…';\n  loader(true,'buscando o snapshot mais recente…');\n  let ok=false;\n  try{\n    const p=await resolveSnapshotPin();\n    if(!p.snapshotRaw) throw new Error('não foi possível resolver DT_HISTORICO');\n    const same=String(p.snapshotRaw)===String(prevRaw);\n    SESS.snapshotRaw=p.snapshotRaw; SESS.snapshotSyncedAt=p.syncedAt; SESS.snapLiteral=p.snapLiteral; SESS.eloMaxCycle=p.eloMaxCycle;\n    SESS.snapshotPinnedDate=null;   // clicking back to \"live\" always un-pins any historical date\n    SESS.locked=false;   // updating the snapshot always unlocks — this session is live again now\n    await loadSectors();   // the sector list follows the live max, so it can have moved too\n    let msg;\n    if(same){\n      msg=`snapshot já era o mais recente · atualizado em ${fmtSyncedAt(p.syncedAt)} — nada recalculado`;\n    } else if(SESS.timeline.length){\n      SESS.dirtyFrom=null;\n      SESS.sectorBaseCache={};   // baselines come from the snapshot, so every one of them changed\n      loader(true,'snapshot novo — recalculando a timeline…');\n      await recomputeFrom(0);\n      msg=`snapshot atualizado em ${fmtSyncedAt(p.syncedAt)} · ${fmt(SESS.timeline.length)} etapa(s) recalculada(s)`;\n    } else {\n      msg=`snapshot atualizado em ${fmtSyncedAt(p.syncedAt)}`;\n    }\n    const d=$('#ds'); if(d){ d.textContent=msg; d.classList.remove('err'); }\n    const sst=$('#snapStatus'); if(sst) sst.textContent=msg;\n    ok=true;\n  }catch(e){\n    const at=(SESS.dirtyFrom==null?null:SESS.dirtyFrom+1);\n    const errMsg='erro ao atualizar o snapshot'+(at?` na etapa ${at} — etapa ${at} em diante ficou desatualizada (⟳)`:'')+': '+e.message;\n    const d=$('#ds'); if(d){ d.textContent=errMsg; d.classList.add('err'); }\n    const sst=$('#snapStatus'); if(sst) sst.textContent=errMsg;\n  }finally{\n    SESS.snapBusy=false;\n    if(pill) pill.classList.remove('busy');\n    loader(false); renderAll();\n    if(ok) setTimeout(()=>{ const d=$('#ds'); if(d&&!d.classList.contains('err')) d.textContent=''; },7000);\n  }\n}\n\n\n// ---------- session options ----------\nfunction readOptForm(){\n  const scopeOf=lvl=>({\n    targetSpan:numOrNull($('#optTargetSpan_'+lvl).value),\n    maxSpan:numOrNull($('#optMaxSpan_'+lvl).value),\n    minSpan:numOrNull($('#optMinSpan_'+lvl).value),\n  });\n  // Mercado span has no UI control anymore — carry the session's current value through untouched\n  // instead of reading DOM inputs that no longer exist.\n  const fc=root.querySelector('input[name=\"forceContig\"]:checked');\n  return {span:{setor:scopeOf('setor'), gv:scopeOf('gv'), mercado:mergedSpanAll().mercado},\n    forceContiguity: fc?fc.value==='1':!!(SESS.settings&&SESS.settings.forceContiguity)};\n}\nfunction spanScopeError(sc,label){\n  if(sc.minSpan&&sc.maxSpan&&sc.minSpan>sc.maxSpan) return `${label} — span mínimo (${fmt(sc.minSpan)}) não pode ser maior que o máximo (${fmt(sc.maxSpan)}).`;\n  if(sc.targetSpan&&sc.maxSpan&&sc.targetSpan>sc.maxSpan) return `${label} — span alvo (${fmt(sc.targetSpan)}) não pode passar do máximo (${fmt(sc.maxSpan)}).`;\n  if(sc.targetSpan&&sc.minSpan&&sc.targetSpan<sc.minSpan) return `${label} — span alvo (${fmt(sc.targetSpan)}) não pode ficar abaixo do mínimo (${fmt(sc.minSpan)}).`;\n  return '';\n}\nfunction spanFormError(f){\n  return spanScopeError(f.span.setor,'Setor')||spanScopeError(f.span.gv,'GV')||spanScopeError(f.span.mercado,'Mercado');\n}\nfunction renderOptPreview(){\n  const el=$('#optPreview'); if(!el) return;\n  const f=readOptForm();\n  const err=spanFormError(f), ee=$('#optSpanErr');\n  if(ee){ ee.textContent=err; ee.classList.toggle('hidden',!err); }\n  const ap=$('#optApply'); if(ap) ap.disabled=!!err;\n  const mv=SESS.timeline[SESS.selected];\n  if(!mv||!mv.munis){ el.innerHTML='Nenhuma etapa calculada ainda — as regras passam a valer no próximo fechamento.'; return; }\n  const lvl=mv.level||'setor', sc=f.span[lvl];\n  let h=`<b style=\"color:var(--ink)\">Prévia — etapa ${SESS.selected+1}, ${lc(LEVELS[lvl].label)} ${mv.sector}</b><br>`;\n  if(sc.targetSpan||sc.maxSpan||sc.minSpan){\n    const sp=spanMapFor(mv,mv.assign||{}), v=spanViolationsOf(sp,{target:sc.targetSpan,max:sc.maxSpan,min:sc.minSpan});\n    h+=`Span dos destinos como está hoje: ${mv.dest.map(s=>s+' <b>'+fmt(sp[s])+'</b>').join(' · ')||'–'}`;\n    if(v.over.length||v.under.length) h+=`<br><span style=\"color:var(--closing)\">Fora dos limites: ${[...v.over.map(s=>s+' ▲ acima do máx'),...v.under.map(s=>s+' ▼ abaixo do mín')].join(', ')}</span> — o modelo ÓTIMO tenta corrigir no recálculo.`;\n    else h+=` <span style=\"color:var(--ok)\">— já dentro dos limites</span>`;\n  } else {\n    h+=`Sem limite de span pra ${lc(LEVELS[lvl].label)} — ${lc(LEVELS[lvl].destLabel)} destino pode ficar de qualquer tamanho.`;\n  }\n  if(SESS.timeline.length) h+=`<br><span style=\"margin-top:4px;display:inline-block\">Aplicar <b>recalcula as ${fmt(SESS.timeline.length)} etapa(s)</b> contra o snapshot congelado — os destinos ajustados à mão serão refeitos pelo modelo ÓTIMO.</span>`;\n  el.innerHTML=h;\n}\nfunction openOptions(){\n  const sp=mergedSpanAll();\n  ['setor','gv'].forEach(lvl=>{\n    $('#optTargetSpan_'+lvl).value=sp[lvl].targetSpan||'';\n    $('#optMaxSpan_'+lvl).value=sp[lvl].maxSpan||'';\n    $('#optMinSpan_'+lvl).value=sp[lvl].minSpan||'';\n  });\n  const fcVal=(SESS.settings&&SESS.settings.forceContiguity)?'1':'0';\n  const fcEl=root.querySelector('input[name=\"forceContig\"][value=\"'+fcVal+'\"]');\n  if(fcEl) fcEl.checked=true;\n  $('#optApply').disabled=false;\n  $('#optSessName').textContent=SESS.sessionLabel||'sessão não salva';\n  $('#optStatus').textContent='';\n  $('#optionsBg').classList.remove('hidden');\n  renderOptPreview();\n  const c=$('#optCancel'); if(c) c.focus();\n}\nfunction closeOptions(){ const b=$('#optionsBg'); if(b) b.classList.add('hidden'); }\nfunction openLockedSheet(){ const b=$('#lockedBg'); if(b) b.classList.remove('hidden'); const s=$('#lockedStatus'); if(s) s.textContent=''; const c=$('#lockedCancel'); if(c) c.focus(); }\nfunction closeLockedSheet(){ const b=$('#lockedBg'); if(b) b.classList.add('hidden'); }\nfunction openDiscarded(closures){\n  const bd=$('#discardedBd'); if(!bd) return;\n  let html='';\n  if(closures.length){\n    html+=`<tr class=\"lider-foot\"><td colspan=\"4\">Distribuições de fechamento descartadas — setor ${esc(String(closures[0].setor))}</td></tr>`\n      +closures.map((r,i)=>`<tr><td colspan=\"2\">${esc(r.distribuicao)}</td><td>${esc(r.motivo)}</td><td><button class=\"ghost apply-disc-closure\" data-idx=\"${i}\">Aplicar</button></td></tr>`).join('');\n  }\n  bd.innerHTML=html || '<tr><td colspan=\"4\" class=\"note\">Nada descartado nesta busca.</td></tr>';\n  bd.querySelectorAll('.apply-disc-closure').forEach(b=>{\n    b.onclick=async()=>{\n      b.disabled=true; b.textContent='aplicando…';\n      const r=closures[+b.dataset.idx];\n      try{\n        const lenBefore=SESS.timeline.length;\n        await addMovement(r.level, r.setor);\n        if(SESS.timeline.length<=lenBefore){ b.disabled=false; b.textContent='Aplicar'; return; }\n        await editSelected(mv2=>{ mv2.assign={...r.assign}; mv2.optBadge='alternativa descartada aplicada manualmente'; });\n        closeDiscarded();\n      }catch(e){ b.disabled=false; b.textContent='erro: '+e.message; }\n    };\n  });\n  const b=$('#discardedBg'); if(b) b.classList.remove('hidden');\n}\nfunction closeDiscarded(){ const b=$('#discardedBg'); if(b) b.classList.add('hidden'); }\nfunction guardLocked(){ if(!SESS.locked) return false; openLockedSheet(); return true; }\nfunction pillClick(){ if(SESS.locked){ openLockedSheet(); return; } refreshSnapshot(); }\nasync function applyOptions(){\n  if(guardLocked()) return;\n  const f=readOptForm();\n  if(spanFormError(f)) return;\n  const prevSp=mergedSpanAll();\n  const changed=['setor','gv','mercado'].some(lvl=>\n    numOrNull(prevSp[lvl].targetSpan)!==f.span[lvl].targetSpan\n    || numOrNull(prevSp[lvl].maxSpan)!==f.span[lvl].maxSpan\n    || numOrNull(prevSp[lvl].minSpan)!==f.span[lvl].minSpan\n  );\n  SESS.settings={...SESS.settings,span:f.span,forceContiguity:f.forceContiguity};\n  closeOptions();\n  if(!changed || !SESS.timeline.length){ renderAll(); return; }\n  SESS.dirtyFrom=null;\n  SESS.sectorBaseCache={};   // the recompute re-derives every baseline; keeping the old ones would\n                             // leave \"Impacto acumulado\" comparing against pre-change numbers\n  loader(true,'aplicando novos limites de span — recalculando timeline…');\n  try{\n    await recomputeFrom(0);\n    $('#ds').textContent=''; $('#ds').classList.remove('err');\n  }catch(e){\n    const at=(SESS.dirtyFrom==null?0:SESS.dirtyFrom)+1;\n    $('#ds').textContent=`erro ao aplicar o limite na etapa ${at}: ${e.message} — etapa ${at} em diante ficou desatualizada (⟳)`;\n    $('#ds').classList.add('err');\n  }finally{ loader(false); renderAll(); }\n}\n\nconst SHARED_DS='timeline-shared-sessionsDataSet';\nconst AUTH_REF='timelineSimAuthFinal';   // single source of truth for the auth strategy reference\nlet SHARE={busy:false,url:null,last:null};\n// Serialise a PRESENTATION model, not the raw session, so the reader stays purely presentational\n// and never re-derives spans/desloc/orphans — no second copy of this engine to keep in sync.\n// NOTE: METRICS_DEF[2] is the money marker; read it as !!md[2] (a literal dollar sign in this\n// position trips the upsert's parse check).\nfunction buildShareStages(){\n  return SESS.timeline.map((mv,i)=>{\n    const c=computeDerived(mv);\n    const incBy={};\n    mv.dest.forEach(s=>{ incBy[s]=Object.fromEntries(METRICS_DEF.map(md=>[md[0],0])); });\n    Object.entries(mv.munis).forEach(([m,o])=>{\n      if(m==='__OUTROS__') return;\n      const s=mv.assign[m]; if(!s||!incBy[s]) return;\n      METRICS_DEF.forEach(md=>{ incBy[s][md[0]]+=o[md[0]]||0; });\n    });\n    const saiTot={};\n    METRICS_DEF.forEach(md=>{\n      const k=md[0];\n      saiTot[k]=Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,p)=>a+((p[1][k])||0),0);\n    });\n    // orphans follow their leader's home: they enter that sector AND leave the closing one\n    c.leaders.forEach(L=>{\n      const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n      if(!om||!L.home) return;\n      METRICS_DEF.forEach(md=>{ const k=md[0];\n        if(incBy[L.home]) incBy[L.home][k]+=om[k]||0;\n        saiTot[k]+=om[k]||0; });\n    });\n    const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n    const sv=mv.dest.map(s=>c.span[s]);\n    const baseOf=(s,k)=>{ const b=mv.baseMetrics[s]||{}; return +(b[k]||0); };\n    return {\n      i:i, sector:mv.sector, level:mv.level||'setor', kind:mv.kind||null, xferFrom:mv.xferFrom||null, xferTo:mv.xferTo||null, xferMuni:mv.xferMuni||null, xferRows:mv.xferRows||null,\n      unitOfMuni:mv.unitOfMuni||{}, policy:mv.policy, domUF:mv.domUF,\n      optTier:(mv.optTier==null?0:mv.optTier),\n      dest:mv.dest.slice(), color:Object.assign({},mv.color), assign:Object.assign({},mv.assign),\n      kpis:{ cbsRealocadas:closingCbsOf(mv), desloc:c.desloc,\n             spread:(sv.length?Math.max.apply(null,sv)-Math.min.apply(null,sv):0),\n             municipios:ms.length, lideres:mv.leaders.length },\n      destinos:mv.dest.map(s=>({ s:s, base:baseOf(s,'cbs'), inc:c.inc[s]||0, orph:c.orph[s]||0, span:c.span[s]||0, gained:c.gained[s]||0 })),\n      municipios:ms.map(m=>(mv.level&&mv.level!=='setor')?{ nome:m, uf:'', cbs:mv.munis[m].cbs, destino:mv.assign[m]||null }:{ nome:mName(m), uf:mUf(m), cbs:mv.munis[m].cbs, destino:mv.assign[m]||null }),\n      excluidos:(mv.excludedMunis||[]).map(x=>({ muni:x.muni, cbs:x.cbs })),\n      orphanTotal:c.orphanTotal||0, orphanStranded:c.orphanStranded||0, noShapeCbs:mv.noShapeCbs||0,\n      lideres:c.leaders.map(L=>({ lider:(L.lider||'').split(' ').slice(0,2).join(' '), grupo:L.grupo,\n        total:L.total, home:L.home, desloc:L.desloc, orphan:L.orphan||0 })),\n      metricas:METRICS_DEF.map(md=>{ const k=md[0];\n        return { key:k, label:md[1], money:!!md[2],\n          rows:mv.dest.map(s=>({ s:s, antes:baseOf(s,k), depois:baseOf(s,k)+((incBy[s]&&incBy[s][k])||0) })),\n          sai:saiTot[k]||0 }; }),\n      geoFeat:mv.geoFeat, nbrFeat:mv.nbrFeat, excludedFeat:[],\n      // Everything below is for the shared read-only viewer (CNL_4z20SJcX0NMp2I2vz) to reproduce\n      // this live map's shared-município mosaic (hatch, clean outline, per-context colouring) —\n      // without it, a município like São Paulo can only ever render as this sector's own slice\n      // plus flat grey \"context\", because none of the resolution work (who else really owns it,\n      // what colour that owner gets, whether it's a rejected/novalid sibling) survived into the\n      // saved record. Every value here is already fully RESOLVED at this point — the read view\n      // only ever renders them, it never re-derives a business rule (no live DOM/world access\n      // there anyway). rejectedCodes is a Set, spread to an array — JSON has no Set.\n      sharedMuniSlices:(mv.sharedMuniSlices||[]).map(sl=>({feature:sl.feature,sector:sl.sector,muni:sl.muni})),\n      muniFullShape:mv.muniFullShape||{},\n      nbrCurrentSector:mv.nbrCurrentSector||{},\n      rejectedCodes:[...(mv.rejectedCodes||[])],\n      muniOwners:mv.muniOwners||{}\n    };\n  });\n}\nfunction buildShareSummary(stages){\n  const world=foldWorldState(SESS.timeline,SESS.timeline.length);\n  const receivers=[];\n  SESS.timeline.forEach((mv,i)=>{\n    if(mv.kind==='transfer') return;\n    const own=mv.pureOwnMetrics||{};\n    receivers.push({ s:mv.sector, situacao:'fechado (etapa '+(i+1)+')', antes:+(own.cbs||0), depois:0 });\n  });\n  const seen={};\n  Object.values(world.muniOverride).forEach(s=>{\n    if(seen[s]) return; seen[s]=1;\n    const base=SESS.sectorBaseCache[s]||{}, infl=world.sectorInflow[s]||{};\n    receivers.push({ s:s, situacao:'recebeu território', antes:+(base.cbs||0), depois:(+(base.cbs||0))+(infl.cbs||0) });\n  });\n  return { stages:stages.length, sectors:SESS.timeline.map(mv=>mv.sector),\n    totalCbs:stages.reduce((a,s)=>a+(s.kpis.cbsRealocadas||0),0),\n    totalDesloc:stages.reduce((a,s)=>a+(s.kpis.desloc||0),0), receivers:receivers };\n}\nfunction renderShareBody(){\n  const el=$('#shareBody'); if(!el) return;\n  const go=$('#shareGo'), cp=$('#shareCopy'), op=$('#shareOpen'), cc=$('#shareCancel');\n  if(SHARE.url){\n    el.innerHTML=`<div class=\"opt-note\" style=\"margin-top:0\"><b style=\"color:var(--ink)\">Sessão compartilhada ✓</b><br>\n      Link de leitura desta simulação congelada. Quem abrir precisa ter login no simulador.\n      <div class=\"shareurl\">${esc(SHARE.url)}</div></div>`;\n    if(go) go.classList.add('hidden');\n    if(cp) cp.classList.remove('hidden');\n    if(op) op.classList.remove('hidden');\n    if(cc) cc.textContent='Fechar';\n    return;\n  }\n  if(go) go.classList.remove('hidden');\n  if(cp) cp.classList.add('hidden');\n  if(op) op.classList.add('hidden');\n  if(cc) cc.textContent='Cancelar';\n  const desloc=SESS.timeline.reduce((a,mv)=>a+(mv.munis?computeDerived(mv).desloc:0),0);\n  el.innerHTML=`<div class=\"opt-lbl\">Confirmar compartilhamento</div>\n    <div class=\"opt-sub\">Publica uma <b>cópia congelada</b> desta sessão, exatamente no estado atual.</div>\n    <div class=\"opt-note\" style=\"margin-top:0\">\n      · <b>${fmt(SESS.timeline.length)}</b> etapa(s) — ${esc(SESS.timeline.map(mv=>mv.sector).join(' → '))}<br>\n      · snapshot atualizado em <b>${esc(fmtSyncedAt(SESS.snapshotSyncedAt))}</b><br>\n      · base: <b>municípios dominantes (CSV)</b> · destino: <b>qualquer município</b> · span: <b>${esc(spanShortAll())}</b><br>\n      · <b>${fmt(desloc)}</b> CBs deslocadas somando a cadeia\n    </div>\n    <div class=\"opt-note\">A cópia <b>não</b> acompanha edições posteriores desta sessão — pra atualizar, compartilhe de novo e use o link novo. O link exige login no simulador; não é público.</div>\n    ${SHARE.last?`<div class=\"opt-note\"><b style=\"color:var(--ink)\">Último link gerado nesta sessão</b> — aponta pro estado de <b>antes</b> das edições feitas depois dele.<div class=\"shareurl\">${esc(SHARE.last)}</div></div>`:''}`;\n}\nasync function openShare(){\n  if(!SESS.timeline.length){ $('#ds').textContent='nada pra compartilhar — feche pelo menos um setor primeiro.'; $('#ds').classList.add('err'); return; }\n  await flushDirty();\n  SHARE.url=null;\n  const s=$('#shareStatus'); if(s){ s.textContent=''; s.classList.remove('err'); }\n  renderShareBody();\n  $('#shareBg').classList.remove('hidden');\n  const b=$('#shareCancel'); if(b) b.focus();\n}\nfunction closeShare(){ const b=$('#shareBg'); if(b) b.classList.add('hidden'); }\nasync function doShare(){\n  if(SHARE.busy) return;\n  const go=$('#shareGo'), stx=$('#shareStatus');\n  SHARE.busy=true;\n  if(go){ go.disabled=true; go.textContent='Compartilhando…'; }\n  if(stx){ stx.textContent=''; stx.classList.remove('err'); }\n  try{\n    // Resolved as a GLOBAL PROCESS, not a declared param — which only works because the block\n    // instance on the `main` artboard binds `sharedDataset: @timeline-shared-sessionsDataSet`.\n    // That binding is load-bearing: a Process is only define()d once something on the rendering\n    // tree references it, so without it this lookup returns null and sharing dies on the guard\n    // below. Do NOT remove the instance param because this file never reads d2.params.sharedDataset.\n    const ds=d2.data(SHARED_DS);\n    if(!ds) throw new Error('dataset de sessões compartilhadas indisponível');\n    await flushDirty();\n    const stages=buildShareStages();\n    const summary=buildShareSummary(stages);\n    let who='';\n    try{ const a=d2.auth(AUTH_REF); who=(a&&a.user&&(a.user.email||a.user.nome))||''; }catch(e){}\n    const created=await ds.create({\n      label:SESS.sessionLabel||('Sessão '+new Date().toLocaleString('pt-BR')),\n      snapshotRaw:SESS.snapshotRaw, snapshotSyncedAt:SESS.snapshotSyncedAt,\n      settings:{...DEFAULT_SETTINGS,...(SESS.settings||{})},\n      stages:stages, summary:summary, sharedBy:who, sharedAt:new Date().toISOString()\n    });\n    const id=(created&&created._id)||(created&&created.data&&created.data._id)||(ds.selected&&ds.selected()&&ds.selected()._id)||null;\n    if(!id) throw new Error('registro gravado, mas sem _id na resposta — não foi possível montar o link');\n    SHARE.url=window.location.origin+'/sessions/'+id;\n    SHARE.last=SHARE.url;\n    renderShareBody();\n  }catch(e){\n    if(stx){ stx.textContent='erro ao compartilhar: '+e.message; stx.classList.add('err'); }\n  }finally{\n    SHARE.busy=false;\n    if(go){ go.disabled=false; go.textContent='Compartilhar'; }\n  }\n}\n\n// ---------- rendering ----------\nfunction renderTimeline(){\n  const el=$('#movementChips'); if(!el) return;\n  if(!SESS.timeline.length){ el.innerHTML='<span class=\"empty\">Nenhuma etapa ainda — use \"Adicionar movimentação\" à direita.</span>'; return; }\n  el.innerHTML=SESS.timeline.map((mv,i)=>{\n    const stale=SESS.dirtyFrom!=null && i>=SESS.dirtyFrom;\n    const on=i===SESS.selected?'on':'';\n    const isXfer=mv.kind==='transfer';\n    const label=isXfer\n      ? `<span class=\"tarw\" aria-hidden=\"true\">↳</span><span class=\"tsec\">${esc(mName(mv.xferMuni))}: ${esc(mv.xferFrom)} → ${esc(mv.xferTo)}</span>`\n      : `<span class=\"tsec\">${LEVELS[mv.level||'setor'].label} ${mv.sector}</span>`;\n    return `<div class=\"tchip ${isXfer?'tchip-transfer':''} ${on} ${stale?'stale':''}\" data-i=\"${i}\">\n      <span class=\"tnum\">${i+1}</span>${label}\n      ${stale?'<span class=\"tstale\" title=\"recalcula ao selecionar outra etapa\">⟳</span>':''}\n      <span class=\"trm\" data-rm=\"${i}\" title=\"remover esta etapa e as seguintes\">✕</span>\n    </div>`;\n  }).join('');\n  el.querySelectorAll('.tchip').forEach(ch=>{\n    ch.addEventListener('click',async (e)=>{\n      if(e.target.dataset.rm!=null && e.target.dataset.rm!==''){ removeFrom(+e.target.dataset.rm); return; }\n      const i=+ch.dataset.i;\n      if(i!==SESS.selected){ await flushDirty(); DISC_PAINT=null; }\n      SESS.selected=i; renderAll();\n    });\n  });\n}\nfunction renderRuleNote(){\n  const el=$('#ruleNote'); if(!el) return;\n  const mv=SESS.timeline[SESS.selected];\n  const lvl=LEVELS[(mv&&mv.level)||'setor'];\n  let h='';\n  if(!mv || (mv.level||'setor')==='setor'){\n    h+=`<b style=\"color:var(--ink)\">Base de municípios dominantes:</b> só os municípios que a base (CSV) atribui ao setor entram no mapa e na alocação`;\n    if(mv&&mv.domCsvMunis) h+=` · <b>${fmt(mv.domCsvMunis)}</b> na base do setor ${mv.sector}`;\n    if(mv&&mv.excludedCbs) h+=` · <b style=\"color:var(--closing)\">${fmt(mv.excludedMunis.length)} município(s) / ${fmt(mv.excludedCbs)} CBs</b> fora da base nesta etapa — não aparecem no mapa e as CBs seguem suas líderes (quebra de vínculo)`;\n  } else {\n    // GV/mercado não têm spillover (todo setor pertence a exatamente uma GV/mercado na base), então\n    // não existe \"excluído\" pra reportar aqui — só o tamanho da base e em quantas unidades ela se agrupa.\n    h+=`<b style=\"color:var(--ink)\">Base de ${lc(lvl.childLabel)}s dominantes:</b> ${mv.domCsvMunis!=null?`<b>${fmt(mv.domCsvMunis)}</b> município(s) `:''}na base ${lvl.de} ${lc(lvl.label)} ${mv.sector}, agrupados em <b>${fmt(Object.keys(mv.unitMunis||{}).length)}</b> ${lc(lvl.childLabel)}(s)`;\n  }\n  if(DOM.error) h+=` · <b style=\"color:var(--closing)\">⚠ base indisponível: ${esc(DOM.error)}</b>`;\n  el.innerHTML=h;\n}\n// ---------- discarded-move map paint mode ----------\n// Toggled by the \"Ver simulações descartadas\" button in a span-rule fix card: instead of a modal\n// listing every discarded município move, paints the affected municípios directly on the map\n// (same .munihl highlight setPlanHl already uses for its hover preview) and lets a click on one\n// apply that entry's moves — the município's hitbox IS the \"Aplicar\" button. Keyed by the\n// violation `code` so only one fix card's discard list paints at a time, and a re-render of the\n// same card can tell its own button should still read as active.\nlet DISC_PAINT=null;\n// Every discBtn currently on screen registers its own resync callback here so a click that\n// changes DISC_PAINT in ONE rule-issue row can refresh every OTHER row's button too — without\n// this a second expanded row kept reading \"Ocultar\"/active after DISC_PAINT moved elsewhere.\n// Reset once per renderSpanRule() rebuild (the point where the old buttons these callbacks\n// close over actually get discarded), not on every click.\nlet DISC_SYNC_FNS=[];\nconst FIX_CACHE = new WeakMap();\nasync function computeFixSpan(mv, code, lvl, sp, lim, isOver){\n  const closeWork=(async()=>{\n    try{\n      const world=foldWorldState(SESS.timeline,SESS.timeline.length);\n      const pv=await computeMovement(String(code),lvl,world);\n      pv.assign=optimizeFor(pv);\n      const pvLim=spanLimits(pv);\n      const pvSp=spanMapFor(pv,pv.assign||{});\n      const pvV=spanLimitsOn(pvLim)?spanViolationsOf(pvSp,pvLim):{over:[],under:[]};\n      const closureRejected=[];\n      if(pv.optAlternatives){\n        pv.optAlternatives.tiers.forEach(tAlt=>{\n          const spMap=spanMapFor(pv,tAlt.assign);\n          const motivo=tAlt.tierIndex===1?'respeitaria o máximo, mas não o mínimo'\n            :tAlt.tierIndex===2?'não respeitaria os limites de span mínimo nem máximo'\n            :'alternativa válida, mas de custo maior que a escolhida';\n          closureRejected.push({setor:code, level:lvl, assign:{...tAlt.assign}, distribuicao:(pv.dest||[]).map(d=>`${d}: ${fmt(spMap[d])}`).join(' · '), motivo, desloc:tAlt.ev.desloc});\n        });\n        if(pv.optAlternatives.contigBroken){\n          const cb=pv.optAlternatives.contigBroken;\n          const spMap=spanMapFor(pv,cb.assign);\n          closureRejected.push({setor:code, level:lvl, assign:{...cb.assign}, distribuicao:(pv.dest||[]).map(d=>`${d}: ${fmt(spMap[d])}`).join(' · '), motivo:`quebraria a conectividade territorial de ${cb.badSectors.join(', ')}`, desloc:cb.ev.desloc});\n        }\n      }\n      return {ok:true, dest:pv.dest||[], spMap:pvSp, over:pvV.over, under:pvV.under, optTier:pv.optTier, rejected:closureRejected};\n    }catch(e){\n      return {ok:false, errorMsg:e.message, rejected:[]};\n    }\n  })();\n  const moveWork=(async()=>{\n    try{\n      const plan=await suggestMuniMoves(mv,sp,lim,code,isOver);\n      return {ok:true, plan};\n    }catch(e){\n      return {ok:false, errorMsg:e.message, plan:null};\n    }\n  })();\n  const [close,move]=await Promise.all([closeWork,moveWork]);\n  return {code, isOver, close, move};\n}\nfunction renderFixResult(resultEl, entry, ctx){\n  const {mv,code,lvl,sp,lim}=ctx;\n  resultEl.innerHTML='';\n  const closeSlot=document.createElement('div'); resultEl.appendChild(closeSlot);\n  const moveSlot=document.createElement('div'); resultEl.appendChild(moveSlot);\n  const discSlot=document.createElement('div'); resultEl.appendChild(discSlot);\n\n  const close=entry.close;\n  closeSlot.className='fixcard';\n  if(!close||!close.ok){\n    closeSlot.innerHTML=`<span style=\"color:var(--closing)\">erro ao simular: ${esc((close&&close.errorMsg)||'desconhecido')}</span>`;\n  } else {\n    const chips=(close.dest||[]).map(d=>{\n      const bad=close.over.includes(d)||close.under.includes(d);\n      return `<span class=\"chip${bad?' bad':''}\">${d} ${fmt(close.spMap[d])}</span>`;\n    }).join('');\n    let verdictCls='ok', verdictTxt='✓ fecha limpo — nenhum receptor estoura o limite';\n    if(close.over.length||close.under.length){ verdictCls='bad'; verdictTxt=`⚠ ainda ficaria fora do limite: ${[...close.over,...close.under].join(', ')} — fechar ${code} sozinho não resolve por completo`; }\n    let pvTier='';\n    if(close.optTier===1) pvTier='nenhuma alocação conectada respeitaria o mínimo — o modelo relaxou essa exigência nesta simulação.';\n    else if(close.optTier===2) pvTier='nenhuma alocação conectada respeitaria os limites — o modelo relaxou essa exigência nesta simulação.';\n    else if(close.optTier===-1) pvTier='simulação resolvida por heurística — os limites não são garantidos.';\n    closeSlot.innerHTML=`<div>fechar ${LEVELS[lvl].label} ${code} redistribui a carga (base + já acumulado) entre:</div>\n      <div class=\"chips\">${chips||'—'}</div>\n      <div class=\"verdict ${verdictCls}\">${verdictTxt}</div>\n      ${pvTier?`<div class=\"hint\">${pvTier}</div>`:''}\n      <button class=\"act applyfix\">✓ Aplicar — fechar ${code} como próxima etapa</button>`;\n    closeSlot.querySelector('.applyfix').onclick=()=>{ addMovement(lvl,code); };\n  }\n\n  const move=entry.move;\n  const plan=(move&&move.ok)?move.plan:null;\n  if(move&&!move.ok){\n    moveSlot.className='fixcard';\n    moveSlot.innerHTML=`<div class=\"hint\">não foi possível testar movimentos de município: ${esc(move.errorMsg||'')}</div>`;\n  } else if(plan&&plan.moves){\n    const rows=plan.moves.map(m=>`<div>${esc(mDisp(m.muni))} (${fmt(m.cbs)} CBs): <b style=\"color:${mv.color[m.from]}\">${m.from}</b> → <b style=\"color:${mv.color[m.to]}\">${m.to}</b></div>`).join('');\n    const touched=[...new Set(plan.moves.flatMap(m=>[m.from,m.to]))];\n    const afterChips=touched.map(s=>`<span class=\"chip${(lim&&lim.max&&plan.spNow[s]>lim.max)?' bad':''}\">${s} ${fmt(sp[s])} → ${fmt(plan.spNow[s])}</span>`).join('');\n    moveSlot.className='fixcard';\n    moveSlot.innerHTML=`<div>mover ${fmt(plan.moves.length)} município(s), sem fechar nenhum setor:</div>\n      <div>${rows}</div>\n      <div class=\"chips\">${afterChips}</div>\n      ${plan.resolved?'<div class=\"verdict ok\">✓ resolve esta violação por completo</div>':'<div class=\"hint\">alivia, mas não resolve sozinho — combine com outra ação se precisar.</div>'}\n      <button class=\"act applymove\">✓ Aplicar — mover ${plan.moves.length>1?'estes municípios':'só este município'}</button>`;\n    moveSlot.querySelector('.applymove').onclick=async()=>{\n      const mb=moveSlot.querySelector('.applymove');\n      mb.disabled=true; mb.textContent='movendo…';\n      try{\n        for(const m of plan.moves){ await applyTransfer(m.from,m.to,m.muni); }\n        mb.textContent='recalculando etapa…';\n        await flushDirty();\n        renderAll();\n      }catch(e){ mb.disabled=false; mb.textContent='erro: '+e.message; }\n    };\n    const applyBtn=moveSlot.querySelector('.applymove');\n    const setPlanHl=on=>{\n      const svg=$('#map'); if(!svg) return;\n      const want=new Set(plan.moves.map(m=>m.muni));\n      svg.querySelectorAll('[data-muni]').forEach(el=>{ if(want.has(el.getAttribute('data-muni'))) el.classList.toggle('munihl',on); });\n    };\n    applyBtn.addEventListener('mouseenter',()=>setPlanHl(true));\n    applyBtn.addEventListener('mouseleave',()=>setPlanHl(false));\n  } else if(plan && !plan.moves){\n    moveSlot.className='fixcard';\n    moveSlot.innerHTML=`<div class=\"hint\">nenhuma combinação de até 2 municípios resolve isso sozinha.</div>`;\n  }\n\n  const moveRejected=(plan&&plan.rejected)||[];\n  const closureRejected=(close&&close.rejected)||[];\n  if(moveRejected.length){\n    const discBtn=document.createElement('button');\n    discBtn.className='ghost';\n    discBtn.style.marginTop='4px';\n    const syncDiscBtn=()=>{\n      const on=!!(DISC_PAINT&&DISC_PAINT.code===code);\n      discBtn.classList.toggle('on',on);\n      discBtn.textContent=`${on?'Ocultar':'Ver'} simulações descartadas (${moveRejected.length})`;\n    };\n    syncDiscBtn();\n    DISC_SYNC_FNS.push(syncDiscBtn);\n    discBtn.onclick=()=>{\n      DISC_PAINT=(DISC_PAINT&&DISC_PAINT.code===code)?null:{code,moves:moveRejected};\n      DISC_SYNC_FNS.forEach(fn=>fn());\n      drawMap();\n    };\n    discSlot.appendChild(discBtn);\n  }\n  if(closureRejected.length){\n    const closBtn=document.createElement('button');\n    closBtn.className='ghost';\n    closBtn.style.marginTop='4px';\n    closBtn.textContent=`Ver distribuições de fechamento descartadas (${closureRejected.length})`;\n    closBtn.onclick=()=>openDiscarded(closureRejected);\n    discSlot.appendChild(closBtn);\n  }\n}\nfunction renderSpanRule(){\n  const el=$('#spanRule'); if(!el) return;\n  DISC_SYNC_FNS=[];\n  const mv=SESS.timeline[SESS.selected], lim=spanLimits(mv);\n  if(!mv||!mv.munis||mv.view==='asis'||!spanLimitsOn(lim)){ el.innerHTML=''; return; }\n  const sp=spanMapFor(mv,mv.assign||{}), v=spanViolationsOf(sp,lim);\n  const bits=[];\n  if(lim.target) bits.push(`alvo ${fmt(lim.target)}`);\n  if(lim.max) bits.push(`máx ${fmt(lim.max)}`);\n  if(lim.min) bits.push(`mín ${fmt(lim.min)}`);\n  const limTxt=`Pré-requisito de span: ${bits.join(' · ')} CBs`;\n  let tierTxt='';\n  if(mv.optTier===1) tierTxt=' — nenhuma alocação conectada respeita o mínimo; o modelo relaxou essa exigência.';\n  else if(mv.optTier===2) tierTxt=' — nenhuma alocação conectada respeita os limites; o modelo relaxou essa exigência.';\n  else if(mv.optTier===-1) tierTxt=' — etapa resolvida por heurística; os limites não são garantidos.';\n  if(v.over.length||v.under.length){\n    const lvl=mv.level||'setor';\n    const fixCodes=[...v.over,...v.under];\n    const rows=fixCodes.map(s=>{\n      const dir=v.over.includes(s)?'acima do máximo':'abaixo do mínimo';\n      return `<div class=\"rule-issue-row\" data-code=\"${s}\">\n        <div class=\"rule-issue-hd\">\n          <button class=\"rule-issue fixspan\" data-code=\"${s}\" title=\"${limTxt}${tierTxt} — clique pra simular fechar ${s} e ver, de verdade, pra onde a carga dele iria\">⚠ ${s} ${dir}: ${fmt(sp[s])}</button>\n          <button class=\"fixdismiss hidden\" data-code=\"${s}\" title=\"Recolher as sugestões desta violação\">✕</button>\n        </div>\n        <div class=\"fixresult\"></div>\n      </div>`;\n    }).join('');\n    el.innerHTML=`<div class=\"rule-item\">\n      <div class=\"rule-cap\">Span</div>\n      <div class=\"rule-issues\">${rows}</div>\n    </div>`;\n    el.querySelectorAll('.rule-issue-row').forEach(row=>{\n      const code=row.dataset.code;\n      const btn=row.querySelector('.fixspan');\n      const resultEl=row.querySelector('.fixresult');\n      const dismissBtn=row.querySelector('.fixdismiss');\n      let cache=FIX_CACHE.get(mv);\n      const cachedEntry=cache&&cache.get(code);\n      if(cachedEntry){\n        renderFixResult(resultEl,cachedEntry,{mv,code,lvl,sp,lim});\n        resultEl.classList.remove('hidden');\n        dismissBtn.classList.remove('hidden');\n      }\n      dismissBtn.onclick=()=>{ FIX_CACHE.get(mv)?.delete(code); if(DISC_PAINT&&DISC_PAINT.code===code){ DISC_PAINT=null; DISC_SYNC_FNS.forEach(fn=>fn()); drawMap(); } resultEl.classList.add('hidden'); dismissBtn.classList.add('hidden'); };\n      btn.onclick=async ()=>{\n        const origText=btn.textContent;\n        resultEl.classList.remove('hidden');\n        if(!hierarchyAllows(lvl)){\n          resultEl.innerHTML=`<div class=\"fixcard\"><span style=\"color:var(--closing)\">esta sessão já fechou um nível mais alto — a sugestão não está mais disponível.</span></div>`;\n          btn.disabled=true; btn.textContent='indisponível'; return;\n        }\n        const closedNow=new Set(SESS.timeline.filter(m=>m.kind!=='transfer' && (m.level||'setor')===lvl).map(m=>m.sector));\n        if(closedNow.has(String(code))){\n          resultEl.innerHTML=`<div class=\"fixcard\"><span style=\"color:var(--closing)\">${LEVELS[lvl].label} ${code} já foi fechado(a) em outra etapa desta timeline — a sugestão não se aplica mais.</span></div>`;\n          btn.disabled=true; btn.textContent='indisponível'; return;\n        }\n        const isOver=v.over.includes(code);\n        let cache2=FIX_CACHE.get(mv); if(!cache2){ cache2=new Map(); FIX_CACHE.set(mv,cache2); }\n        let entry=cache2.get(code);\n        if(!entry){\n          dismissBtn.classList.add('hidden');\n          btn.disabled=true; btn.textContent='simulando movimentações…';\n          entry=await computeFixSpan(mv,code,lvl,sp,lim,isOver);\n          cache2.set(code,entry);\n          btn.disabled=false; btn.textContent=origText;\n        }\n        renderFixResult(resultEl,entry,{mv,code,lvl,sp,lim});\n        resultEl.classList.remove('hidden');\n        dismissBtn.classList.remove('hidden');\n      };\n    });\n  } else {\n    el.innerHTML=`<div class=\"rule-item\">\n      <div class=\"rule-cap\">Span</div>\n      <div class=\"rule-ok\" title=\"${limTxt}\">✓ todos os setores receptores dentro dos limites</div>\n    </div>`;\n  }\n}\nfunction renderContig(){const mv=SESS.timeline[SESS.selected];const el=$('#contig');if(!el||!mv)return;\n  if(mv.view==='asis'){el.innerHTML='';return;}\n  const lvl=LEVELS[mv.level||'setor'];\n  const bad=contigBadFor(mv,mv.assign);\n  if(bad.length){\n    el.innerHTML=`<div class=\"rule-item\">\n      <div class=\"rule-cap\">Conectividade</div>\n      <div class=\"rule-issues\"><button class=\"rule-issue\" id=\"fixContig\" title=\"${lc(lvl.childLabel)}(s) desconectado(s) do território ${lvl.de} ${lc(lvl.label)} — clique pra rodar ★ ÓTIMO e reconectar\">⚠ Território desconectado em ${bad.join(', ')}</button></div>\n    </div>`;\n    $('#fixContig').onclick=()=>editSelected(m=>{ m.assign=optimizeFor(m); });\n  } else {\n    el.innerHTML=`<div class=\"rule-item\">\n      <div class=\"rule-cap\">Conectividade</div>\n      <div class=\"rule-ok\">✓ cada ${lc(lvl.label)} forma uma região conectada</div>\n    </div>`;\n  }\n}\nfunction closingCbsOf(mv){ return Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,[,o])=>a+o.cbs,0); }\nfunction sectorTotalCbs(mv){ return Object.values(mv.munis).reduce((a,o)=>a+o.cbs,0); }\nfunction renderKpis(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  const childPlural=lvl.childLabel==='Setor'?'Setores':(lvl.childLabel==='GV'?'GVs':'Municípios');\n  if(mv.view==='asis'){const mc=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').length;\n    $('#kpis').innerHTML=[\n      {n:fmt(sectorTotalCbs(mv)),l:`CBs ${lvl.no} ${lc(lvl.label)} ${mv.sector} (antes desta etapa)`,t:`Total de CBs ${lvl.no} ${lc(lvl.label)} hoje — já considerando o que etapas anteriores da timeline mandaram pra cá.`},\n      {n:mv.csvMunisTotal?`${fmt(mc)}/${fmt(mv.csvMunisTotal)}`:fmt(mc),l:`${childPlural} ${lvl.de} ${lc(lvl.label)}`,t:`${lvl.childLabel}(s) desenhados nesta etapa${mv.csvMunisTotal?` de ${fmt(mv.csvMunisTotal)} que a base de dominantes atribui ${lvl.a} ${lc(lvl.label)}`:''}.${(mv.missingMunis||[]).length?' Fora do mapa: '+mv.missingMunis.map(x=>x.muni+' ('+x.reason+')').join(' · '):''}${Object.keys(mv.ufGuessed||{}).length?' UF inferida da UF dominante do setor (nome repetido em outra UF carregada): '+Object.entries(mv.ufGuessed).map(([nm,u])=>nm+'→'+u).join(', '):''}`},\n      {n:fmt(mv.leaders.length),l:'Líderes (grupos)',t:`Líderes presentes ${lvl.no} ${lc(lvl.label)} nesta etapa.`},\n      {n:fmt(mv.dest.length),l:`${lvl.destLabel}s adjacentes`,t:`${lvl.destLabel}s vizinh${lvl.destLabel==='GV'?'as':'os'} (ainda abertas)${lvl.parentPhrase?` ${lvl.parentPhrase}`:''}${mv.gv?` (${mv.gv})`:''} que poderiam receber os ${lc(lvl.childLabel)}s.${(mv.gvRejected||[]).length&&lvl.parentWord?' Fora do mapa, por serem de outr'+(lvl.parentWord==='gerência'?'a':'o')+' '+lvl.parentWord+': '+mv.gvRejected.join(', ')+'.':''}`},\n    ].map(k=>`<div class=\"kpi\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n    return;}\n  const sv=mv.dest.map(s=>c.span[s]);const spread=sv.length?Math.max(...sv)-Math.min(...sv):0;\n  // Only flags a destination that actually received CBs (c.inc[s]>0) but ended up with no leader\n  // calling it home — a destination that received nothing tripped this trivially (gained is 0\n  // there by definition, never a real \"missing leader\" case) and was just noise on the KPI.\n  const zero=mv.dest.filter(s=>c.gained[s]===0&&c.inc[s]>0);const totCbs=closingCbsOf(mv);\n  $('#kpis').innerHTML=[\n    // \"CBs fora do X\" só faz sentido pra fechamento de setor — a mecânica de vínculo (CBs que\n    // seguem a líder pra fora do território) é uma coisa de setor/orphan, não de GV/mercado.\n    (mv.level||'setor')==='setor'?{n:fmt(c.desloc),l:mv.policy==='fora'?`CBs fora ${lvl.de} ${lc(lvl.destLabel)}`:'CBs c/ quebra de relação',cls:'warn',t:`CBs cuja líder acabou noutr${lvl.a==='à'?'a':'o'} ${lc(lvl.destLabel)} nesta etapa.`}:null,\n    {n:fmt(spread),l:'Desequilíbrio (máx−mín CBs)',cls:spread>800?'warn':'ok',t:`Diferença de tamanho entre ${lvl.destLabel==='GV'?'a maior e a menor':'o maior e o menor'} ${lc(lvl.destLabel)} depois desta etapa.`},\n    {n:fmt(totCbs),l:'CBs realocadas',cls:'',t:`Total de CBs ${lvl.de} ${lc(lvl.label)} ${mv.sector} sendo redistribuídas nesta etapa.`},\n    (mv.level||'setor')==='setor'?{n:zero.length?zero.join(', '):'nenhum',l:`${lvl.destLabel} sem líder herdada`,cls:zero.length?'warn':'ok',t:`${lvl.destLabel} que recebe ${lc(lvl.childLabel)}(s) mas nenhuma líder nesta etapa.`}:null,\n  ].filter(Boolean).concat(mv.excludedCbs?[{n:fmt(mv.excludedCbs),l:`CBs fora do setor · ${fmt(mv.excludedMunis.length)} município(s) fora da base`,cls:'warn',t:'CBs em municípios que a base de dominantes não atribui a este setor: ficam fora do mapa e da alocação geográfica e seguem a líder do seu grupo.'}]:[])\n   .concat((mv.missingMunis||[]).length?[{n:fmt(mv.missingMunis.length),l:`Município(s) da base fora do mapa · ${fmt(mv.csvMunisTotal)} na base`,cls:'warn',t:`A base de dominantes atribui ${fmt(mv.csvMunisTotal)} município(s) ao setor ${mv.sector} e ${fmt(mv.csvMunisTotal-mv.missingMunis.length)} chegaram ao mapa. Fora: ${mv.missingMunis.map(x=>x.muni+' — '+x.reason+(x.cbs?' ('+fmt(x.cbs)+' CBs)':'')).join(' · ')}`}]:[])\n   .map(k=>`<div class=\"kpi ${k.cls}\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n}\nfunction renderSpans(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  const spansHd=$('#spansHd'); if(spansHd) spansHd.textContent=`Span por ${lc(lvl.label)} (CBs) — base + entrantes`;\n  if(mv.view==='asis'){const items=[{s:mv.sector,val:sectorTotalCbs(mv),color:CLOSE_COLOR}].concat(mv.dest.map(s=>({s,val:+(mv.baseMetrics[s]?.cbs||0),color:mv.color[s]})));\n    const max=Math.max(...items.map(x=>x.val),1);\n    $('#spans').innerHTML=items.map(x=>`<div class=\"bar-row\"><div class=\"tag\" style=\"color:${x.color}\">${x.s}</div><div class=\"bar-track\">\n      <div class=\"bar-inc\" style=\"width:${x.val/max*100}%;background:${x.color}\"></div></div>\n      <div class=\"bar-val\">${fmt(x.val)}</div></div>`).join('')\n      +`<div class=\"note\">Estrutura antes desta etapa — ${lvl.a} ${lc(lvl.label)} <b>${mv.sector}</b> ainda existe. Alterne pra <b>TOBE</b> pra ver a redistribuição.</div>`;\n    return;}\n  const lim=spanLimits(mv),on=spanLimitsOn(lim);\n  const rawMax=Math.max(...mv.dest.map(s=>c.span[s]),lim.target||0,lim.max||0,1);\n  const max=on?rawMax*1.04:rawMax;   // headroom so a marker sitting at the scale top stays visible\n  const marks='';\n  // A manual município transfer (mv.kind==='transfer') patches mv.baseMetrics[to]/[from] directly\n  // (computeTransferMovement) instead of going through mv.munis/mv.assign — so c.inc[s] (this\n  // stage's own geographic redistribution) never sees it, and the transfer's gain/loss silently\n  // disappeared into \"base\" as if it had already existed before this very stage (always \"+0\" here,\n  // even though this exact stage is the transfer). xferDeltaOf surfaces it as THIS stage's own\n  // +/- instead, exactly like c.inc already does for a normal closing movement.\n  const xferDeltaOf=s=>{ if(mv.kind!=='transfer') return null; if(s===mv.xferTo) return +mv.xferMetrics.cbs; if(s===mv.xferFrom) return -mv.xferMetrics.cbs; return 0; };\n  $('#spans').innerHTML=mv.dest.map(s=>{\n    const xd=xferDeltaOf(s);\n    const delta=xd==null?c.inc[s]:xd;\n    // Only ever highlight a genuine GAIN as the filled \"entrante\" segment — a transfer's LOSS still\n    // shows correctly in the total and in its own (-X) label, but stretching the pale \"base\" segment\n    // past the sector's real current size to depict a shrink would draw a bigger bar for a smaller\n    // number, more confusing than simply not drawing a segment for it at all.\n    const shownInc=xd==null?c.inc[s]:Math.max(xd,0);\n    const base=c.span[s]-shownInc;\n    const over=lim.max&&c.span[s]>lim.max,under=lim.min&&c.span[s]<lim.min;\n    return `<div class=\"bar-row\"><div class=\"tag\" style=\"color:${mv.color[s]}\">${s}</div><div class=\"bar-track\">\n      <div class=\"bar-base\" style=\"width:${base/max*100}%;background:${mv.color[s]}\"></div>\n      <div class=\"bar-inc${xd!=null&&xd>0?' xfer':''}\" style=\"width:${shownInc/max*100}%;background:${mv.color[s]}\"></div>${marks}</div>\n      <div class=\"bar-val\" style=\"${over?'color:var(--closing);font-weight:700':(under?'color:#ff9500;font-weight:700':'')}\">${fmt(c.span[s])} <span style=\"opacity:.6\">(${delta>=0?'+':''}${fmt(delta)})</span>${over?' ▲':''}${under?' ▼':''}</div></div>`;}).join('')\n    +`<div class=\"note\">Claro = base (já incluindo etapas anteriores) · cheio = CBs entrantes ${lvl.de} ${mv.sector} nesta etapa.${mv.kind==='transfer'?' Nesta etapa, a faixa <b>listrada</b> é a transferência manual de município (perda no setor de origem aparece só no rótulo, sem faixa).':''}${c.orphanTotal?` Inclui <b>${fmt(c.orphanTotal-c.orphanStranded)}</b> CB(s) de municípios fora da base de dominantes que seguiram suas líderes.`:''}</div>`;\n}\n// Two very different reasons land rows in the __OUTROS__ bucket — no IBGE shape, or outside the\n// sector's dominance base. Same bucket computationally, but they must not read as the same thing.\nfunction outsideNote(mv){\n  const bits=[];\n  if(mv.noShapeCbs) bits.push(`<b>${fmt(mv.noShapeCbs)}</b> CBs em ${fmt(mv.noShapeMunis.length)} município(s) sem shape na UF`);\n  if(mv.excludedCbs) bits.push(`<b>${fmt(mv.excludedCbs)}</b> CBs em ${fmt(mv.excludedMunis.length)} município(s) <b>fora da base de dominantes</b> deste setor — <b style=\"color:var(--closing)\">quebra de vínculo</b>`);\n  if(!bits.length) return '';\n  let h=`<div class=\"note\">Fora do mapa e da alocação geográfica: ${bits.join(' · ')}.`;\n  if(mv.excludedCbs){\n    const c=computeDerived(mv);\n    h+=` As <b>${fmt(mv.excludedCbs)}</b> CBs em quebra de vínculo <b>seguem a líder do seu grupo</b>`;\n    const byS=mv.dest.filter(s=>c.orph[s]).map(s=>`<b style=\"color:${mv.color[s]}\">${s}</b> (${fmt(c.orph[s])})`);\n    h+=byS.length?`: ${byS.join(' · ')}.`:'.';\n    if(c.orphanStranded) h+=` <b style=\"color:var(--closing)\">${fmt(c.orphanStranded)} CB(s) sem destino</b> — a líder não ficou com nenhum município realocado, então não há setor pra seguir.`;\n  }\n  if(mv.noShapeCbs) h+=` As CBs sem shape na UF continuam fora de qualquer setor destino.`;\n  if(mv.excludedMunis&&mv.excludedMunis.length) h+=`<br><span style=\"opacity:.85\">${mv.excludedMunis.slice(0,8).map(x=>x.muni+' ('+fmt(x.cbs)+')').join(', ')}${mv.excludedMunis.length>8?' +'+(mv.excludedMunis.length-8):''}</span>`;\n  return h+`</div>`;\n}\nfunction renderMuni(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const lvl=LEVELS[mv.level||'setor'];\n  const plural=lvl.childLabel==='Setor'?'Setores':(lvl.childLabel==='GV'?'GVs':'Municípios');\n  const fem=lvl.childLabel==='GV';\n  const t=$('#muniListTitle'); if(t) t.textContent=`${plural} → ${lc(lvl.destLabel)} destino`;\n  const nt=$('#muniListNote'); if(nt) nt.innerHTML=`Clique no <b>código ${lvl.a} ${lc(lvl.destLabel)}</b> pra mandar ${lvl.a} ${lc(lvl.childLabel)} <b>inteir${fem?'a':'o'}</b> pra lá (regra de negócio). A cor acompanha o mapa.`;\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  if(mv.view==='asis'){$('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"muni-item-hd\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(mv.munis[m].cbs)} CBs</div></div><span class=\"badge\" style=\"background:${CLOSE_COLOR};color:#fff;border-color:${CLOSE_COLOR}\">${lc(lvl.label)} ${mv.sector}</span></div>`).join('')\n      +`<div class=\"note\">Hoje tod${fem?'as':'os'} pertencem ${lvl.a} ${lc(lvl.label)} <b>${mv.sector}</b>. No <b>TOBE</b> cada um vira botões de destino.</div>`+outsideNote(mv);return;}\n  const xferBlock=(mv.kind==='transfer')?(\n    `<div class=\"note\" style=\"margin-top:10px;font-weight:600;color:var(--ink)\">Municípios do setor ${mv.xferFrom}</div>\n    <div class=\"muni-item\"><div class=\"muni-item-hd\"><div class=\"nm\">${mDisp(mv.xferMuni)}</div><div class=\"ct\">${fmt(mv.xferMetrics.cbs)} CBs</div></div>\n      <div class=\"seg seg-xfer\">${mv.dest.map(s=>`<button data-s=\"${s}\" class=\"${mv.xferTo===s?'on':''}\" style=\"${mv.xferTo===s?'background:'+mv.color[s]:''}\">${s}</button>`).join('')}</div>\n    </div>`\n  ):'';\n  $('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"muni-item-hd\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(mv.munis[m].cbs)} CBs</div></div>\n    <div class=\"seg\" data-m=\"${m}\">${mv.dest.map(s=>`<button data-s=\"${s}\" class=\"${mv.assign[m]===s?'on':''}\" style=\"${mv.assign[m]===s?'background:'+mv.color[s]:''}\">${s}</button>`).join('')}</div></div>`).join('')\n    +xferBlock+outsideNote(mv);\n  $('#muniList').querySelectorAll('.seg:not(.seg-xfer) button').forEach(b=>b.onclick=()=>{const m=b.parentElement.dataset.m,s=b.dataset.s;editSelected(m2=>{m2.assign[m]=s;});});\n  $('#muniList').querySelectorAll('.seg-xfer button').forEach(b=>b.onclick=()=>setTransferTarget(b.dataset.s));\n}\nfunction renderLeaders(){const mv=SESS.timeline[SESS.selected]; if(!mv){ renderXferLeaders(null); return; } const c=computeDerived(mv);\n  const showOrph=c.orphanTotal>0;\n  const lvl=LEVELS[mv.level||'setor'];\n  const titleEl=$('#leadersTitle'), noteEl=$('#leadersNote');\n  if((mv.level||'setor')!=='setor'){\n    // GV/mercado: quem troca de dono é o setor/GV inteiro (mv.assign), nunca a líder isolada — o\n    // grão que importa aqui é MUNICÍPIO × UNIDADE (a mesma matriz de renderMatrix/renderMatrixTobe),\n    // não líder × unidade: um município com CBs em mais de uma unidade (compartilhado entre setores\n    // da mesma GV) já é o caso que esta tabela existe pra revelar, e não depende de ASIS/TOBE — é\n    // posse ATUAL, a mesma nos dois modos.\n    if(titleEl) titleEl.textContent=`Municípios por ${lc(lvl.childLabel)} (nesta etapa)`;\n    if(noteEl) noteEl.innerHTML=`Cada linha é um município; os blocos são o(s) ${lc(lvl.childLabel)}(s) onde ele tem CBs, largura proporcional ao total do bloco — mais de um bloco já é compartilhamento entre ${lc(lvl.childLabel)}s.`;\n    const units=Object.keys(mv.unitMunis||{}).sort((a,b)=>(mv.munis[b]?.cbs||0)-(mv.munis[a]?.cbs||0));\n    const ms=Object.keys(mv.geoFeat||{}).sort((a,b)=>(mv.cbsByMuni[b]||0)-(mv.cbsByMuni[a]||0));\n    if(!units.length||!ms.length){\n      $('#leaders').innerHTML=`<tr><td class=\"note\" style=\"padding:10px\">Nenhum ${lc(lvl.childLabel)} com município desenhado nesta etapa.</td></tr>`;\n      renderXferLeaders(mv);\n      return;\n    }\n    const unitOfRow=r=>mv.level==='gv'?String(r.setor):(DOM.gvBySetor[String(r.setor)]||'');\n    const cellBy={};\n    mv.matrix.forEach(r=>{ const m=mkey(r.municipio,r.uf); if(!mv.geoFeat[m]) return; const u=unitOfRow(r); if(!u) return;\n      cellBy[m+'|'+u]=(cellBy[m+'|'+u]||0)+ +r.cbs; });\n    const cell=(m,u)=>cellBy[m+'|'+u]||0;\n    const mx=Math.max(...ms.flatMap(m=>units.map(u=>cell(m,u))),1);\n    // Uma coluna por unidade virava uma grade enorme e quase toda vazia — a esmagadora maioria dos\n    // municípios pertence a UMA única unidade. Em vez de coluna fixa, cada linha carrega só os\n    // blocos das unidades onde aquele município REALMENTE tem CBs (segsFor), largura proporcional\n    // (flex-grow=v, base 0) — um compartilhamento entre unidades vira mais de um bloco na mesma\n    // linha, sem precisar de coluna nem seção própria pra revelar o caso.\n    const segsFor=m=>units.map(u=>({u,v:cell(m,u)})).filter(s=>s.v>0).sort((a,b)=>b.v-a.v);\n    $('#leaders').innerHTML=`<thead><tr><th>Município</th><th style=\"text-align:left\">${lvl.childLabel}(s) — CBs</th><th>Tot</th></tr></thead><tbody>`\n      +ms.map(m=>{\n        const tot=mv.cbsByMuni[m]||0; const segs=segsFor(m);\n        const segsHtml=segs.length\n          ?`<div style=\"display:flex;gap:2px;min-height:30px\">`\n            +segs.map(({u,v})=>{const pct=tot?Math.round(v/tot*100):0;\n              return `<div style=\"flex:${v} 0 0%;min-width:2px;background:rgba(229,181,63,${.15+v/mx*.5});border-radius:4px;display:flex;flex-direction:column;align-items:center;justify-content:center;overflow:hidden;padding:2px 4px;font-size:10.5px;line-height:1.25;white-space:nowrap\" title=\"${lvl.childLabel} ${u} · ${fmt(v)} CBs (${pct}%)\"><b>${u}</b><span style=\"opacity:.65\">${fmt(v)} (${pct}%)</span></div>`;\n            }).join('')\n          +`</div>`\n          :`<span style=\"color:var(--mut)\">·</span>`;\n        return `<tr><td>${m}</td><td>${segsHtml}</td><td style=\"font-weight:700\">${fmt(tot)}</td></tr>`;\n      }).join('')\n      +`<tr style=\"font-weight:700\"><td>TOTAL</td><td></td><td>${fmt(ms.reduce((a,m)=>a+(mv.cbsByMuni[m]||0),0))}</td></tr>`\n      +`</tbody>`;\n    renderXferLeaders(mv);\n    return;\n  }\n  if(titleEl) titleEl.textContent='Líderes — destino e CBs deslocadas (nesta etapa)';\n  if(noteEl) noteEl.innerHTML=`<b>Home</b> = setor que ficou com a maioria das CBs da líder. <b style=\"color:var(--closing)\">Deslocadas</b> = CBs dela que caíram noutro setor.`;\n  if(mv.view==='asis'){$('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>Grupo</th><th>CBs</th><th>${LEVELS[mv.level||'setor'].label}</th></tr></thead><tbody>`\n    +mv.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${L.grupo}</td><td>${fmt(L.total)}</td><td><span class=\"home\" style=\"background:${CLOSE_COLOR}\"></span>${mv.sector}</td></tr>`).join('')\n    +`<tr style=\"font-weight:700\"><td>TOTAL</td><td></td><td>${fmt(mv.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td></tr>`+`</tbody>`;renderXferLeaders(mv);return;}\n  $('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>CBs</th><th>Home</th>${mv.dest.map(s=>`<th>${s}</th>`).join('')}${showOrph?'<th title=\"CBs em municípios fora da base de dominantes que seguiram esta líder\">Fora do setor</th>':''}<th>Desloc.</th></tr></thead><tbody>`\n   +c.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${fmt(L.total)}</td>\n     <td>${L.home?`<span class=\"home\" style=\"background:${mv.color[L.home]}\"></span>${L.home}`:'–'}</td>\n     ${mv.dest.map(s=>`<td class=\"cell\" style=\"${s===L.home?'color:'+mv.color[s]+';font-weight:700':''}\">${L.byS[s]||'·'}</td>`).join('')}\n     ${showOrph?`<td class=\"cell\" style=\"${L.orphan&&!L.orphanHome?'color:var(--closing);font-weight:700':''}\" title=\"${L.orphan?(L.orphanHome?'seguem a líder para o setor '+L.orphanHome:'sem home — CBs sem setor pra seguir'):''}\">${L.orphan?fmt(L.orphan)+(L.orphanHome?'':' ⚠'):'·'}</td>`:''}\n     <td style=\"color:${L.desloc?'var(--closing)':'var(--mut)'};font-weight:${L.desloc?700:400}\">${L.desloc||'0'}</td></tr>`).join('')\n   +`<tr style=\"font-weight:700\"><td>TOTAL</td><td>${fmt(c.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td>${mv.dest.map(s=>`<td>${fmt(c.leaders.reduce((a,L)=>a+L.byS[s],0))}</td>`).join('')}${showOrph?`<td>${fmt(c.orphanTotal)}</td>`:''}<td style=\"color:var(--closing)\">${fmt(c.desloc)}</td></tr>`+`</tbody>`;\n  renderXferLeaders(mv);\n}\nfunction renderXferLeaders(mv){\n  const block=$('#leadersXferBlock'); if(!block) return;\n  const hasXfer = mv && mv.kind==='transfer' && mv.xferRows && mv.xferRows.length;\n  block.classList.toggle('hidden', !hasXfer);\n  if(!hasXfer) return;\n  const title=$('#leadersXferTitle');\n  if(title) title.textContent=`Transferência manual — ${mDisp(mv.xferMuni)} (${mv.xferFrom} → ${mv.xferTo})`;\n  $('#leadersXfer').innerHTML=`<thead><tr><th>Líder</th><th>CBs</th><th>Grupo</th></tr></thead><tbody>`\n    +mv.xferRows.map(r=>`<tr><td>${esc((r.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+r.grupo))}</td><td>${fmt(r.cbs)}</td><td>${r.grupo}</td></tr>`).join('')\n    +`</tbody>`;\n}\nfunction renderXferBreakdown(mv, tableId, blockId, titleId){\n  const block=$('#'+blockId); if(!block) return;\n  const hasXfer = mv && mv.kind==='transfer' && mv.xferRows && mv.xferRows.length;\n  block.classList.toggle('hidden', !hasXfer);\n  if(!hasXfer) return;\n  const title=$('#'+titleId);\n  if(title) title.textContent=`Transferência manual — ${mDisp(mv.xferMuni)} (${mv.xferFrom} → ${mv.xferTo})`;\n  const rowsData=mv.xferRows;\n  const mx=Math.max(...rowsData.map(r=>+r.cbs||0),1);\n  const short=r=>(r.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+r.grupo);\n  $('#'+tableId).innerHTML=`<thead><tr><th>Município</th>${rowsData.map(r=>`<th title=\"${esc(r.lider||'')}\">${r.grupo}<br><span style=\"font-weight:400;font-size:10px;color:var(--mut)\">${esc(short(r))}</span></th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n    +`<tr><td>${mDisp(mv.xferMuni)}</td>`\n    +rowsData.map(r=>{const v=+r.cbs||0; return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v||'·'}</td>`;}).join('')\n    +`<td style=\"font-weight:700\">${fmt(mv.xferMetrics.cbs)}</td></tr></tbody>`;\n}\nfunction renderMatrix(){const mv=SESS.timeline[SESS.selected]; if(!mv) return;\n  // GV/mercado: a unidade que troca de dono é setor/GV, não município — então esta matriz cruza\n  // MUNICÍPIO × UNIDADE (setor fechando GV, GV fechando mercado) em vez de município × grupo.\n  // mv.matrix já carrega r.setor em toda linha (a query do CRM sempre pediu isso), então dá pra\n  // derivar a unidade de cada linha sem nenhuma consulta nova — unitOfRow abaixo é a mesma tradução\n  // setor→GV que resolveUnit/aggregateMovement já usam pra este grão.\n  if((mv.level||'setor')!=='setor'){\n    const lvl=LEVELS[mv.level];\n    const units=Object.keys(mv.unitMunis||{}).sort((a,b)=>(mv.munis[b]?.cbs||0)-(mv.munis[a]?.cbs||0));\n    const ms=Object.keys(mv.geoFeat||{}).sort((a,b)=>(mv.cbsByMuni[b]||0)-(mv.cbsByMuni[a]||0));\n    if(!units.length||!ms.length){ $('#matrix').innerHTML=`<tr><td class=\"note\" style=\"padding:10px\">Nenhum ${lc(lvl.childLabel)} com município desenhado nesta etapa.</td></tr>`; renderXferBreakdown(null,'matrixXfer','matrixXferBlock','matrixXferTitle'); return; }\n    const unitOfRow=r=>mv.level==='gv'?String(r.setor):(DOM.gvBySetor[String(r.setor)]||'');\n    const cellBy={};\n    mv.matrix.forEach(r=>{ const m=mkey(r.municipio,r.uf); if(!mv.geoFeat[m]) return; const u=unitOfRow(r); if(!u) return;\n      cellBy[m+'|'+u]=(cellBy[m+'|'+u]||0)+ +r.cbs; });\n    const cell=(m,u)=>cellBy[m+'|'+u]||0;\n    const mx=Math.max(...ms.flatMap(m=>units.map(u=>cell(m,u))),1);\n    $('#matrix').innerHTML=`<thead><tr><th>Município</th>${units.map(u=>`<th>${lvl.childLabel} ${u}</th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n      +ms.map(m=>{const tot=mv.cbsByMuni[m]||0; return `<tr><td>${m}</td>`\n        +units.map(u=>{const v=cell(m,u);const pct=tot?Math.round(v/tot*100):0;return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v?`${fmt(v)} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n        +`<td style=\"font-weight:700\">${fmt(tot)}</td></tr>`;}).join('')\n      +`<tr style=\"font-weight:700\"><td>TOTAL</td>${units.map(u=>`<td>${fmt(ms.reduce((a,m)=>a+cell(m,u),0))}</td>`).join('')}<td>${fmt(ms.reduce((a,m)=>a+(mv.cbsByMuni[m]||0),0))}</td></tr>`\n      +`</tbody>`;\n    renderXferBreakdown(null,'matrixXfer','matrixXferBlock','matrixXferTitle');\n    return;\n  }\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  // `_out` is a flag stamped on the ROW, and rows are shared between etapas (absorbed + inherited\n  // orphans), so the last etapa to aggregate wins and reading it here can describe the wrong etapa.\n  // Derive the bucket from THIS mv instead. Summing rather than .find() because several out-municípios\n  // of the same group all collapse into __OUTROS__, and only the first was being counted.\n  const outOf=x=>!mv.geoFeat[mkey(x.municipio,x.uf)];\n  const cell=(m,g)=>mv.matrix.reduce((a,x)=>a+((((outOf(x)?'__OUTROS__':mkey(x.municipio,x.uf))===m)&&String(x.grupo)===g)?+x.cbs:0),0);\n  const mx=Math.max(...ms.flatMap(m=>mv.leaders.map(L=>cell(m,L.grupo))),1);\n  const short=L=>(L.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+L.grupo);\n  // No destination column/home dot here (unlike TOBE). This is the AS-IS matrix: every município in\n  // it still belongs to the closing sector, so showing where it is HEADED mixed the two states of\n  // the world in one table and made the ASIS view read like a plan. The destination lives on the\n  // map and in \"Municípios → setor destino\", which are about the to-be.\n  $('#matrix').innerHTML=`<thead><tr><th>Município | Grupo | Líder</th>${mv.leaders.map(L=>`<th title=\"${esc(L.lider||'')}\">${L.grupo}<br><span style=\"font-weight:400;font-size:10px;color:var(--mut)\">${esc(short(L))}</span></th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +ms.map(m=>{const tot=mv.munis[m].cbs; return `<tr><td>${m}</td>`\n     +mv.leaders.map(L=>{const v=cell(m,L.grupo);const pct=L.total?Math.round(v/L.total*100):0;return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v?`${v} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(tot)}</td></tr>`;}).join('')\n   +`<tr class=\"lider-foot\"><td>Municípios fora do setor</td>`\n     +mv.leaders.map(L=>{const v=(mv.foraDoSetorByGrupo&&mv.foraDoSetorByGrupo[L.grupo])||0;const pct=L.total?Math.round(v/L.total*100):0; return `<td title=\"CBs desta líder (dado do Databricks) em município(s) que a base de dominantes (dominantesCsvUrl) não atribui ao setor ${mv.sector}\">${v?`${fmt(v)} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n     +`<td></td></tr>`\n   +`</tbody>`;\n  renderXferBreakdown(mv,'matrixXfer','matrixXferBlock','matrixXferTitle');\n}\n// TOBE counterpart of the AS-IS matrix: the same município × grupo grid plus the two columns the AS-IS\n// view deliberately refuses to carry — where the município ends up, and under which líder.\n// \"Líder resultante\" is the líder holding the most CBs in that município. A município is served by several\n// grupos at once and the data names no single owner, so the dominant one is the honest answer and its\n// share is printed beside it instead of being hidden behind a single name.\nfunction renderMatrixTobe(){const mv=SESS.timeline[SESS.selected]; const el=$('#matrixTobe'); const nt=$('#tobeNote');\n  if(!el) return;\n  if(!mv||!mv.munis){ el.innerHTML=''; if(nt) nt.innerHTML=''; renderXferBreakdown(null,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle'); return; }\n  if((mv.level||'setor')!=='setor'){\n    // Mesma matriz município × unidade do ASIS (renderMatrix), mais o que o TOBE sempre acrescenta:\n    // pra onde cada linha/coluna vai. Aqui o destino é simples pelo motivo oposto do setor-level: não\n    // há \"líder dominante\" a inferir — cada unidade (coluna) já tem UM destino fixo (mv.assign[u]),\n    // então o próprio município herda esse destino de mv.unitOfMuni sem precisar de heurística nenhuma.\n    const lvl=LEVELS[mv.level];\n    const units=Object.keys(mv.unitMunis||{}).sort((a,b)=>(mv.munis[b]?.cbs||0)-(mv.munis[a]?.cbs||0));\n    const ms=Object.keys(mv.geoFeat||{}).sort((a,b)=>(mv.cbsByMuni[b]||0)-(mv.cbsByMuni[a]||0));\n    if(!units.length||!ms.length){ el.innerHTML=`<tr><td class=\"note\" style=\"padding:10px\">Nenhum ${lc(lvl.childLabel)} com município desenhado nesta etapa.</td></tr>`; if(nt) nt.innerHTML=''; renderXferBreakdown(null,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle'); return; }\n    const unitOfRow=r=>mv.level==='gv'?String(r.setor):(DOM.gvBySetor[String(r.setor)]||'');\n    const cellBy={};\n    mv.matrix.forEach(r=>{ const m=mkey(r.municipio,r.uf); if(!mv.geoFeat[m]) return; const u=unitOfRow(r); if(!u) return;\n      cellBy[m+'|'+u]=(cellBy[m+'|'+u]||0)+ +r.cbs; });\n    const cell=(m,u)=>cellBy[m+'|'+u]||0;\n    const mx=Math.max(...ms.flatMap(m=>units.map(u=>cell(m,u))),1);\n    el.innerHTML=`<thead><tr><th>Município</th>${units.map(u=>`<th>${lvl.childLabel} ${u}</th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n      +ms.map(m=>{const tot=mv.cbsByMuni[m]||0; const uOf=mv.unitOfMuni[m]; const dest=uOf?mv.assign[uOf]:null;\n        return `<tr><td>${dest?`<span class=\"home\" style=\"background:${mv.color[dest]}\"></span>`:''}${m}</td>`\n        +units.map(u=>{const v=cell(m,u);const pct=tot?Math.round(v/tot*100):0;return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v?`${fmt(v)} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n        +`<td style=\"font-weight:700\">${fmt(tot)}</td></tr>`;}).join('')\n      +`<tr class=\"lider-foot\"><td>Destino</td>`\n        +units.map(u=>{const dest=mv.assign[u]; return `<td>${dest?`<span class=\"home\" style=\"background:${mv.color[dest]}\"></span>${dest}`:'–'}</td>`;}).join('')\n        +`<td></td></tr>`\n      +`</tbody>`;\n    if(nt) nt.innerHTML=`<b>Destino</b> (rodapé) = pra onde vai cada ${lc(lvl.childLabel)} inteiro (coluna) — fixo pra todos os municípios dele, já que quem troca de dono é o ${lc(lvl.childLabel)}, não o município.`;\n    renderXferBreakdown(null,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle');\n    return;\n  }\n  const c=computeDerived(mv);\n  const homeOf={}; c.leaders.forEach(L=>{ homeOf[L.grupo]=L.home; });\n  const homeCbsOf={}; c.leaders.forEach(L=>{ homeCbsOf[L.grupo]=(L.home&&L.total)?Math.round((L.byS[L.home]||0)/L.total*100):0; });\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  // One pass over the matrix instead of a scan per cell: the AS-IS table can afford the naive version\n  // on six municípios, but this one asks for the dominant líder too, which multiplies the lookups.\n  const outOf=x=>!mv.geoFeat[mkey(x.municipio,x.uf)];\n  const cellBy={}; mv.matrix.forEach(x=>{ const k=(outOf(x)?'__OUTROS__':mkey(x.municipio,x.uf))+'|'+String(x.grupo);\n    cellBy[k]=(cellBy[k]||0)+ +x.cbs; });\n  const cell=(m,g)=>cellBy[m+'|'+g]||0;\n  const mx=Math.max(...ms.flatMap(m=>mv.leaders.map(L=>cell(m,L.grupo))),1);\n  const domLeader=m=>{ let best=null,bc=0;\n    mv.leaders.forEach(L=>{ const v=cell(m,L.grupo); if(v>bc){ bc=v; best=L; } });\n    return best?{L:best,cbs:bc}:null; };\n  const short=L=>(L.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+L.grupo);\n  // NOT named `rows` — that is the module-level Databricks response helper, and shadowing it inside a\n  // render function is a trap for whoever adds a query here later.\n  // Off-mismatch ainda calculado pra alimentar a nota abaixo da tabela — só não vira mais coluna: o\n  // destino agora é \"Setor destino\" por GRUPO no rodapé, não mais por município na grade.\n  const recs=ms.map(m=>{ const d=mv.assign[m]||null, dl=domLeader(m);\n    const home=dl?(homeOf[dl.L.grupo]||null):null;\n    return {m:m,off:!!(dl&&home&&d&&home!==d)}; });\n  el.innerHTML=`<thead><tr><th>Município | Grupo | Líder</th>${mv.leaders.map(L=>`<th title=\"${esc(L.lider||'')}\">${L.grupo}<br><span style=\"font-weight:400;font-size:10px;color:var(--mut)\">${esc(short(L))}</span></th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +ms.map(m=>`<tr><td><span class=\"home\" style=\"background:${mv.color[mv.assign[m]]||'#ccc'}\"></span>${m}</td>`\n     +mv.leaders.map(L=>{const v=cell(m,L.grupo);const pct=L.total?Math.round(v/L.total*100):0;return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v?`${v} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(mv.munis[m].cbs)}</td></tr>`).join('')\n   // Um rótulo de destino por GRUPO (coluna), não por município (linha) — cada grupo tem UM home\n   // fixo (computeDerived já calculou), então ele mora uma vez no rodapé, igual o nome já morava.\n   +`<tr class=\"lider-foot\"><td>Municípios fora do setor</td>`\n     +mv.leaders.map(L=>{const v=(mv.foraDoSetorByGrupo&&mv.foraDoSetorByGrupo[L.grupo])||0;const pct=L.total?Math.round(v/L.total*100):0; return `<td title=\"CBs desta líder (dado do Databricks) em município(s) que a base de dominantes (dominantesCsvUrl) não atribui ao setor ${mv.sector}\">${v?`${fmt(v)} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n     +`<td></td></tr>`\n   +`<tr class=\"lider-foot\"><td>Setor destino</td>`\n     +mv.leaders.map(L=>{const h=homeOf[L.grupo]; return `<td>${h?`<span class=\"home\" style=\"background:${mv.color[h]}\"></span>${h}`:'–'}</td>`;}).join('')\n     +`<td></td></tr>`\n   +`<tr class=\"lider-foot\"><td>CBs dentro do setor</td>`\n     +mv.leaders.map(L=>{const h=homeOf[L.grupo]; const v=homeCbsOf[L.grupo]||0; return `<td>${h?v+'%':'–'}</td>`;}).join('')\n     +`<td></td></tr>`     +`</tbody>`;\n  renderXferBreakdown(mv,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle');\n  if(nt){\n    const offN=recs.filter(r=>r.off).length;\n    nt.innerHTML=(mv.policy!=='fora')\n      ? `Política <b>quebrar relação</b>: as CBs deixam a líder atual e passam à estrutura do setor destino — o rodapé mostra pra onde cada grupo vai.`\n      : `<b>Setor destino</b> = o setor que recebe a maior parte das CBs de cada líder (rodapé, uma vez por grupo). `\n        +(offN?`<span style=\"color:var(--closing)\">${fmt(offN)} município(s) ⚠</span> têm a líder dominante indo para um setor diferente do dela — as CBs seguem a líder (é disso que o KPI “CBs fora do setor” é feito).`\n          :`Em todos os municípios a líder dominante termina no mesmo setor que recebe o território.`);\n  }\n}\nfunction renderPolicy(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  // O toggle fora-do-setor/quebra-de-relação é uma decisão de setor fechando — em GV/mercado a\n  // seção some inteira em vez de mostrar um controle que não corresponde a nada calculado.\n  const sec=$('#policySection'); if(sec) sec.classList.toggle('hidden',(mv.level||'setor')!=='setor');\n  if((mv.level||'setor')!=='setor') return;\n  $('#policyNote').innerHTML=mv.policy==='fora'\n    ?`<b style=\"color:var(--acc)\">Mantendo vínculo:</b> ${fmt(c.desloc)} CBs seguem com a líder mas ficam <b>fisicamente noutr${lvl.a==='à'?'a':'o'} ${lc(lvl.destLabel)}</b>. Preserva relação; suja geografia.`\n    :`<b style=\"color:var(--acc)\">Quebrando vínculo:</b> ${fmt(c.desloc)} CBs <b>re-vinculam a uma líder d${lvl.a==='à'?'a':'o'} ${lc(lvl.destLabel)} destino</b>. Geografia limpa; rompe relação.`;\n}\nfunction renderPlan(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor']; const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>a.localeCompare(b));\n  // GV/mercado: a chave \"m\" aqui é um código de setor/GV, não uma chave município/UF — mName/mUf\n  // parseariam lixo, então o CSV de plano exporta o código cru nesses níveis.\n  mv._plan=ms.map(m=>(mv.level&&mv.level!=='setor')?{municipio:m,uf:'',cbs:mv.munis[m].cbs,destino:mv.assign[m]}:{municipio:mName(m),uf:mUf(m),cbs:mv.munis[m].cbs,destino:mv.assign[m]});\n  $('#plan').innerHTML=`Etapa ${SESS.selected+1} · ${lvl.label} <b>${mv.sector}</b> → ${ms.map(m=>`${m}→<b style=\"color:${mv.color[mv.assign[m]]}\">${mv.assign[m]||'–'}</b>`).join(' · ')}<br>\n   Política: <b>${mv.policy==='fora'?`manter vínculo (CBs fora ${lvl.de} ${lc(lvl.destLabel)})`:'quebrar relação'}</b> · ${fmt(c.desloc)} CBs impactadas nesta etapa.`\n   +(c.orphanTotal?`<br>Fora da base de dominantes: ${fmt(c.orphanTotal)} CB(s) seguem suas líderes — ${mv.dest.filter(s=>c.orph[s]).map(s=>`${s} (${fmt(c.orph[s])})`).join(' · ')||'–'}${c.orphanStranded?` · <b style=\"color:var(--closing)\">${fmt(c.orphanStranded)} sem líder com destino</b>`:''}. Não entram no CSV, que é por município.`:'')\n   +(mv.kind==='transfer'?`<br><b style=\"color:var(--acc)\">Transferência manual</b>: ${mName(mv.xferMuni)} (${mUf(mv.xferMuni)}) — ${mv.xferFrom} → ${mv.xferTo} · ${fmt(mv.xferMetrics.cbs)} CBs`:'');\n}\nfunction renderReport(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  mv.metric=mv.metric||'cbs';\n  const KEYS=METRICS_DEF.map(m=>m[0]);\n  const incBy={};mv.dest.forEach(s=>incBy[s]=Object.fromEntries(KEYS.map(k=>[k,0])));\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(!s||!incBy[s])return;KEYS.forEach(k=>incBy[s][k]+=o[k]||0);});\n  // orphan CBs land on their leader's home sector, not on a geographic destination\n  c.leaders.forEach(L=>{ const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n    if(om&&L.home&&incBy[L.home]) KEYS.forEach(k=>incBy[L.home][k]+=om[k]||0); });\n  // mv.munis['__OUTROS__'] already holds EVERY row this sector has that never got drawn on the\n  // map — \"fora da base de dominantes\" (the one subset mv.orphanMetricsByGroup tracks) AND any\n  // other reason a município has no geoFeat (no shape in the loaded UFs, key mismatch — see\n  // missingMunis). Summing every mv.munis entry, __OUTROS__ included, is the exact same total\n  // sectorTotalCbs() already uses for the \"CBs no setor (antes desta etapa)\" KPI — this bar has to\n  // agree with that KPI, not undercount by whichever slice of __OUTROS__ isn't a tracked orphan\n  // group (a município with no shape at all silently vanished from this total before).\n  const closeTot={};KEYS.forEach(k=>closeTot[k]=Object.values(mv.munis).reduce((a,o)=>a+(o[k]||0),0));\n  const trend=mv.trend.slice(-6);\n  const chartHTML=mk=>{\n    const isM=['receita','rec_nat','rec_avon'].includes(mk); const fN=isM?money:fmt;\n    const data=mv.dest.map(s=>({label:s,color:mv.color[s],antes:+(mv.baseMetrics[s]?.[mk]||0),depois:+(mv.baseMetrics[s]?.[mk]||0)+(incBy[s]?.[mk]||0)}));\n    data.push({label:mv.sector+'✕',color:'#ff3b30',antes:closeTot[mk]||0,depois:0});\n    const max=Math.max(...data.flatMap(d=>[d.antes,d.depois]),1);\n    const W=720,H=290,base=238,top=42,maxH=base-top,n=data.length,gw=(W-16)/n;\n    let g='';\n    data.forEach((d,i)=>{const cx=8+gw*i+gw/2,bw=Math.min(30,gw*0.30);\n      const ah=d.antes/max*maxH,dh=d.depois/max*maxH,ax=cx-bw-2,dx=cx+2;\n      g+=`<rect x=\"${ax.toFixed(1)}\" y=\"${(base-ah).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${ah.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\" opacity=\"0.35\"/>`;\n      g+=`<rect x=\"${dx.toFixed(1)}\" y=\"${(base-dh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${dh.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\"/>`;\n      g+=`<text x=\"${(ax+bw/2).toFixed(1)}\" y=\"${(base-ah-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9\" fill=\"#9a9aa8\">${fN(d.antes)}</text>`;\n      g+=`<text x=\"${(dx+bw/2).toFixed(1)}\" y=\"${(base-dh-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9.5\" font-weight=\"700\" fill=\"${d.color}\">${fN(d.depois)}</text>`;\n      const dp=d.antes?((d.depois-d.antes)/d.antes*100):(d.depois?100:0);\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"16\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"${dp>=0?'#34c759':'#ff3b30'}\">${dp>0?'+':''}${dp.toFixed(0)}%</text>`;\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"${base+16}\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"#1c1c2b\">${d.label}</text>`;});\n    return `<svg viewBox=\"0 0 ${W} ${H}\" style=\"width:100%\"><line x1=\"8\" y1=\"${base}\" x2=\"${W-8}\" y2=\"${base}\" stroke=\"#e7e7ef\"/>${g}</svg>`;\n  };\n  let h=`<div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap\">\n      <h2 style=\"margin:0\">Impacto antes → depois desta etapa</h2>\n      <select id=\"metricSel\">${METRICS_DEF.map(m=>`<option value=\"${m[0]}\" ${m[0]===mv.metric?'selected':''}>${m[1]}</option>`).join('')}</select></div>\n    <div class=\"note\" style=\"margin:2px 0 4px\">Barra clara = <b>antes desta etapa</b> (já incluindo etapas anteriores) · barra cheia = <b>depois</b> · <b style=\"color:var(--acc)\">${mv.sector}✕</b> = ${lc(lvl.label)} fechad${lvl.a==='à'?'a':'o'} nesta etapa · % = variação</div>\n    ${chartHTML(mv.metric)}`;\n  h+=`<h2 style=\"margin-top:16px\">Antes → Depois por ${lc(lvl.destLabel)} receptor (snapshot congelado)</h2>\n    <div class=\"scroll grow\"><table><thead><tr><th>${lvl.destLabel}</th>${METRICS_DEF.map(m=>`<th>${m[1]}</th>`).join('')}</tr></thead><tbody>`;\n  mv.dest.forEach(s=>{const b=mv.baseMetrics[s]||{};const inc=incBy[s];\n    h+=`<tr><td><span class=\"home\" style=\"background:${mv.color[s]}\"></span>${s}</td>`+\n      METRICS_DEF.map(([k])=>{const bv=+b[k]||0,av=bv+(inc[k]||0);const d=bv?((av-bv)/bv*100):0;const f=METRICS_DEF.find(x=>x[0]===k)[2]==='$'?money:fmt;\n        return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\" title=\"${({cbs:'CBs',ativas:'CBs ativas',pedidos:'Pedidos',pontos:'Pontos',receita:'Receita',rec_nat:'Receita Natura',rec_avon:'Receita Avon'})[k]} ${({cbs:'recebidas',ativas:'recebidas',pedidos:'recebidos',pontos:'recebidos',receita:'recebida',rec_nat:'recebida',rec_avon:'recebida'})[k]}: +${f(inc[k]||0)}\\nAntes ${f(bv)} -> Depois ${f(av)}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;}).join('')+`</tr>`;});\n  h+=`<tr style=\"opacity:.8\"><td>${mv.sector} (sai)</td>`+METRICS_DEF.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(closeTot[k]||0)}</td>`).join('')+`</tr>`;\n  h+=`</tbody></table></div>`;\n  const incLvl={};mv.dest.forEach(s=>incLvl[s]={});\n  Object.keys(mv.munis).forEach(m=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(!s||!incLvl[s])return;const lv=mv.levelByMuni[m]||{};for(const nv in lv)incLvl[s][nv]=(incLvl[s][nv]||0)+lv[nv];});\n  c.leaders.forEach(L=>{ const ol=mv.orphanLevelByGroup&&mv.orphanLevelByGroup[L.grupo];\n    if(ol&&L.home&&incLvl[L.home]) Object.entries(ol).forEach(([nv,n])=>incLvl[L.home][nv]=(incLvl[L.home][nv]||0)+n); });\n  h+=`<h2 style=\"margin-top:18px\">Consultoras por nível — antes → depois por ${lc(lvl.destLabel)} receptor</h2>\n    <div class=\"scroll grow\"><table><thead><tr><th>${lvl.destLabel}</th>${NIVEIS.map(n=>`<th>${n}</th>`).join('')}<th>Total</th></tr></thead><tbody>`;\n  mv.dest.forEach(s=>{const base=mv.baseLevel[s]||{},inc=incLvl[s]||{};let tb=0,ta=0;\n    h+=`<tr><td><span class=\"home\" style=\"background:${mv.color[s]}\"></span>${s}</td>`+\n      NIVEIS.map(nv=>{const b=base[nv]||0,i=inc[nv]||0,a=b+i;tb+=b;ta+=a;\n        return `<td>${a?fmt(a):'·'}${i?`<div class=\"delta up\">+${fmt(i)}</div>`:''}</td>`;}).join('')+\n      `<td style=\"font-weight:700\">${fmt(ta)}${ta-tb?`<div class=\"delta up\">+${fmt(ta-tb)}</div>`:''}</td></tr>`;});\n  h+=`</tbody></table></div><div class=\"note\">Número = CBs por nível <b>depois</b> desta etapa · <span style=\"color:var(--ok)\">+N</span> = entrantes do ${mv.sector}.</div>`;\n  const spk=(key)=>{const vals=trend.map(t=>+t[key]||0);const mx=Math.max(...vals,1);\n    return `<div class=\"spark\">${vals.map(v=>`<div class=\"b\" style=\"height:${(v/mx*100).toFixed(0)}%\" title=\"${fmt(v)}\"></div>`).join('')}</div>\n      <div class=\"note\">${trend.map(t=>String(t.ciclo).slice(-2)).join(' · ')}</div>`;};\n  h+=`<h2 style=\"margin-top:18px\">Setor ${mv.sector} — últimos ${trend.length} ciclos até o snapshot congelado</h2>\n    <div class=\"note\" style=\"margin:-4px 0 8px\">Tendência histórica <b>do próprio setor</b> — não ajustada pela absorção hipotética de etapas anteriores (limitação conhecida: tendência é por setor, não decomponível por município).</div>\n    <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:10px\">\n    ${[['cbs','CBs'],['ativas','Ativas'],['receita','Receita'],['pontos','Pontos']].map(([k,l])=>`<div><div class=\"note\" style=\"margin:0 0 3px;font-weight:600;color:var(--ink)\">${l}</div>${spk(k)}</div>`).join('')}</div>`;\n  $('#tab-report').innerHTML=h;\n  const sel=$('#metricSel'); if(sel){ sel.onchange=()=>{mv.metric=sel.value; renderReport();}; initMacSelect(sel); }\n}\nfunction renderCumulative(){\n  const el=$('#tab-cumulative'); if(!el) return;\n  // Esta view soma a cadeia inteira via foldWorldState().muniOverride/sectorBaseCache — hoje só\n  // populados pelo fechamento de setor. GV/mercado têm o rastro equivalente (secOverride/gvOverride),\n  // mas essa agregação ainda não foi refeita nesse grão; melhor avisar do que mostrar tabela vazia.\n  const lockLvl=SESS.timeline.length?(SESS.timeline[0].level||'setor'):'setor';\n  if(lockLvl!=='setor'){ el.innerHTML=`<div class=\"note\">Impacto acumulado da cadeia ainda não disponível pra sessões de ${lc(LEVELS[lockLvl].label)} — use o relatório da etapa (aba anterior) etapa a etapa.</div>`; return; }\n  if(!SESS.timeline.length){ el.innerHTML='<div class=\"note\">Nenhuma etapa na timeline ainda.</div>'; return; }\n  const ready=SESS.timeline.every(mv=>mv.munis);\n  if(!ready){ el.innerHTML='<div class=\"note\">Aguardando etapas terminarem de calcular…</div>'; return; }\n  const closingMoves=SESS.timeline.filter(mv=>mv.kind!=='transfer');\n  const finalWorld=foldWorldState(SESS.timeline,SESS.timeline.length);\n  const openSectors=[...new Set(Object.values(finalWorld.muniOverride))];\n  let h=`<h2 style=\"margin:0 0 8px\">Linha do tempo — ${closingMoves.length} fechamento(s)</h2>\n    <div class=\"note\" style=\"margin:-2px 0 10px\">Setores fechados, em ordem: ${closingMoves.map(mv=>mv.sector).join(' → ')}. Cada linha abaixo soma o efeito de <b>toda a cadeia</b>, não apenas da última etapa.</div>`;\n  h+=`<div class=\"scroll grow\"><table><thead><tr><th>Setor</th><th>Situação</th>${METRICS_DEF.map(([,l])=>`<th>${l}</th>`).join('')}</tr></thead><tbody>`;\n  closingMoves.forEach(mv=>{\n    const i=SESS.timeline.indexOf(mv);\n    h+=`<tr style=\"opacity:.75\"><td>${mv.sector}</td><td>fechado (etapa ${i+1})</td>`+METRICS_DEF.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(mv.pureOwnMetrics?.[k]||0)}</td>`).join('')+`</tr>`;\n  });\n  openSectors.forEach(s=>{\n    const base=SESS.sectorBaseCache[s]||{};\n    const infl=finalWorld.sectorInflow[s]||{};\n    h+=`<tr><td>${s}</td><td>recebeu território</td>`+METRICS_DEF.map(([k,,t])=>{\n      const bv=+base[k]||0, av=bv+(infl[k]||0); const d=bv?((av-bv)/bv*100):0; const f=(t==='$'?money:fmt);\n      return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;\n    }).join('')+`</tr>`;\n  });\n  h+=`</tbody></table></div><div class=\"note\">\"Antes\" de um setor receptor é o snapshot congelado (sem nenhum fechamento aplicado); \"depois\" soma tudo que a cadeia inteira jogou pra ele.</div>`;\n  el.innerHTML=h;\n}\nfunction project(feats){let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  const each=cb=>feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(p=>p.forEach(r=>r.forEach(cb)));});\n  each(p=>{mnx=Math.min(mnx,p[0]);mxx=Math.max(mxx,p[0]);mny=Math.min(mny,p[1]);mxy=Math.max(mxy,p[1]);});\n  const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=MAP_W,H=MAP_H,pad=22;\n  const gw=(mxx-mnx)*kx,gh=(mxy-mny),sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n  const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n  return p=>[ox+(p[0]-mnx)*kx*sc, oy+(mxy-p[1])*sc];\n}\nfunction pathFor(f,pr){const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  return ps.map(poly=>poly.map(r=>'M'+r.map(p=>{const q=pr(p);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');}\nfunction cxy(f,pr){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(poly=>poly[0].forEach(p=>{const q=pr(p);x+=q[0];y+=q[1];n++;}));return[x/n,y/n];}\n// ---------- shared-município slicing ----------\n// A huge urban município (São Paulo etc.) can be the CSV's dominant município for MANY different\n// setores at once — Natura subdivides one big city into dozens of commercial sectors while IBGE's\n// shapefile only has ONE polygon for the whole city. Drawing that single polygon once per claiming\n// sector stacked identical copies on top of each other: only whichever sector was processed first\n// ever \"won\" the shape, and every other sector's label floated over territory it didn't actually own\n// (mv.muniOwners below records every claimant, not just the first). Slice the polygon into N\n// equal-width strips instead — one per claiming sector, ordered by sector code — along whichever\n// axis the shape is longer on, so each sector genuinely gets its own area instead of losing the race.\nfunction clipRingHalf(ring,axis,keepGe,val){\n  const out=[],n=ring.length; if(!n) return out;\n  const side=p=>keepGe?p[axis]>=val:p[axis]<=val;\n  for(let i=0;i<n;i++){\n    const cur=ring[i],prev=ring[(i-1+n)%n];\n    const curIn=side(cur),prevIn=side(prev);\n    if(curIn!==prevIn){\n      const t=(val-prev[axis])/(cur[axis]-prev[axis]);\n      const other=1-axis,pt=[0,0]; pt[axis]=val; pt[other]=prev[other]+t*(cur[other]-prev[other]);\n      out.push(pt);\n    }\n    if(curIn) out.push(cur);\n  }\n  return out;\n}\nfunction sliceFeatureEqual(feature,n){\n  if(n<=1) return [feature];\n  const g=feature.geometry,parts=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  const rings=[]; parts.forEach(poly=>poly.forEach(r=>{ if(r&&r.length>=4) rings.push(r); }));\n  let mnx=Infinity,mxx=-Infinity,mny=Infinity,mxy=-Infinity;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  if(!isFinite(mnx)) return Array.from({length:n},()=>({...feature,__empty:true}));\n  const wide=(mxx-mnx)>=(mxy-mny), axis=wide?0:1;\n  const lo=wide?mnx:mny, hi=wide?mxx:mxy, span=(hi-lo)||1;\n  const out=[];\n  for(let i=0;i<n;i++){\n    const a=lo+span*i/n, b=lo+span*(i+1)/n;\n    const polys=[];\n    rings.forEach(r=>{\n      let c=clipRingHalf(r,axis,true,a);\n      c=clipRingHalf(c,axis,false,b);\n      if(c.length>=3) polys.push([[...c,c[0]]]);\n    });\n    out.push(polys.length?{type:'Feature',properties:feature.properties,__uf:feature.__uf,geometry:{type:'MultiPolygon',coordinates:polys}}:{...feature,__empty:true});\n  }\n  return out;\n}\n// ---------- label placement ----------\n// Pole of inaccessibility: the interior point farthest from ANY boundary of the dissolved area. A\n// centroid is only a mean — on a concave sector, one split across a river, or a member whose vertex\n// average falls in a bay, it lands outside the very territory it is supposed to name, which is what\n// pushed the sector numbers off their own shapes. Even-odd across every ring of every member is a\n// correct union test here because municipalities tile rather than overlap.\n// The search box is clipped to the viewBox, so the result is inside the territory AND on screen.\n// Rings are decimated first — this is a label, not a measurement — which keeps a 20-município sector\n// to a few million ops, and the result is cached per stage: geometry never changes between renders,\n// only the fill does.\nfunction labelPointFor(fs,pr){\n  const rings=[];\n  (fs||[]).forEach(f=>{ if(!f||!f.geometry) return;\n    const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n    ps.forEach(poly=>poly.forEach(r=>{\n      if(!r||r.length<4) return;\n      const step=Math.max(1,Math.floor(r.length/120)),pts=[];\n      for(let i=0;i<r.length;i+=step) pts.push(pr(r[i]));\n      if(pts.length>3) rings.push(pts);\n    }));});\n  if(!rings.length) return null;\n  let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  mnx=Math.max(mnx,2); mny=Math.max(mny,2); mxx=Math.min(mxx,698); mxy=Math.min(mxy,498);\n  if(!(mxx>mnx&&mxy>mny)) return null;\n  const inside=(x,y)=>{ let c=false;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      if(((a[1]>y)!==(b[1]>y)) && (x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])) c=!c; }\n    return c; };\n  const edgeDist=(x,y)=>{ let m=Infinity;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      const dx=b[0]-a[0],dy=b[1]-a[1],L=dx*dx+dy*dy;\n      let t=L?(((x-a[0])*dx+(y-a[1])*dy)/L):0; t=t<0?0:(t>1?1:t);\n      const ex=a[0]+t*dx-x,ey=a[1]+t*dy-y,d=ex*ex+ey*ey; if(d<m)m=d; }\n    return Math.sqrt(m); };\n  const W=mxx-mnx,H=mxy-mny,N=26;\n  // Centroide (média simples dos vértices) do território inteiro — só usado pra desempatar A FAVOR\n  // do meio quando um ponto mais central tem folga quase tão boa quanto o campeão isolado. Um setor\n  // com um lóbulo bem mais gordo de um lado sempre ganhava o rótulo puxado pra esse lado, mesmo\n  // quando um ponto quase tão espaçoso e bem mais central também servia — o peso é pequeno de\n  // propósito, nunca vale mais que uma folga real melhor.\n  let ccx=0,ccy=0,cn=0;\n  rings.forEach(r=>r.forEach(p=>{ ccx+=p[0]; ccy+=p[1]; cn++; }));\n  ccx/=cn||1; ccy/=cn||1;\n  const CENTER_PULL=0.22;\n  const scoreAt=(x,y,d)=>d-CENTER_PULL*Math.hypot(x-ccx,y-ccy);\n  let best=null,bd=-1,bs=-Infinity;\n  for(let i=0;i<=N;i++) for(let j=0;j<=N;j++){\n    const x=mnx+W*i/N,y=mny+H*j/N;\n    if(!inside(x,y)) continue;\n    const d=edgeDist(x,y),s=scoreAt(x,y,d); if(s>bs){ bs=s; bd=d; best=[x,y]; }\n  }\n  if(!best) return null;\n  // Hill-climb off the coarse grid: the grid only has to land somewhere in the right lobe, the\n  // refinement finds the deep point inside it — halving the step whenever no neighbour improves.\n  let step=Math.max(W,H)/N/2;\n  for(let k=0;k<7;k++){\n    let moved=false;\n    for(const o of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]){\n      const x=best[0]+o[0]*step,y=best[1]+o[1]*step;\n      if(x<mnx||x>mxx||y<mny||y>mxy||!inside(x,y)) continue;\n      const d=edgeDist(x,y),s=scoreAt(x,y,d); if(s>bs){ bs=s; bd=d; best=[x,y]; moved=true; }\n    }\n    if(!moved) step/=2;\n  }\n  // The clearance travels with the point: it is the radius of the largest circle that fits inside the\n  // territory there, which is exactly the budget a caller needs to decide how much text will fit.\n  return {p:best,r:bd};\n}\nfunction labelPointOf(mv,key,fs,pr){\n  if(!mv._lbl) mv._lbl={};\n  if(!(key in mv._lbl)) mv._lbl[key]=labelPointFor(fs,pr);\n  return mv._lbl[key];\n}\n// ---------- map zoom/pan ----------\n// Zoom é feito manipulando o viewBox, nunca um transform CSS — assim rótulos e polígonos vivem no\n// MESMO sistema de coordenadas e escalam juntos automaticamente (texto tem font-size em unidades do\n// viewBox, então encolher o viewBox amplia tudo por igual, sem lógica extra pro tamanho da fonte).\nconst MAP_W=700; let MAP_H=500; const MAP_ZOOM_MAX=8;\nlet mapView={x:0,y:0,w:MAP_W,h:MAP_H};\n// MAP_H acompanha a proporção real do card na tela (MAP_W fica fixo) — sem isso o \"mundo\" de\n// desenho tinha sempre proporção 700x500 fixa, e o <svg> (que preserva essa proporção) ficava\n// menor que o card sempre que o card fosse bem mais alto que largo, sobrando um vão em branco\n// embaixo — a sensação de \"mapa preso num quadrado\" dentro de um espaço bem maior.\nfunction syncMapDims(){\n  const wrap=$('.map-wrap'); if(!wrap) return;\n  const cw=wrap.clientWidth, ch=wrap.clientHeight; if(!cw||!ch) return;\n  const newH=Math.round(MAP_W*(ch/cw));\n  if(Math.abs(newH-MAP_H)<2) return;\n  const fracW=mapView.w/MAP_W, cx=(mapView.x+mapView.w/2)/MAP_W, cy=(mapView.y+mapView.h/2)/MAP_H;\n  MAP_H=newH;\n  const w=Math.min(MAP_W,Math.max(MAP_W/MAP_ZOOM_MAX,MAP_W*fracW));\n  const h=Math.min(MAP_H,Math.max(MAP_H/MAP_ZOOM_MAX,MAP_H*fracW));\n  let x=cx*MAP_W-w/2, y=cy*MAP_H-h/2;\n  x=Math.min(MAP_W-w,Math.max(0,x)); y=Math.min(MAP_H-h,Math.max(0,y));\n  mapView={x,y,w,h};\n}\nfunction applyMapView(){\n  const svg=$('#map'); if(!svg) return;\n  svg.setAttribute('viewBox', mapView.x.toFixed(2)+' '+mapView.y.toFixed(2)+' '+mapView.w.toFixed(2)+' '+mapView.h.toFixed(2));\n  // Rótulo tem font-size em unidades do viewBox, então sem isso ele cresceria junto com o zoom até\n  // virar um borrão — encolhendo o font-size na mesma proporção que o viewBox encolhe cancela\n  // exatamente o zoom, e o texto fica do MESMO tamanho na tela em qualquer nível de zoom.\n  const fsScale=mapView.w/MAP_W;\n  svg.querySelectorAll('text[data-fs]').forEach(t=>{\n    const base=+t.getAttribute('data-fs')||8;\n    t.setAttribute('font-size',(base*fsScale).toFixed(2));\n  });\n  // Mesmo raciocínio pro halo do texto e pro fio que liga um rótulo deslocado ao seu município —\n  // os dois têm largura de traço fixa em unidades do viewBox, então sem reescalar iam virar uma\n  // mancha grossa (halo) ou uma barra (fio) no zoom alto em vez de ficarem do mesmo tamanho na tela.\n  svg.querySelectorAll('[data-sw]').forEach(el=>{\n    const base=+el.getAttribute('data-sw')||1;\n    el.style.strokeWidth=(base*fsScale).toFixed(3)+'px';\n  });\n  // Fio de rótulo deslocado é calculado UMA vez, no mundo inteiro de 700x500, pra evitar colisão\n  // com TODO outro rótulo — inclusive os que, no zoom atual, nem estão mais na tela. Quanto mais\n  // fundo o zoom, maior a fração da área visível que esse mesmo fio (comprimento fixo em unidades\n  // do mundo) passa a cobrir, virando um risco diagonal cortando o mapa até um rótulo que já saiu\n  // do enquadramento. Esconder o fio quando ele passa de uma fração do que está visível agora\n  // resolve isso sem precisar recalcular posição de rótulo a cada passo de zoom.\n  const viewDiag=Math.hypot(mapView.w,mapView.h);\n  svg.querySelectorAll('line.lead').forEach(ln=>{\n    const dx=(+ln.getAttribute('x2'))-(+ln.getAttribute('x1')), dy=(+ln.getAttribute('y2'))-(+ln.getAttribute('y1'));\n    ln.style.display=(Math.hypot(dx,dy)>viewDiag*0.35)?'none':'';\n  });\n}\nfunction zoomMapBy(factor){\n  const cx=mapView.x+mapView.w/2, cy=mapView.y+mapView.h/2;\n  let w=mapView.w/factor, h=mapView.h/factor;\n  w=Math.min(MAP_W, Math.max(MAP_W/MAP_ZOOM_MAX, w));\n  h=Math.min(MAP_H, Math.max(MAP_H/MAP_ZOOM_MAX, h));\n  let x=cx-w/2, y=cy-h/2;\n  x=Math.min(MAP_W-w, Math.max(0,x));\n  y=Math.min(MAP_H-h, Math.max(0,y));\n  mapView={x,y,w,h};\n  applyMapView();\n}\n// Mesma matemática do zoomMapBy, mas ancorada no cursor em vez do centro da view — o ponto do mapa\n// que está sob o mouse continua sob o mouse depois do zoom, o que faz o scroll parecer\n// contínuo/\"seamless\" em vez de recentralizar o mapa a cada girada da roda.\nfunction zoomMapAt(factor, clientX, clientY){\n  const svg=$('#map'); if(!svg) return;\n  const rect=svg.getBoundingClientRect(); if(!rect.width||!rect.height) return;\n  const fx=(clientX-rect.left)/rect.width, fy=(clientY-rect.top)/rect.height;\n  const wx=mapView.x+fx*mapView.w, wy=mapView.y+fy*mapView.h;\n  let w=mapView.w/factor, h=mapView.h/factor;\n  w=Math.min(MAP_W, Math.max(MAP_W/MAP_ZOOM_MAX, w));\n  h=Math.min(MAP_H, Math.max(MAP_H/MAP_ZOOM_MAX, h));\n  let x=wx-fx*w, y=wy-fy*h;\n  x=Math.min(MAP_W-w, Math.max(0,x));\n  y=Math.min(MAP_H-h, Math.max(0,y));\n  mapView={x,y,w,h};\n  applyMapView();\n}\n// Arrastar sempre fica DENTRO do canvas de 700x500 (clamp em [0, MAP_W-w] / [0, MAP_H-h]) — nunca\n// existe um x/y que deixa o viewBox parcialmente fora da área com conteúdo, então o mapa nunca\n// \"some\" da tela, só pode chegar até a borda do que existe pra ver.\nlet mapDrag=null, mapDidDrag=false;\nfunction mapPointerDown(e){\n  const svg=$('#map'); if(!svg) return;\n  mapDrag={x0:e.clientX,y0:e.clientY,vx0:mapView.x,vy0:mapView.y,rect:svg.getBoundingClientRect()};\n  mapDidDrag=false;\n  svg.classList.add('dragging');\n  // NÃO setPointerCapture aqui: capturar o ponteiro no <svg> reencaminha o click resultante pro\n  // próprio <svg> em vez do <path> do município embaixo do cursor — todo clique parava de chegar\n  // no onclick de reatribuição, não só depois de um arrasto de verdade. move/up escutam em `root`\n  // (nunca substituído por renderAll) em vez de no <svg>, então o arrasto continua funcionando\n  // mesmo se o ponteiro sair da área do mapa no meio do gesto.\n}\nfunction mapPointerMove(e){\n  if(!mapDrag) return;\n  const dxPix=e.clientX-mapDrag.x0, dyPix=e.clientY-mapDrag.y0;\n  if(!mapDidDrag && Math.hypot(dxPix,dyPix)>4) mapDidDrag=true;\n  if(!mapDrag.rect.width||!mapDrag.rect.height) return;\n  const sx=mapView.w/mapDrag.rect.width, sy=mapView.h/mapDrag.rect.height;\n  let x=mapDrag.vx0-dxPix*sx, y=mapDrag.vy0-dyPix*sy;\n  x=Math.min(MAP_W-mapView.w, Math.max(0,x));\n  y=Math.min(MAP_H-mapView.h, Math.max(0,y));\n  mapView={...mapView,x,y};\n  applyMapView();\n}\nfunction mapPointerUp(){\n  if(!mapDrag) return;\n  mapDrag=null;\n  const svg=$('#map'); if(svg) svg.classList.remove('dragging');\n}\n// ---------- OSM reference layers (rodovias/rios/picos) ----------\n// This map is a hand-projected SVG, not a Leaflet/Mapbox instance — there is no tile layer to drop a\n// basemap into. Vector geometry is the only shape that can ride the SAME pr() projection every\n// município shape already uses and land in the right spot. Scoped to the bbox of what mv.geoFeat +\n// mv.nbrFeat actually put on screen for THIS stage — never the whole country — because Overpass's\n// road/river graph for Brazil is enormous and the ask here is é \"what's around this closure\", not a\n// national basemap. Cached by (layer, bbox) rather than by session/stage index: two stages that\n// happen to look at the same patch of ground share one fetch, and switching sessions can never serve\n// a stale layer under a reused stage index.\n// UMA query Overpass pra Rodovias inteira (as 8 subclasses documentadas na wiki do Overpass API,\n// https://wiki.openstreetmap.org/wiki/Overpass_API, num regex só), não 8 requests separados — 8\n// chamadas de uma vez contra o mesmo servidor público é exatamente o padrão que dispara\n// rate-limit/504 \"servidor ocupado\" (foi o que quebrou \"secondary\"/\"secondary_link\"). Busca e cache\n// continuam por bbox, só que agora sob UMA chave ('rodovias'); a subclasse de cada trecho\n// (highway=motorway/trunk/primary/secondary + _link) já vem em el.tags.highway na resposta (\"out\n// geom\" inclui as tags do elemento por padrão) — cada checkbox individual só filtra o que já foi\n// baixado, sem request nova nenhuma.\nconst HIGHWAY_META={\n  motorway:{label:'Rodovia expressa',tag:'highway=motorway',color:'#777777',sw:0.5},\n  trunk:{label:'Via arterial',tag:'highway=trunk',color:'#777777',sw:0.5},\n  primary:{label:'Rodovia principal',tag:'highway=primary',color:'#777777',sw:0.5},\n  secondary:{label:'Rodovia secundária',tag:'highway=secondary',color:'#777777',sw:0.5},\n};\nconst HIGHWAY_TAG_TO_KEY={motorway:'motorway',trunk:'trunk',primary:'primary',secondary:'secondary'};\n// Menor pra maior — desenha secundária primeiro (embaixo) e motorway por último (em cima), pra uma via\n// maior nunca sumir sob uma menor só por causa da ordem arbitrária que o Overpass devolve.\nconst HIGHWAY_RANK={secondary:0,primary:1,trunk:2,motorway:3};\nconst HIGHWAY_KEYS=Object.keys(HIGHWAY_META);\nconst OSM_LAYERS=[\n  {key:'rodovias',label:'Rodovias',\n   ql:b=>`way[\"highway\"~\"^(motorway|trunk|primary|secondary)$\"](${b});`},\n  // Só rio PERMANENTE conta como barreira de verdade pra distância real entre municípios — riacho\n  // é atravessado por qualquer bueiro, e um rio \"intermitente\" (comum no semiárido nordestino) fica\n  // seco a maior parte do ano, então nenhum dos dois muda quanto tempo leva pra ir de um lugar ao\n  // outro.\n  {key:'rivers',label:'Rios',color:'#0a84ff',sw:0.5,\n   ql:b=>`way[\"waterway\"=\"river\"][\"intermittent\"!=\"yes\"](${b});`},\n  // Aeroporto/aeródromo (https://wiki.openstreetmap.org/wiki/Tag:aeroway%3Daerodrome) pode estar\n  // mapeado como nó único (campo pequeno) OU como área (way/relation, o terreno inteiro) — nwr pega\n  // os três, e \"out center\" devolve um ponto pronto pra qualquer um deles (lat/lon direto no nó,\n  // center.lat/lon calculado pro way/relation), sem geometria de contorno igual rodovia/rio porque\n  // aqui o que importa é a LOCALIZAÇÃO, não o desenho do pátio.\n  // Só aeroporto COMERCIAL: exige tag \"iata\" (código IATA de 3 letras só existe em aeródromo com voo\n  // comercial regular) — filtra fora pista de fazenda/campo de pouso privado/aeródromo militar sem\n  // codigo, que respondem à mesma query aeroway=aerodrome mas não são \"aeroporto\" no sentido do usuário.\n  {key:'airports',label:'Aeroportos',mode:'point',outMode:'center',color:'#000000',\n   ql:b=>`nwr[\"aeroway\"=\"aerodrome\"][\"iata\"](${b});`},\n];\nconst OSM_ON={motorway:false,trunk:false,primary:false,secondary:false,rivers:false,airports:false};\n// Contorno branco/colorido em volta de cada pedaço do que está fechando — ligado por padrão (era o\n// único comportamento antes deste toggle existir), desligar só tira o traço (stroke:none), nunca\n// muda preenchimento nem lógica de clique.\nlet SHOW_MUNI_OUTLINE=false;\nlet OSM_PANEL_OPEN=false; // page chrome only — deliberately not on SESS, never persisted/shared (mirrors OSM_ON/LEGEND_ON's own convention)\n// Legendas default ON — é o comportamento de sempre (rótulo em toda unidade/área de contexto);\n// os checkboxes só existem pra permitir DESLIGAR num mapa carregado, não pra ligar algo novo.\nconst LEGEND_ON={muni:false,ctx:false};\nconst CHILD_PLURAL={Município:'municípios',Setor:'setores',GV:'GVs'};\nconst OSM_CACHE={};\nconst OSM_MAX_SPAN=6; // graus — acima disso (fechamento de mercado muito grande) não busca sozinho\nfunction osmBboxOf(feats){\n  let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n    ps.forEach(poly=>poly.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; })));});\n  if(!isFinite(mnx)) return null;\n  return {south:mny,west:mnx,north:mxy,east:mxx};\n}\n// Calling overpass-api.de straight from the browser died with a bare \"Failed to fetch\" on the\n// operator's own network (proved: the exact same call succeeded from a different network) — a\n// corporate firewall/proxy blocking that third-party domain, most likely. Routing through Data2's own\n// Rush listener (osmOverpassProxy → HttpClient → overpass-api.de) moves the outbound hop to Data2's\n// backend, which never touches the operator's restricted network at all.\nasync function ensureOsmLayer(def,bbox,ck){\n  OSM_CACHE[ck]={status:'loading',data:null,error:null,attempt:0};\n  const b=bbox.south.toFixed(4)+','+bbox.west.toFixed(4)+','+bbox.north.toFixed(4)+','+bbox.east.toFixed(4);\n  const query='[out:json][timeout:25];('+def.ql(b)+');out'+(def.outMode==='center'?' center':' geom')+';';\n  const rec=OSM_CACHE[ck];\n  // The public Overpass instance fails under load often enough (504s, \"server busy\") that stopping\n  // after one try and forcing a manual re-click was more friction than the API itself — this keeps\n  // retrying with a capped backoff until it actually gets a response, instead of desmarking the\n  // checkbox(es) and giving up. stillWanted() is checked before AND after every attempt so a layer\n  // toggled off mid-retry stops hammering the server for something nobody wants anymore, rather than\n  // retrying forever in the background; drawOsmLayer's own `if(!rec)` guard is what stops this from\n  // ever running twice in parallel for the same bbox+layer, since `rec` stays present (status\n  // 'loading'/'retrying') for the whole life of this loop, never deleted on failure like before.\n  const stillWanted=()=>(def.key==='rodovias'?HIGHWAY_KEYS:[def.key]).some(k=>OSM_ON[k]);\n  for(let attempt=1;;attempt++){\n    if(!stillWanted()){ delete OSM_CACHE[ck]; return; }\n    rec.attempt=attempt; rec.status=attempt>1?'retrying':'loading';\n    try{\n      const res=await d2.rush('osmOverpassProxy').invoke({data:query});\n      const env=res&&res.data;\n      if(!env) throw new Error('proxy sem resposta');\n      if(!env.ok) throw new Error(env.error||'falha desconhecida no proxy');\n      rec.data=env.elements||[]; rec.status='ok';\n      drawMap();\n      return;\n    }catch(e){\n      rec.error=(e&&e.message)||'falha no proxy';\n      if(!stillWanted()){ delete OSM_CACHE[ck]; return; }\n      drawMap();\n      // Capped backoff (3s, 6s, 9s… up to 20s) — enough spacing to stay clear of the exact\n      // rate-limit pattern that already broke \"secondary\"/\"secondary_link\" once (see the comment\n      // above OSM_LAYERS), without making the operator wait an unreasonable amount between tries.\n      await new Promise(r=>setTimeout(r,Math.min(3000*attempt,20000)));\n    }\n  }\n}\n// ---- simplificação (RDP) + suavização (Catmull-Rom→Bézier) da geometria de via/rio do Overpass ----\n// Só pra linhas (rodovia/rio) — polígono de município/setor usa pathFor() e continua exato, sem\n// mexer, porque ali a fidelidade geométrica é o que importa (fronteira administrativa de verdade).\n// RDP_EPS em unidades do viewBox fixo (700x500 — ver comment em fitMapHeight/.map-wrap): pequeno o\n// bastante pra só remover ponto quase-colinear (zig-zag do GPS), não pra distorcer a curva real.\nconst RDP_EPS=0.6;\nfunction rdpSimplify(pts,eps){\n  if(pts.length<3) return pts;\n  const a=pts[0], b=pts[pts.length-1];\n  const dx=b[0]-a[0], dy=b[1]-a[1], len2=dx*dx+dy*dy;\n  let maxD=0,idx=0;\n  for(let i=1;i<pts.length-1;i++){\n    const p=pts[i];\n    let d;\n    if(len2===0){ d=Math.hypot(p[0]-a[0],p[1]-a[1]); }\n    else{\n      const t=((p[0]-a[0])*dx+(p[1]-a[1])*dy)/len2;\n      const px=a[0]+t*dx, py=a[1]+t*dy;\n      d=Math.hypot(p[0]-px,p[1]-py);\n    }\n    if(d>maxD){ maxD=d; idx=i; }\n  }\n  if(maxD>eps){\n    const left=rdpSimplify(pts.slice(0,idx+1),eps);\n    const right=rdpSimplify(pts.slice(idx),eps);\n    return left.slice(0,-1).concat(right);\n  }\n  return [a,b];\n}\n// Catmull-Rom → Bézier cúbica: passa exatamente pelos pontos simplificados (não aproxima, não corta\n// canto) mas troca o zig-zag reto do GPS por uma curva suave entre eles — o \"eles mentem sobre a\n// geometria\" descrito: pontos crus do OSM são só retas entre coordenadas, isto desenha uma curva de\n// verdade passando pelas mesmas coordenadas.\nfunction catmullRomPathD(pts){\n  if(pts.length<3) return 'M'+pts.map(p=>p[0].toFixed(1)+','+p[1].toFixed(1)).join('L');\n  let d=`M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;\n  for(let i=0;i<pts.length-1;i++){\n    const p0=pts[i-1]||pts[i], p1=pts[i], p2=pts[i+1], p3=pts[i+2]||p2;\n    const c1x=p1[0]+(p2[0]-p0[0])/6, c1y=p1[1]+(p2[1]-p0[1])/6;\n    const c2x=p2[0]-(p3[0]-p1[0])/6, c2y=p2[1]-(p3[1]-p1[1])/6;\n    d+=`C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;\n  }\n  return d;\n}\nfunction drawOsmLayer(svg,pr,feats,NS){\n  const st=$('#osmStatus');\n  const rodoviasOn=HIGHWAY_KEYS.some(k=>OSM_ON[k]);\n  const active=OSM_LAYERS.filter(l=>l.key==='rodovias'?rodoviasOn:OSM_ON[l.key]);\n  if(!active.length){ if(st) st.textContent=''; return; }\n  const bbox=osmBboxOf(feats); if(!bbox){ if(st) st.textContent=''; return; }\n  const tooBig=(bbox.north-bbox.south)>OSM_MAX_SPAN||(bbox.east-bbox.west)>OSM_MAX_SPAN;\n  const msgs=[];\n  active.forEach(def=>{\n    if(tooBig){ msgs.push(def.label+': área grande demais p/ carregar automaticamente'); return; }\n    const bk=bbox.south.toFixed(3)+'_'+bbox.west.toFixed(3)+'_'+bbox.north.toFixed(3)+'_'+bbox.east.toFixed(3);\n    const ck=def.key+'|'+bk;\n    let rec=OSM_CACHE[ck];\n    if(!rec){ ensureOsmLayer(def,bbox,ck); rec=OSM_CACHE[ck]; }\n    if(rec.status==='loading') msgs.push(def.label+': carregando…');\n    else if(rec.status==='retrying') msgs.push(def.label+': tentativa '+(rec.attempt||1)+'… (Overpass instável, repetindo automaticamente — última falha: '+(rec.error||'?')+')');\n    if(rec.status!=='ok') return;\n    if(def.mode==='point'){\n      // Localização, não contorno: nó usa lat/lon direto; way/relation usa o center que \"out\n      // center\" já calculou — nenhum dos dois carrega el.geometry, então essa camada nunca cai no\n      // desenho de linha abaixo.\n      const g=document.createElementNS(NS,'g');\n      rec.data.forEach(el=>{\n        const pt=el.type==='node'?{lon:el.lon,lat:el.lat}:(el.center?{lon:el.center.lon,lat:el.center.lat}:null);\n        if(!pt) return;\n        const q=pr([pt.lon,pt.lat]);\n        // Ícone de avião (glifo ✈, não um ponto azul cheio) — halo branco (paintOrder:stroke) pro símbolo\n        // ler em cima de qualquer cor de fundo do mapa, igual o halo já usado nos labels de município.\n        const ic=document.createElementNS(NS,'text');\n        ic.setAttribute('x',q[0].toFixed(1)); ic.setAttribute('y',q[1].toFixed(1));\n        ic.setAttribute('text-anchor','middle'); ic.setAttribute('dominant-baseline','central');\n        ic.setAttribute('font-size','13'); ic.setAttribute('fill',def.color);\n        ic.style.cursor='help'; ic.style.userSelect='none';\n        ic.textContent='✈';\n        const nm=(el.tags&&(el.tags.name||el.tags['name:pt']))||'Aeroporto';\n        ic.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';t.innerHTML=`<b>${esc(nm)}</b>`;};\n        ic.onmouseleave=()=>{$('#tip').style.opacity=0;};\n        g.appendChild(ic);\n      });\n      svg.appendChild(g);\n      return;\n    }\n    const g=document.createElementNS(NS,'g'); g.style.pointerEvents='none';\n    // Empilha do menos importante pro mais importante ANTES de desenhar — sem isto o Overpass devolve\n    // as vias em ordem arbitrária e uma via secundária pode acabar desenhada por cima de uma rodovia\n    // maior (o \"layer cake\" errado). Camadas sem subclasse (rios) mantêm a ordem que vieram.\n    const dataToRender=def.key==='rodovias'\n      ?[...rec.data].sort((a,b)=>(HIGHWAY_RANK[HIGHWAY_TAG_TO_KEY[a.tags&&a.tags.highway]]??-1)-(HIGHWAY_RANK[HIGHWAY_TAG_TO_KEY[b.tags&&b.tags.highway]]??-1))\n      :rec.data;\n    dataToRender.forEach(el=>{\n      if(!el.geometry||!el.geometry.length) return;\n      // Rodovias vem como UMA resposta com as 8 subclasses misturadas — cada trecho só desenha se\n      // a subclasse dele (el.tags.highway) estiver ligada agora; rios continua usando o próprio def.\n      let color,sw;\n      if(def.key==='rodovias'){\n        const hk=HIGHWAY_TAG_TO_KEY[el.tags&&el.tags.highway];\n        if(!hk||!OSM_ON[hk]) return;\n        const m=HIGHWAY_META[hk]; color=m.color; sw=m.sw;\n      } else { color=def.color; sw=def.sw; }\n      const rawPts=el.geometry.filter(Boolean).map(p=>pr([p.lon,p.lat]));\n      const d=catmullRomPathD(rdpSimplify(rawPts,RDP_EPS));\n      // Largura fixa em unidades do viewBox, sem contra-escala nenhuma (nada de data-sw) — estrada é\n      // geografia: encolhe sozinha quando o zoom mostra uma área enorme de uma vez (menos zoom =\n      // linha mais fina) e cresce normalmente ao aproximar, exatamente como o resto do mapa.\n      // Halo (contorno claro por baixo do traço real) só pra rios agora — em rodovia ficava lendo como\n      // um traço tracejado/serrilhado em vez de um contorno limpo, pedido explícito pra tirar.\n      if(def.key!=='rodovias'){\n        const halo=document.createElementNS(NS,'path'); halo.setAttribute('d',d); halo.setAttribute('fill','none');\n        halo.setAttribute('stroke','#fff'); halo.setAttribute('stroke-width',sw+1.3);\n        halo.style.strokeLinejoin='round'; halo.style.strokeLinecap='round';\n        g.appendChild(halo);\n      }\n      const p=document.createElementNS(NS,'path'); p.setAttribute('d',d); p.setAttribute('fill','none');\n      p.setAttribute('stroke',color); p.setAttribute('opacity', def.key==='rodovias' ? '1' : '0.72');\n      p.setAttribute('stroke-width',sw);\n      p.style.strokeLinejoin='round'; p.style.strokeLinecap='round';\n      g.appendChild(p);\n    });\n    svg.appendChild(g);\n  });\n  if(st) st.textContent=msgs.join(' · ');\n}\n// Mede o espaço de viewport que sobra abaixo do topo do card do mapa e usa isso como altura —\n// não um percentual fixo — porque o que existe ACIMA do mapa varia (trilho de etapas e toolbar só\n// aparecem quando a sessão já tem movimentações), então só medir de verdade para exatamente na\n// borda da tela em qualquer estado.\nfunction fitMapHeight(){\n  const wrap=$('.map-wrap'); if(!wrap) return;\n  const top=wrap.getBoundingClientRect().top;\n  if(top<=0) return;\n  // Margem generosa de propósito: sobra um pedaço visível do card seguinte (líderes/matriz) na\n  // borda inferior da tela, como sinal de que tem mais conteúdo abaixo pra rolar.\n  const margin=80;\n  wrap.style.height=Math.max(220, window.innerHeight-top-margin)+'px';\n}\nfunction drawMap(){\n  const mv=SESS.timeline[SESS.selected]; const svg=$('#map'); const ignNote=$('#mapIgnoredNote');\n  if(!mv){ svg.innerHTML=''; if(ignNote) ignNote.innerHTML=''; return; }\n  const level=mv.level||'setor';\n  const legendMuniLbl=$('#legendMuniLbl');\n  if(legendMuniLbl) legendMuniLbl.textContent = level==='setor' ? 'Nome de municípios' : `Código de ${CHILD_PLURAL[LEVELS[level].childLabel]||LEVELS[level].childLabel}`;\n  const muniOutlineLbl=$('#muniOutlineLbl');\n  if(muniOutlineLbl) muniOutlineLbl.textContent = `Contorno de ${level==='setor'?'municípios':(CHILD_PLURAL[LEVELS[level].childLabel]||LEVELS[level].childLabel)}`;\n  // GV/mercado fechando: o contorno de território agora é SEMPRE desenhado (unit-outline, mais\n  // abaixo em drawMap) — não sobra nada pra este checkbox ligar/desligar nesse nível, então ele\n  // some da toolbar em vez de virar um toggle morto que não muda mais nada na tela.\n  const muniOutlineRowEl=$('#muniOutline') && $('#muniOutline').closest('label');\n  if(muniOutlineRowEl) muniOutlineRowEl.classList.toggle('hidden', level!=='setor');\n  // setor fecha: a unidade É o município. GV/mercado fecham: cada município herda a atribuição do\n  // setor/GV a que pertence — municípios do mesmo setor sempre têm a MESMA cor e giram JUNTOS no\n  // clique, porque é o setor inteiro (não o município) que troca de dono nesses níveis.\n  const unitAssign=m=>level==='setor'?mv.assign[m]:mv.assign[(mv.unitOfMuni||{})[m]];\n  const c=computeDerived(mv);const NS='http://www.w3.org/2000/svg'; svg.innerHTML='';\n  const feats=[...mv.nbrFeat,...Object.values(mv.geoFeat)]; if(!feats.length) return;\n  // Hatch overlay flags a município whose full CSV sector list has >1 real sector, independent of\n  // how many of those sectors qualify as a valid destination target right now (isValidCtxTarget,\n  // below) — a município can collapse to one solid colour when only one owner qualifies, or to the\n  // flat __MULTI_NOVALID__ tone when none do, and either way the other real sectors it holds would\n  // otherwise be invisible. Dim by design (stroke-opacity .15): a hint to check the hover, not a\n  // second layer of colour competing with the destination fill.\n  const hatch=document.createElementNS(NS,'pattern');\n  hatch.setAttribute('id','multiSectorHatch'); hatch.setAttribute('width','7'); hatch.setAttribute('height','7');\n  hatch.setAttribute('patternUnits','userSpaceOnUse'); hatch.setAttribute('patternTransform','rotate(45)');\n  const hatchLine=document.createElementNS(NS,'line');\n  hatchLine.setAttribute('x1','0'); hatchLine.setAttribute('y1','0'); hatchLine.setAttribute('x2','0'); hatchLine.setAttribute('y2','7');\n  hatchLine.setAttribute('stroke','#1c1c1e'); hatchLine.setAttribute('stroke-width','0.6'); hatchLine.setAttribute('stroke-opacity','0.15');\n  hatch.appendChild(hatchLine);\n  const defs=document.createElementNS(NS,'defs'); defs.appendChild(hatch); svg.appendChild(defs);\n  syncMapDims();\n  const pr=project(feats);\n  const world=foldWorldState(SESS.timeline,SESS.selected);\n  // The context layer is ONE area per SECTOR, not one shape per município. The unit of this decision\n  // is the destination sector; a scatter of individually outlined municipalities carrying their own\n  // names invited exactly the wrong reading of the map. Members of a sector are filled AND stroked in\n  // the same colour, so the shared borders vanish and the group reads as a single dissolved\n  // territory — the IBGE shapes do not share vertices precisely enough to extract a clean union\n  // outline, and carrying a real polygon-clipping union for a context layer is not worth it.\n  // Labels are COLLECTED here and drawn last, after every path. Drawn inline they were buried by the\n  // next sector's fill, and placement cannot avoid a collision it has not seen yet.\n  const labels=[];\n  // Um destino \"de verdade\" pra este município compartilhado não exige ter sobrevivido ao corte de\n  // 6 do mv.dest — qualquer setor adjacente + da mesma GV/mercado + ainda não fechado nesta sessão já\n  // conta como candidato válido aqui (mv.validCandidates, a mesma malha usada como plainCandidates em\n  // computeMovement, sem o corte de top-6). Só se aplica a fechamento de setor — é aí que existe a\n  // noção de \"candidato descoberto por adjacência geográfica\"; GV/mercado mantém o comportamento\n  // anterior (todo setor real do município vira uma fatia, sem filtro de validade).\n  const isValidCtxTarget=s=>!!s&&(mv.dest.includes(s)||(mv.validCandidates&&mv.validCandidates.has(s)));\n  // Existence/closed-state at whatever grain THIS closure operates at — setor reads the live CRM\n  // sector list (as before); GV/mercado read the same dominantes-CSV hierarchy maps everything else\n  // at this level already trusts, since there is no live CRM listing of GVs/mercados to check against.\n  const levelExists=s=>level==='setor'?SESS.sectorsList.some(x=>x.setor===s)\n    :(level==='gv'?!!(DOM.sectorsByGv&&DOM.sectorsByGv[s]):!!(DOM.sectorsByMercado&&DOM.sectorsByMercado[s]));\n  const levelClosedSet=level==='setor'?world.closedSectors:(level==='gv'?world.closedGvs:world.closedMercados);\n  const ctxInvalidReason=s=>{\n    const lvl=LEVELS[level], fem=lvl.de==='da';\n    if(!levelExists(s)) return `${lvl.label} ${s} não existe mais na base atual.`;\n    if(levelClosedSet&&levelClosedSet.has(s)) return `${lvl.label} ${s} já foi fechad${fem?'a':'o'} em uma etapa anterior desta sessão.`;\n    if(lvl.parentLabel){\n      const p=parentOf(level,s);\n      if(mv.gv && p!==mv.gv) return `${lvl.label} ${s} é de outr${lvl.parentWord==='gerência'?'a':'o'} ${lvl.parentWord} (${p||'?'}) — a ${lvl.parentWord} d${fem?'a':'o'} ${lc(lvl.label)} fechando é ${mv.gv||'?'}, então não entra como destino.`;\n    }\n    return `${lvl.label} ${s} não faz fronteira com nenhum ${lc(lvl.childLabel)} d${lvl.de} ${lc(lvl.label)} ${mv.sector} sendo fechad${fem?'a':'o'}.`;\n  };\n  // A sibling sharing the CLOSING unit's own território (mv.sharedMuniSlices — São Paulo etc., setor\n  // level only today) is adjacent by definition, literally the same território — isValidCtxTarget's\n  // geographic-vote requirement (mv.dest/mv.validCandidates, built from OTHER municípios bordering the\n  // closing one) never applies to it, and wrongly failed every sibling that hadn't separately won that\n  // vote elsewhere. Same bar used everywhere else at this level: exists, not already closed, same\n  // parent as the unit closing — no geographic step, because none of it means anything for a\n  // co-located setor/GV.\n  const isValidSiblingTarget=s=>!!s&&levelExists(s)&&!(levelClosedSet&&levelClosedSet.has(s))&&parentOf(level,s)===mv.gv;\n  const muniTipHTML=(nm,owners)=>{\n    const lvl=LEVELS[level], fem=lvl.de==='da';\n    const destPl={Setor:'setores',GV:'GVs',Mercado:'mercados'}[lvl.destLabel]||lc(lvl.destLabel)+'s';\n    const siblingCtx=owners.includes(mv.sector);\n    const validFn=siblingCtx?isValidSiblingTarget:isValidCtxTarget;\n    const rowHtml=o=>o===mv.sector\n      ?`<div>● <b>${o}</b> — sendo fechad${fem?'a':'o'} nesta etapa</div>`\n      :validFn(o)\n        ?`<div>✓ <b>${o}</b> — destino válido</div>`\n        :`<div style=\"opacity:.8\">✕ ${o} — ${ctxInvalidReason(o)}</div>`;\n    // ✓/● (actionable) float to the top, ✕ (not a real option) sinks to the bottom — a município\n    // with dozens of real owners (São Paulo etc.) otherwise buries the handful that matter under a\n    // wall of ✕ reasons in numeric order. Stable sort keeps each group in its original numeric order.\n    const rows=[...owners].sort((a,b)=>{\n      const va=a===mv.sector||validFn(a), vb=b===mv.sector||validFn(b);\n      return va===vb?0:(va?-1:1);\n    }).map(rowHtml).join('');\n    return `<div style=\"margin-top:6px;padding-top:6px;border-top:1px solid rgba(255,255,255,.25)\"><b>${mDisp(nm)}</b> — ${owners.length} ${destPl} neste município:${rows}</div>`;\n  };\n  const nbrBySec={};\n  // Border geometry for a genuinely multi-owner município is drawn ONCE, after every fill, from the\n  // município's FULL original shape — never from a per-slice fragment. sliceFeatureEqual band-cuts a\n  // real (often concave) shape into straight-edged pieces, and a real município can split into SEVERAL\n  // disjoint ring fragments per band; tracing every one of those individually produced a blocky,\n  // jagged mess instead of one clean outline. Keyed by município (nm) so it draws exactly once even\n  // though the município itself may contribute several fs entries across different destination keys.\n  const multiBorderFeats={};\n  mv.nbrFeat.forEach(f=>{\n    const nm=mkey(f.properties.name||f.properties.NOME||f.properties.nome||'',f.__uf);\n    // A município's real sector count is a CSV fact (DOM.setoresByMuni), never scoped to whichever\n    // destinations happen to be live candidates for THIS closure — mv.muniOwners only tracks the\n    // latter (top mv.dest picks), so a real multi-sector município whose other sectors lost the vote\n    // rendered as one flat colour instead of striped. Same source of truth mv.sharedMuniSlices\n    // already uses for the closing-sector side of this exact problem.\n    const csvOwners=DOM.setoresByMuni&&DOM.setoresByMuni[nm];\n    const owners=(csvOwners&&csvOwners.size?[...csvOwners]:(mv.muniOwners&&mv.muniOwners[nm]?[...new Set(mv.muniOwners[nm])]:[])).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n    if(owners.length>1){\n      // Real ownership is always recorded at SETOR grain (owners, above) — a GV/mercado closure has\n      // to judge \"does this município actually split HERE\" at ITS OWN grain, not the raw setor one:\n      // translate every real setor owner to its GV/mercado (destUnitOfSector — world-aware, the same\n      // resolver the single-owner fallback below already relies on for cascade/override correctness)\n      // and dedupe. Two setores owning a município that both roll up into the SAME GV are not a split\n      // at the GV level — only genuinely different level-owners earn their own stripe. At setor level\n      // this is the identity (no translation), so setor-closing behaviour is unchanged.\n      // A level-owner already closed in an earlier etapa of THIS session has been absorbed into\n      // whatever it closed into — it is not a live, separate owner of this município anymore, so it\n      // must never count toward \"does this município still split\" nor show up in the tooltip list.\n      // Filtered here (once, before the >1 check) rather than downstream: dropping a closed owner\n      // can legitimately collapse a real 2-owner município down to a plain single-owner one, which\n      // is exactly the fallthrough this function already handles below.\n      const levelOwners = (level==='setor' ? owners\n        : [...new Set(owners.map(so=>destUnitOfSector(level,so,world)).filter(Boolean))])\n            .filter(u=>!(levelClosedSet&&levelClosedSet.has(u)))\n            .sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      if(levelOwners.length>1){\n        multiBorderFeats[nm]=f;\n        // Only a VALID target earns its own stripe: a level-owner that's cross-parent, already closed,\n        // or not actually adjacent to this closure is treated as if it were not a real owner at all,\n        // and its share of the shape goes to the valid ones instead. Zero valid owners still stripes\n        // (so \"more than one owner here\" survives) but with one flat neutral tone, since there is no\n        // real destination colour to award any of them.\n        const valid=levelOwners.filter(isValidCtxTarget);\n        const sliceOwners=valid.length?valid:levelOwners;\n        // Zero valid owners: slice count is 1 (no cut at all), not levelOwners.length — there is no\n        // real destination colour to award any of the pieces, so a bbox-band cut here only carves the\n        // shape into arbitrary straight-edged fragments with nothing meaningful to show for it.\n        sliceFeatureEqual(f,valid.length?sliceOwners.length:1).forEach((slice,i)=>{\n          if(slice.__empty) return;\n          const key=valid.length?sliceOwners[i]:'__MULTI_NOVALID__';\n          (nbrBySec[key]=nbrBySec[key]||[]).push({feature:slice,muni:nm,owners:levelOwners});\n        });\n        return;\n      }\n      // Every real setor under this município rolled up into the same level-owner (or none) — no\n      // genuine split to show at this grain, so it falls through to the plain single-owner path below.\n    }\n    const s=String((mv.nbrCurrentSector&&mv.nbrCurrentSector[nm])||'');\n    (nbrBySec[s]=nbrBySec[s]||[]).push({feature:f,muni:nm});\n  });\n  // Same \"only a valid target earns its own stripe\" rule as the neighbour branch above — a sibling\n  // sector sharing this município with the one closing (São Paulo etc.) does not get its own wedge\n  // just for existing in the CSV; it needs to actually be a live destination candidate too. Grouped\n  // under the SAME '__MULTI_NOVALID__' bucket so an invalid sibling reads as flat neutral context\n  // instead of an arbitrary colour that looks like a real, selectable destination.\n  (mv.sharedMuniSlices||[]).forEach(sl=>{\n    const csvOwners=DOM.setoresByMuni&&DOM.setoresByMuni[sl.muni];\n    // Same closed-owner exclusion as the neighbour loop above — a sibling already closed earlier\n    // this session is gone, not just an invalid pick, so it drops out of the owner list entirely.\n    const owners=(csvOwners&&csvOwners.size?[...csvOwners]:[mv.sector,sl.sector])\n      .filter(o=>!(levelClosedSet&&levelClosedSet.has(o)))\n      .sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n    const key=isValidSiblingTarget(sl.sector)?sl.sector:'__MULTI_NOVALID__';\n    (nbrBySec[key]=nbrBySec[key]||[]).push({feature:sl.feature,muni:sl.muni,owners});\n  });\n  // A município shared with the CLOSING sector itself (São Paulo etc., not just a bordering\n  // neighbour) never passes through the mv.nbrFeat loop above (where multiBorderFeats is normally\n  // populated). This used to be rebuilt by concatenating mv.geoFeat's own slice with every\n  // mv.sharedMuniSlices piece into one MultiPolygon and stroking that — but sliceFeatureEqual\n  // band-cuts a município into straight fragments with no real polygon union, so stroking that\n  // naive concatenation traced every INTERNAL cut too, not just the true external edge (the stray\n  // straight gray lines cutting across a município like São Paulo). mv.muniFullShape[nm] — stashed\n  // during slicing, above, BEFORE mv.geoFeat[n] gets overwritten with just this sector's own slice —\n  // is the real, un-cut original polygon, so this draws exactly the same clean single-feature\n  // outline a bordering-neighbour shared município already gets, with none of the internal seams.\n  Object.keys(mv.muniFullShape||{}).forEach(nm=>{ if(!multiBorderFeats[nm]) multiBorderFeats[nm]=mv.muniFullShape[nm]; });\n  Object.keys(nbrBySec).forEach(s=>{\n    const fs=nbrBySec[s];\n    const isNovalid=s==='__MULTI_NOVALID__';\n    const isDest=!isNovalid&&!!s&&mv.dest.includes(s);\n    const isRejected=!isNovalid&&!!s&&mv.rejectedCodes&&mv.rejectedCodes.has(s);\n    const isUnresolved=!isNovalid&&!s;\n    // Um município com um código de setor real — mesmo não sendo candidato de destino NESTA etapa,\n    // mesmo sendo um setor já fechado numa etapa anterior — não deixa de existir nem de ter um\n    // destino de verdade; ele só não é candidato AGORA. Cor estável troca o cinza \"sem info\" por essa\n    // identidade real. Só rejected (lado errado da hierarquia), unresolved (sem shape/sem dado nenhum)\n    // e novalid (nenhum setor real deste município qualifica como destino) continuam sem cor de\n    // verdade — esses não têm \"pra onde foi\".\n    const col=isNovalid?null:(isDest?(mv.color[s]||null):(!isRejected&&!isUnresolved?colorForSector(s):null));\n    const rej=isRejected?(mv.rejectedInfo&&mv.rejectedInfo[s]):null;\n    // Sem união de polígono de verdade, a única forma de emular UM contorno em volta do setor\n    // inteiro é desenhar essa borda ANTES do preenchimento: cada aresta INTERNA (entre dois membros\n    // do mesmo setor) fica coberta pelo preenchimento opaco do vizinho por cima, e só a aresta\n    // EXTERNA (sem vizinho do mesmo setor do outro lado) continua visível — e, se dois setores\n    // \"inválidos\" diferentes se tocam, cada um desenha sua própria borda naquele limite, então a\n    // linha aparece do mesmo jeito, sem sumir.\n    if(isRejected) fs.forEach(fw=>{\n      const bp=document.createElementNS(NS,'path');\n      bp.setAttribute('d',pathFor(fw.feature,pr));\n      bp.setAttribute('class','sect-rejected-border');\n      svg.appendChild(bp);\n    });\n    fs.forEach(fw=>{\n      const f=fw.feature;\n      const p=document.createElementNS(NS,'path');\n      p.setAttribute('d',pathFor(f,pr));\n      // Stashed so discard-paint (further down) can re-slice the EXACT geometry this hitbox shows —\n      // for a município shared across several real sectors, `f` here is already just ONE owner's\n      // slice of the full shape, not the whole município; re-slicing the full shape instead of this\n      // would carve alternative-divisions bigger than the piece actually on screen, spilling the\n      // divisor lines out past this piece's real edge into a neighbouring owner's area.\n      p._geoFeature=f;\n      p.setAttribute('class','sect'+(isRejected?' rejected':(isUnresolved?' unresolved':(isNovalid?' plain':''))));\n      // Stamped so an external trigger (the span-rule fix card's hover, see moveWork) can look this\n      // piece up by município identity and toggle .munihl on it — same class the GV/mercado\n      // unit-hover group already uses, just driven from outside the map instead of from its own\n      // mouse events. Not every fw carries a muni (only pieces that came through the single- or\n      // multi-owner branches above do; a bare context feature with no CSV owner has none).\n      if(fw.muni) p.setAttribute('data-muni',fw.muni);\n      // Transfer-movement stage: highlight the município being retargeted permanently (not just on\n      // hover, unlike the fix-card's setPlanHl reuse of this same .munihl class) and let a click cycle\n      // its target sector — mv.view (not the not-yet-declared `asis` local) since this runs before that\n      // const is assigned further down this function.\n      if(mv.kind==='transfer' && fw.muni===mv.xferMuni){\n        p.classList.add('munihl');\n        if(mv.view!=='asis'){ p.style.cursor='pointer'; p.onclick=()=>cycleTransferTarget(); }\n      }\n      // isDest: NO stroke at all, fill only. Matching stroke to fill (even at the same opacity)\n      // still doubles up at every shared município edge — two adjacent members each paint their own\n      // translucent stroke on top of the shared line, so it composites darker than the interior even\n      // when both strokes are the exact fill colour. Only an absent stroke truly stops compositing.\n      if(col){ p.setAttribute('fill',col); }\n      const tipParts=[];\n      if(rej){\n        tipParts.push(level==='setor'\n          ? `${LEVELS.setor.destLabel} ${s} é da GV ${rej.parent||'?'} — a GV do setor fechando é a ${mv.gv||'?'}, então não entra como destino.`\n          : `${LEVELS.gv.destLabel} ${s} é do mercado ${rej.parent||'?'} — o mercado da GV fechando é o ${mv.gv||'?'}, então não entra como destino.`);\n      }\n      if(isUnresolved){\n        // Setor não encontrado pro município vizinho — mostra o NOME real do município no hover em vez\n        // de deixar em branco ou fingir um destino. Estado de debug de propósito: ajuda a achar rápido\n        // qual município ficou sem sector resolvido na base/consulta ao vivo.\n        const nmDisp=mDisp(mkey(f.properties.name||f.properties.NOME||f.properties.nome||'',f.__uf));\n        tipParts.push(`<b>${nmDisp}</b><br><span style=\"color:var(--mut)\">setor não encontrado — sem CBs neste snapshot ou não bateu com a base</span>`);\n      }\n      // Município com mais de um setor real: lista todos, válidos e não-válidos (com o motivo), numa\n      // seção própria do tooltip — mesmo quando este slice específico já tem um tooltip por outro\n      // motivo (rejected/unresolved), a lista fica anexada embaixo.\n      if(fw.owners&&fw.owners.length>1) tipParts.push(muniTipHTML(fw.muni,fw.owners));\n      if(tipParts.length){\n        p.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';t.innerHTML=tipParts.join('');};\n        p.onmouseleave=()=>$('#tip').style.opacity=0;\n      }\n      svg.appendChild(p);\n      if(fw.owners&&fw.owners.length>1){\n        const hp=document.createElementNS(NS,'path');\n        hp.setAttribute('d',pathFor(f,pr));\n        hp.setAttribute('fill','url(#multiSectorHatch)');\n        hp.style.pointerEvents='none';\n        svg.appendChild(hp);\n      }\n    });\n    if(!s||isNovalid) return;\n    // One number per sector, at the pole of inaccessibility of its DISSOLVED area — the whole group\n    // of members at once, not a representative member, so the number sits in the visual middle of the\n    // territory the operator actually sees.\n    const lp=labelPointOf(mv,'s:'+s,fs.map(fw=>fw.feature),pr);\n    // Context areas always read LIGHT — .sect paints dest colours at fill-opacity .32 over white, and\n    // plain/closed-ctx are near-white greys — so a sector number never wants white ink, whatever colour\n    // the sector itself was assigned.\n    // A leader line is only worth drawing when there's real room to write into — otherwise a big\n    // multi-sector município (São Paulo etc., dozens of slivers) turns into a canvas-wide tangle of\n    // lines, one per sliver too thin to ever hold readable text. lp.r is the radius of the biggest\n    // circle that fits inside the slice; below this it isn't worth labelling at all.\n    if(LEGEND_ON.ctx && lp&&lp.p&&lp.r>=5) labels.push({anchor:lp.p,big:true,lines:[{t:s,size:11.5,weight:700}]});\n  });\n  const asis=mv.view==='asis';\n  if(level==='setor'){\n    // Setor fechando: a UNIDADE é o próprio município — cada um se comporta, colore e rotula\n    // individualmente, exatamente como sempre foi.\n    Object.entries(mv.geoFeat).forEach(([m,f])=>{const p=document.createElementNS(NS,'path');p.setAttribute('d',pathFor(f,pr));\n      const fillCol=asis?CLOSE_COLOR:(mv.color[unitAssign(m)]||'#444');\n      p.setAttribute('class','muni');p.setAttribute('fill',fillCol);\n      if(!SHOW_MUNI_OUTLINE) p.style.stroke='none';\n      if(!asis) p.onclick=()=>{editSelected(m2=>{if(m2.dest.length)m2.assign[m]=(SESS.settings&&SESS.settings.forceContiguity)?nextContigSafeDest(m2,m):m2.dest[(m2.dest.indexOf(m2.assign[m])+1)%m2.dest.length];m2.selMuni=m;});};\n      p.style.cursor=asis?'default':'pointer';\n      p.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';\n        t.innerHTML=asis?`<b>${mDisp(m)}</b><br>${fmt(mv.cbsByMuni[m]||0)} CBs · setor ${mv.sector}`:`<b>${mDisp(m)}</b><br>${fmt(mv.cbsByMuni[m]||0)} CBs → ${unitAssign(m)||'–'}<br><span style=\"color:var(--mut)\">clique p/ trocar</span>`;};\n      p.onmouseleave=()=>$('#tip').style.opacity=0;svg.appendChild(p);\n      const lp=labelPointOf(mv,'m:'+m,[f],pr);\n      const at=(lp&&lp.p)||cxy(f,pr);\n      const nmFull=(mUf(m)?mUf(m)+' - ':'')+mName(m);\n      if(LEGEND_ON.muni) labels.push({anchor:at,cbs:+(mv.cbsByMuni[m]||0),\n        lines:[{t:nmFull,size:8.2,weight:700}]});\n    });\n  } else {\n    // GV/mercado fechando: a UNIDADE é o setor (ou a GV) inteiro, nunca o município isolado — vários\n    // municípios do MESMO setor têm de ler como UMA área só (stroke = fill inline, opaco, então não há\n    // o artefato de dupla-composição da camada de destino: aqui a cor é sólida, não translúcida).\n    // A fronteira que o operador precisa ver é setor-a-setor, não município-a-município, e isso já\n    // sai de graça da própria cor: setores com destinos diferentes contrastam sem precisar de traço\n    // algum. O único caso que fica sem contorno é dois setores vizinhos caindo no MESMO destino — sem\n    // union real de polígono (ver nota da camada de contexto, acima) não há como bordear só o setor\n    // sem também bordear cada município dentro dele, e a segunda opção é a bagunça pontilhada que essa\n    // troca veio corrigir. UM rótulo só por unidade, no ponto de inacessibilidade da área do grupo,\n    // nunca um rótulo por município.\n    Object.entries(mv.unitMunis||{}).forEach(([u,ms])=>{\n      const fillCol=asis?CLOSE_COLOR:(mv.color[mv.assign[u]]||'#444');\n      const feats=ms.map(m=>mv.geoFeat[m]).filter(Boolean);\n      // Sector-territory outline: ONE outline around this unit's whole dissolved territory, drawn\n      // before any fill of this unit — see the .unit-outline comment (CSS, above) for why order\n      // matters here. Always on for GV/mercado closing (not gated by SHOW_MUNI_OUTLINE, unlike the\n      // município-level toggle used at setor level) — telling one sector's territory apart from its\n      // neighbour's is the whole point of this view, not an optional extra the operator opts into.\n      ms.forEach(m=>{\n        const f=mv.geoFeat[m]; if(!f) return;\n        const bp=document.createElementNS(NS,'path');\n        bp.setAttribute('d',pathFor(f,pr));\n        bp.setAttribute('class','unit-outline');\n        svg.appendChild(bp);\n      });\n      // Todo município deste u é UMA unidade só na tela — passar o mouse sobre qualquer pedaço tem\n      // que acender o grupo inteiro, não só o pedaço que o cursor está tocando. :hover no CSS só\n      // sabe acender o próprio <path>; o grupo inteiro precisa de JS guardando os irmãos.\n      const els=[];\n      ms.forEach(m=>{\n        const f=mv.geoFeat[m]; if(!f) return;\n        const p=document.createElementNS(NS,'path');p.setAttribute('d',pathFor(f,pr));\n        // stroke SEMPRE igual ao fill (nunca 'none', mesmo com o toggle desligado) — dois\n        // municípios opacos da MESMA unidade ainda rasterizam com uma costura fina de antialiasing\n        // na borda compartilhada (mesmo fix já aplicado em .sect.plain/.sect.rejected); só um\n        // stroke da cor exata do fill fecha essa costura, o que é o que faz cada município desta\n        // unidade ler como UM território dissolvido só, em vez de uma malha de municípios\n        // individualmente contornados — exatamente o bug que motivou este comentário.\n        p.setAttribute('class','muni');p.setAttribute('fill',fillCol);p.style.stroke=fillCol;\n        els.push(p);\n        if(!asis) p.onclick=()=>{editSelected(m2=>{if(m2.dest.length)m2.assign[u]=(SESS.settings&&SESS.settings.forceContiguity)?nextContigSafeDest(m2,u):m2.dest[(m2.dest.indexOf(m2.assign[u])+1)%m2.dest.length];m2.selMuni=u;});};\n        p.style.cursor=asis?'default':'pointer';\n        p.onmouseenter=()=>{ els.forEach(e=>e.classList.add('munihl')); };\n        p.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';\n          t.innerHTML=asis?`<b>${LEVELS[level].childLabel} ${u}</b><br>${fmt(mv.munis[u]?.cbs||0)} CBs · ${LEVELS[level].label} ${mv.sector}`:`<b>${LEVELS[level].childLabel} ${u}</b><br>${fmt(mv.munis[u]?.cbs||0)} CBs → ${mv.assign[u]||'–'}<br><span style=\"color:var(--mut)\">clique p/ trocar</span>`;};\n        p.onmouseleave=()=>{ els.forEach(e=>e.classList.remove('munihl')); $('#tip').style.opacity=0; };svg.appendChild(p);\n      });\n      const lp=labelPointOf(mv,'u:'+u,feats,pr);\n      const at=(lp&&lp.p)||(feats[0]&&cxy(feats[0],pr));\n      if(at && LEGEND_ON.muni){\n        labels.push({anchor:at,big:true,cbs:+(mv.munis[u]?.cbs||0),\n          lines:[{t:u,size:9,weight:700}]});\n      }\n    });\n  }\n  // One clean outline per genuinely multi-owner município, from its FULL original shape — see the\n  // comment where multiBorderFeats is built (above, in the population loop) for why this can't be\n  // done per-slice-fragment. Drawn LAST, after every fill (including the closing sector's own\n  // município fills above) — the shared município's own slice is opaque and painted on top of\n  // whatever came before it, so a border drawn earlier than that fill got silently erased along the\n  // stretch of perimeter running through the closing sector's own piece.\n  Object.values(multiBorderFeats).forEach(feat=>{\n    const mb=document.createElementNS(NS,'path');\n    mb.setAttribute('d',pathFor(feat,pr));\n    mb.setAttribute('class','sect-multi-border');\n    svg.appendChild(mb);\n  });\n  // Every label is drawn directly at its own labelPointOf anchor — already the point of maximum\n  // clearance inside its território — instead of hunting for open canvas space. No leader lines, no\n  // shrinking, no dropped labels: a name always sits on the shape it belongs to.\n  drawOsmLayer(svg,pr,feats,NS);\n  labels.forEach(L=>{\n    const g=document.createElementNS(NS,'g'); g.setAttribute('class','lbl'+(L.big?' big':''));\n    const hTot=L.lines.reduce((a,l)=>a+l.size*1.2,0);\n    let ty=L.anchor[1]-hTot/2+L.lines[0].size*0.86;\n    const ink='#fff', inkMut='rgba(255,255,255,.85)';\n    const halo='rgba(0,0,0,.72)';\n    const swBase=L.big?2.4:1.8;\n    L.lines.forEach(l=>{ const t=document.createElementNS(NS,'text');\n      t.setAttribute('x',L.anchor[0].toFixed(1)); t.setAttribute('y',ty.toFixed(1));\n      t.setAttribute('font-size',l.size); t.setAttribute('data-fs',l.size); t.setAttribute('font-weight',l.weight||600);\n      t.setAttribute('data-sw',swBase); t.style.strokeWidth=swBase+'px';\n      t.style.fill=l.mut?inkMut:ink; t.style.stroke=halo;\n      t.textContent=l.t; g.appendChild(t); ty+=l.size*1.2; });\n    svg.appendChild(g);\n  });\n  const rejectedLegend=(!asis && mv.gvRejected && mv.gvRejected.length)\n    ? `<span><b style=\"background:#e4e4ea;border:1px solid #9797a3\"></b>fora da hierarquia (${mv.gvRejected.length}) — passe o mouse no mapa</span>`\n    : '';\n  $('#legend').innerHTML=asis\n    ?`<span><b style=\"background:${CLOSE_COLOR}\"></b>${LEVELS[level].label} ${mv.sector} (estrutura antes desta etapa — ${level==='setor'?Object.keys(mv.geoFeat).length+' municípios':Object.keys(mv.unitMunis||{}).length+' '+LEVELS[level].childLabel.toLowerCase()+'(s)'})</span>`\n    // \"vizinhos (contexto)\"/\"setor já fechado antes\" (.sect.plain/.sect.closed-ctx) still never\n    // legend — those stay dead. \"rejected\" is the one gray key that's real again: it marks a\n    // geographically-touching setor/GV excluded for failing the hierarchy rule, appended below.\n    :mv.dest.map(s=>`<span><b style=\"background:${mv.color[s]}\"></b>${LEVELS[level].destLabel} ${s} — ${c.gained[s]} líder(es)</span>`).join('')+rejectedLegend;\n  // Municipalities outside the dominance base are deliberately NOT drawn — no shape, no hover, no\n  // legend entry. They are reported in the panel instead, so the map only ever shows territory that\n  // is actually being reallocated.\n  const aliasNote=MUNI_ALIAS_ERROR\n    ? `<div style=\"margin-top:4px\"><b style=\"color:var(--closing)\">⚠ tabela de correções de grafia de município não carregou:</b> ${esc(MUNI_ALIAS_ERROR)} — aliases cadastrados nela não estão em vigor nesta sessão.</div>`\n    : '';\n  if(ignNote) ignNote.innerHTML=((mv.excludedMunis&&mv.excludedMunis.length)\n    ? `<b style=\"color:var(--closing)\">${fmt(mv.excludedMunis.length)} município(s) / ${fmt(mv.excludedCbs||0)} CBs</b> fora da base de dominantes deste setor não são desenhados no mapa — as CBs seguem a líder do seu grupo (detalhe em “Municípios → setor destino”).`\n    : '')+aliasNote;\n  const legendNote=$('#mapLegendNote');\n  if(legendNote){\n    const lvl=LEVELS[level], fem=lvl.de==='da', childPl=CHILD_PLURAL[lvl.childLabel]||lc(lvl.childLabel);\n    legendNote.innerHTML=`Coloridos = ${childPl} ${lvl.de} ${lc(lvl.label)} fechad${fem?'a':'o'} nesta etapa (cor = destino). Ao redor, cada área é ${fem?'uma':'um'} <b>${lc(lvl.label)} vizinh${fem?'a':'o'} inteir${fem?'a':'o'}</b> com o código no meio — não ${childPl} soltos: cor = ${lc(lvl.label)} destino, cinza = contexto, cinza tracejado = ${lc(lvl.label)} já fechad${fem?'a':'o'} numa etapa anterior. Shapes reais IBGE/tbrugz.`;\n  }\n  // Discarded-move paint mode (DISC_PAINT, toggled by a span-rule fix card's \"Ver simulações\n  // descartadas\" button): reuses the same [data-muni] stamping setPlanHl already relies on for its\n  // hover highlight — those attributes only land on município pieces drawn as neighbour/destination\n  // context above, which is exactly the donor/receiver pool suggestMuniMoves draws its candidates\n  // from. Wired here (not in the button handler) because svg.innerHTML is rebuilt from scratch on\n  // every drawMap — a handler attached to a path only survives until the next redraw.\n  if(DISC_PAINT&&DISC_PAINT.moves&&DISC_PAINT.moves.length){\n    const byMuni={};\n    DISC_PAINT.moves.forEach(r=>{ if(r.moves&&r.moves.length) r.moves.forEach(m=>{ (byMuni[m.muni]=byMuni[m.muni]||[]).push(r); }); });\n    // One município can have several rejected alternatives (ALAGOINHAS vs 3629/3830/3571/3572/3540\n    // etc). Rather than slice its shape into one striped sliver per alternative — hard to read, and\n    // implies each sliver IS a specific alternative, which it never geometrically is — the whole\n    // shape just gets the plain .disc-hl border, and the hover tooltip lists every alternative this\n    // município has instead of only the first one.\n    svg.querySelectorAll('[data-muni]').forEach(el=>{\n      const muniKey=el.getAttribute('data-muni');\n      const entries=byMuni[muniKey]; if(!entries||!entries.length) return;\n      el.classList.add('disc-hl');\n      el.style.cursor='pointer';\n      el.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';\n        const rows=entries.map(r=>`<div>${esc(r.motivo||'')}${r.receiver?' → '+esc(String(r.receiver)):''}</div>`).join('');\n        t.innerHTML=`<b>${esc(mDisp(muniKey))}</b><br>${entries.length>1?`<b>${entries.length} simulações descartadas:</b>`:''}${rows}`\n          +(entries.length===1?`<br><span style=\"color:var(--mut)\">clique p/ aplicar esta movimentação descartada</span>`:'');};\n      el.onmouseleave=()=>$('#tip').style.opacity=0;\n      // Which alternative a click would mean is ambiguous once a município has more than one — the\n      // old per-slice hitbox answered that geometrically; a single highlighted shape can't, so\n      // apply-on-click only survives for the unambiguous, single-alternative case.\n      if(entries.length===1){\n        const r=entries[0];\n        el.onclick=async()=>{\n          try{\n            for(const m of (r.moves||[])){ await applyTransfer(m.from,m.to,m.muni); }\n            DISC_PAINT=null;\n            await flushDirty();\n            renderAll();\n          }catch(e){ setError('erro ao aplicar movimentação descartada: '+e.message); }\n        };\n      }\n    });\n  }\n  applyMapView();\n}\nasync function runSql(){const sql=$('#sqlIn').value.trim();$('#sqlStatus').textContent='rodando…';$('#sqlStatus').classList.remove('err');\n  try{ await ensurePinned(); const resp=await q(sql);const rs=rows(resp);const cols=(resp.manifest?.schema?.columns||[]).map(c=>c.name);\n    $('#sqlOut').innerHTML=`<thead><tr>${cols.map(c=>`<th>${c}</th>`).join('')}</tr></thead><tbody>`+rs.slice(0,200).map(r=>`<tr>${cols.map(c=>`<td>${r[c]??'·'}</td>`).join('')}</tr>`).join('')+`</tbody>`;\n    $('#sqlStatus').textContent=rs.length+' linha(s)';}\n  catch(e){$('#sqlStatus').textContent='bloqueado/erro: '+e.message;$('#sqlStatus').classList.add('err');}\n}\nfunction renderAll(){\n  renderStage();\n  fitMapHeight();\n  renderTimeline(); renderSectorPicker();\n  const shb=$('#openShare'); if(shb) shb.disabled=!SESS.timeline.length;\n  const dpb=$('#dupSession'); if(dpb) dpb.disabled=!SESS.timeline.length;\n  const rnb=$('#renameSession'); if(rnb) rnb.disabled=!SESS.timeline.length;\n  const mv=SESS.timeline[SESS.selected];\n  // Display contract everywhere else in this file is \"never show snapshotRaw, only\n  // snapshotSyncedAt\" (see fmtSyncedAt's own comment) — deliberately broken ONLY for a historical\n  // pin, where the whole point of picking a date is confirming which one you're actually looking\n  // at; snapshotSyncedAt there would just say \"now\", telling you nothing about what you picked.\n  const pillEl=$('#liveState');\n  if(pillEl){\n    pillEl.classList.toggle('locked', !!SESS.locked);\n    if(SESS.locked){\n      pillEl.textContent='🔒 snapshot desta sessão: '+fmtSyncedAt(SESS.snapshotSyncedAt)+' · clique para atualizar';\n      pillEl.title='Esta sessão foi carregada com os dados já calculados e congelados. Clique para atualizar o snapshot e liberar edição.';\n    } else {\n      pillEl.textContent = SESS.snapshotRaw\n        ? (SESS.snapshotPinnedDate ? '📌 snapshot fixado em '+fmtSyncedAt(SESS.snapshotRaw) : 'snapshot atualizado em '+fmtSyncedAt(SESS.snapshotSyncedAt))\n        : '● iniciando';\n      pillEl.title='Clique pra atualizar o snapshot para o mais recente';\n    }\n  }\n  renderRuleNote(); renderSpanRule();\n  if(!mv){ ['#map','#matrix','#matrixTobe','#tobeNote','#leaders'].forEach(s=>{const el=$(s);if(el)el.innerHTML='';}); $('#kpis').innerHTML=''; $('#spans').innerHTML=''; $('#muniList').innerHTML=''; $('#plan').innerHTML=''; renderXferBreakdown(null,'matrixXfer','matrixXferBlock','matrixXferTitle'); renderXferBreakdown(null,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle'); renderXferLeaders(null); return; }\n  $('#viewToggle').querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.v===mv.view));\n  $('#policy').querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.p===mv.policy));\n  $('#optBadge').textContent=mv.optBadge||'';\n  drawMap(); renderContig(); renderSpanRule(); renderKpis(); renderSpans(); renderMuni(); renderLeaders(); renderMatrix(); renderMatrixTobe(); renderPolicy(); renderPlan(); renderReport(); renderCumulative();\n}\n\n// ---------- event wiring ----------\nwindow.addEventListener('resize', ()=>{ fitMapHeight(); drawMap(); });\n$('#confirmSnap').onclick=()=>{ SESS.stage='main'; renderAll(); };\n$('#openMove').onclick=openMove;\n$('#moveClose').onclick=closeMove;\n$('#moveCancel').onclick=closeMove;\n$('#moveBg').addEventListener('click',e=>{ if(e.target.id==='moveBg') closeMove(); });\nroot.querySelectorAll('input[name=\"moveLevel\"]').forEach(r=>r.addEventListener('change',renderMoveForm));\n$('#moveRand').onclick=moveRandPick;\n$('#moveGo').onclick=submitMove;\ninitMacCombo($('#moveCode'), $('#moveCodeList'));\n$('#moveCode').addEventListener('keydown',e=>{ if(e.key==='Enter') submitMove(); });\n$('#confirmSessionPick').onchange=()=>loadSession($('#confirmSessionPick').value);\n$('#optim').onclick=()=>editSelected(mv=>{ mv.assign=optimizeFor(mv); });\n$('#autoTobe').onclick=()=>editSelected(mv=>{ mv.assign=autoAssignFor(mv); mv.optBadge='TOBE por adjacência'; });\n$('#viewToggle').querySelectorAll('button').forEach(b=>b.onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;mv.view=b.dataset.v;renderAll();});\n$('#mapZoomIn').onclick=()=>zoomMapBy(1.4);\n$('#mapZoomOut').onclick=()=>zoomMapBy(1/1.4);\n// Rodovias é só um atalho de \"marcar/desmarcar todos os níveis viários de uma vez\" — o estado real\n// de cada nível continua vivendo em OSM_ON por chave individual, então esconder um nível depois de\n// carregado é só desmarcar aquele checkbox, sem afetar os outros nem precisar recarregar nada.\nfunction syncRodoviasMaster(){\n  const master=$('#osmRodovias'); if(!master) return;\n  const on=HIGHWAY_KEYS.map(k=>!!OSM_ON[k]);\n  const allOn=on.every(Boolean), allOff=on.every(v=>!v);\n  master.checked=allOn; master.indeterminate=!allOn&&!allOff;\n}\n[...HIGHWAY_KEYS,'rivers','airports'].forEach(k=>{ const el=$('#osm'+k[0].toUpperCase()+k.slice(1)); if(el) el.onchange=()=>{ OSM_ON[k]=el.checked; syncRodoviasMaster(); drawMap(); }; });\nconst muniOutlineEl=$('#muniOutline');\nif(muniOutlineEl) muniOutlineEl.onchange=()=>{ SHOW_MUNI_OUTLINE=muniOutlineEl.checked; drawMap(); };\nconst osmRodoviasEl=$('#osmRodovias');\nif(osmRodoviasEl) osmRodoviasEl.onchange=()=>{\n  const on=osmRodoviasEl.checked;\n  HIGHWAY_KEYS.forEach(k=>{ OSM_ON[k]=on; const el=$('#osm'+k[0].toUpperCase()+k.slice(1)); if(el) el.checked=on; });\n  osmRodoviasEl.indeterminate=false;\n  drawMap();\n};\n['muni','ctx'].forEach(k=>{ const el=$('#legend'+(k==='muni'?'Muni':'Ctx')); if(el) el.onchange=()=>{ LEGEND_ON[k]=el.checked; drawMap(); }; });\nconst osmPanelEl=$('#osmPanel'), osmPanelIconEl=$('#osmPanelIcon'), osmPanelXEl=$('#osmPanelX');\nfunction syncOsmPanelOpen(){\n  if(osmPanelEl) osmPanelEl.classList.toggle('hidden', !OSM_PANEL_OPEN);\n  if(osmPanelIconEl) osmPanelIconEl.classList.toggle('hidden', OSM_PANEL_OPEN);\n}\nif(osmPanelIconEl) osmPanelIconEl.onclick=()=>{ OSM_PANEL_OPEN=true; syncOsmPanelOpen(); };\nif(osmPanelXEl) osmPanelXEl.onclick=()=>{ OSM_PANEL_OPEN=false; syncOsmPanelOpen(); };\nsyncOsmPanelOpen();\n$('#map').addEventListener('pointerdown',mapPointerDown);\n$('#map').addEventListener('wheel',e=>{ e.preventDefault(); zoomMapAt(e.deltaY<0?1.15:1/1.15, e.clientX, e.clientY); }, {passive:false});\nroot.addEventListener('pointermove',mapPointerMove);\nroot.addEventListener('pointerup',mapPointerUp);\nroot.addEventListener('pointerleave',mapPointerUp);\n// Arrastar não pode virar \"cliquei num município e troquei o destino sem querer\": se o ponteiro se\n// moveu além do limiar de arrasto, este clique (capture, antes de chegar no <path>) é engolido.\n$('#map').addEventListener('click',e=>{ if(mapDidDrag){ e.stopPropagation(); e.preventDefault(); mapDidDrag=false; } },true);\n$('#policy').querySelectorAll('button').forEach(b=>b.onclick=()=>{if(guardLocked())return;const mv=SESS.timeline[SESS.selected];if(!mv)return;mv.policy=b.dataset.p;\n  $('#policy').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b));\n  renderKpis();renderPolicy();renderPlan();});\nroot.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('on',x===b));\n  ['matrix','tobe','report','cumulative','sql'].forEach(t=>$('#tab-'+t).classList.toggle('hidden',t!==b.dataset.tab));});\n$('#runSql').onclick=runSql;\n$('#copyPlan').onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;const payload={setor:mv.sector,politica:mv.policy,plano:mv._plan};if(mv.kind==='transfer')payload.transferencia={municipio:mName(mv.xferMuni),uf:mUf(mv.xferMuni),from:mv.xferFrom,to:mv.xferTo,cbs:mv.xferMetrics.cbs};navigator.clipboard.writeText(JSON.stringify(payload,null,2));$('#copyPlan').textContent='copiado ✓';setTimeout(()=>$('#copyPlan').textContent='Copiar',1500);};\n$('#csvPlan').onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;const rowsCsv=mv._plan.map(p=>`${p.municipio},${p.uf||''},${p.cbs},${mv.sector},${p.destino},fechamento`);if(mv.kind==='transfer')rowsCsv.push(`${mName(mv.xferMuni)},${mUf(mv.xferMuni)||''},${mv.xferMetrics.cbs},${mv.xferFrom},${mv.xferTo},transferência`);const csv='municipio,uf,cbs,setor_origem,setor_destino,tipo\\n'+rowsCsv.join('\\n');\n  const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));a.download=`plano_migracao_setor_${mv.sector}.csv`;a.click();};\n$('#saveSession').onclick=saveSession;\n$('#dupSession').onclick=duplicateSession;\n$('#renameSession').onclick=renameSession;\n$('#sessionPick').onchange=()=>loadSession($('#sessionPick').value);\n$('#liveState').onclick=pillClick;\n$('#liveState').addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); pillClick(); } });\n$('#openOptions').onclick=openOptions;\n$('#optClose').onclick=closeOptions;\n$('#optCancel').onclick=closeOptions;\n$('#optApply').onclick=applyOptions;\n['setor','gv'].forEach(lvl=>['#optTargetSpan_','#optMaxSpan_','#optMinSpan_'].forEach(pfx=>$(pfx+lvl).addEventListener('input',renderOptPreview)));\n$('#optionsBg').addEventListener('click',e=>{ if(e.target.id==='optionsBg') closeOptions(); });\n$('#lockedClose').onclick=closeLockedSheet;\n$('#lockedCancel').onclick=closeLockedSheet;\n$('#lockedBg').addEventListener('click',e=>{ if(e.target.id==='lockedBg') closeLockedSheet(); });\n$('#lockedGo').onclick=async()=>{\n  const go=$('#lockedGo');\n  go.disabled=true; go.textContent='Atualizando…';\n  await refreshSnapshot();\n  closeLockedSheet();\n  go.disabled=false; go.textContent='Atualizar snapshot';\n};\n$('#discardedClose').onclick=closeDiscarded;\n$('#discardedCancel').onclick=closeDiscarded;\n$('#discardedBg').addEventListener('click',e=>{ if(e.target.id==='discardedBg') closeDiscarded(); });\nroot.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeOptions(); closeShare(); closeMove(); closeLockedSheet(); closeDiscarded(); } });\n\n$('#openShare').onclick=openShare;\n$('#shareX').onclick=closeShare;\n$('#shareCancel').onclick=closeShare;\n$('#shareGo').onclick=doShare;\n$('#shareCopy').onclick=()=>{ if(!SHARE.url) return; navigator.clipboard.writeText(SHARE.url);\n  const b=$('#shareCopy'); if(b){ b.textContent='copiado ✓'; setTimeout(()=>{ const x=$('#shareCopy'); if(x) x.textContent='Copiar link'; },1500); } };\n$('#shareOpen').onclick=()=>{ if(SHARE.url) window.open(SHARE.url,'_blank'); };\n$('#shareBg').addEventListener('click',e=>{ if(e.target.id==='shareBg') closeShare(); });\n\n// ---------- init ----------\n(async()=>{\n  loader(true,'congelando snapshot da sessão…');\n  try{\n    await Promise.all([ensurePinned(), loadSectors(), refreshSessionPicker(), loadMuniAliases()]);\n    SESS.stage='confirm';\n  }catch(e){ setError('erro init: '+e.message); }\n  finally{ loader(false); renderAll(); const rd=$('#ready'); if(rd) rd.setAttribute('data-loaded','1'); }\n})();\n\n  }\n}\n"}},"sessionsDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"sessionsDataset"}}}},"dominantesCsvUrl":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"dominantesCsvUrl"}}}}}}}}}},{"_id":"6a7f2f3de811f18f500dca71","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"pS1W650RpCdVhLm9IkwFi","package":"natura-geomarketing","extends":"data2-core-primitives@Block","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const T='product.perfil_cb.crm_perfil_cb_diaria_historico';\nconst ELO='product.elo_modelo_combinado.commercial_consultant_index';\nconst PALETTE=['#5856d6','#ff9500','#28cd41','#af52de','#ff2d55','#a2845e'];\nconst CLOSE_COLOR='#8e8e93';\nconst UF_CODE={RO:'11',AC:'12',AM:'13',RR:'14',PA:'15',AP:'16',TO:'17',MA:'21',PI:'22',CE:'23',RN:'24',PB:'25',PE:'26',AL:'27',SE:'28',BA:'29',MG:'31',ES:'32',RJ:'33',SP:'35',PR:'41',SC:'42',RS:'43',MS:'50',MT:'51',GO:'52',DF:'53'};\nconst UF_BORDERS={AC:['AM','RO'],AL:['BA','PE','SE'],AM:['AC','RO','RR','PA','MT'],AP:['PA'],\n BA:['AL','SE','PE','PI','TO','GO','MG','ES'],CE:['PI','PB','PE','RN'],DF:['GO','MG'],\n ES:['BA','MG','RJ'],GO:['DF','MT','MS','MG','BA','TO'],MA:['PA','TO','PI'],\n MG:['BA','GO','DF','MS','SP','RJ','ES'],MS:['MT','GO','MG','SP','PR'],MT:['RO','AM','PA','TO','GO','MS'],\n PA:['AP','AM','MT','TO','MA','RR'],PB:['RN','CE','PE'],PE:['PB','CE','PI','BA','AL'],\n PI:['MA','TO','BA','PE','CE'],PR:['SP','MS','SC'],RJ:['ES','MG','SP'],RN:['CE','PB'],\n RO:['AC','AM','MT'],RR:['AM','PA'],RS:['SC'],SC:['PR','RS'],SE:['BA','AL'],\n SP:['MG','RJ','PR','MS'],TO:['PA','MA','PI','BA','GO','MT']};\nconst NIVEIS=['Bronze','Cristal','Prata','Ouro','Diamante','Diamante+'];\nconst METRICS_DEF=[['cbs','CBs'],['ativas','Ativas'],['pedidos','Pedidos'],['pontos','Pontos'],['receita','Receita','$'],['rec_nat','Rec. Natura','$'],['rec_avon','Rec. Avon','$']];\nconst OPT_K=1.5;\nconst SESSIONS_DS='geo-timeline-sessionsDataSet';   // dedicated saved-sessions table, separate from SHARED_DS — see notes at listSessions()\n// Span defaults are the real operating band, not placeholders: setor 2200 ≤ span ≤ 3500 (alvo 2700),\n// GV 10000 ≤ span ≤ 22000 (alvo 16000). Mercado não tem um histórico equivalente ainda, então nasce\n// sem limite (null) até alguém configurar. Span agora é uma escala POR NÍVEL: fechar um setor só olha\n// span.setor, fechar uma GV só olha span.gv, fechar um mercado só olha span.mercado — nunca se misturam.\n// Existing sessions are NOT touched — their persisted settings carry explicit values that spread OVER\n// these in openOptions/applyOptions, which is deliberate: their etapas were computed under the old\n// band and changing it silently would leave the stored result and the rule that produced it out of sync.\nconst DEFAULT_SETTINGS={span:{\n  setor:{targetSpan:2700,maxSpan:3500,minSpan:2200},\n  gv:{targetSpan:16000,maxSpan:22000,minSpan:10000},\n  mercado:{targetSpan:null,maxSpan:null,minSpan:null},\n},\n  forceContiguity:true,\n};\nconst DOM_MEDIA_BASE='https://natura-geomarketing.data2.media/file/';\nconst DOM_CSV_FALLBACK=DOM_MEDIA_BASE+'yw_TQdp0QXEk6JsrliAqP'; \nconst numOrNull=v=>{const n=Math.round(+v);return (isFinite(n)&&n>0)?n:null;};\nconst spanShort=st=>{const p=[];if(st&&st.targetSpan)p.push('alvo '+st.targetSpan);if(st&&st.maxSpan)p.push('máx '+st.maxSpan);if(st&&st.minSpan)p.push('mín '+st.minSpan);return p.length?p.join(' · '):'livre';};\n\nexport default {\n  css: `\n  :host{display:block;height:100%;--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--acc2:#0060df;--closing:#ff3b30;--ok:#28cd41;\n    /* Tokens do AppKit real (macOS Big Sur): raio 6 e sombra y1/blur3/15% preto são os valores\n       documentados de botão/controle do sistema, não um chute — usados em todo botão, select,\n       segmented control e tab pra ler como controle nativo em vez de elemento web plano. */\n    --radius-ctrl:6px;--shadow-ctrl:0 1px 3px rgba(0,0,0,.15)}\n  *{box-sizing:border-box}\n  .simroot{position:relative;height:100%;overflow:auto;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}\n  header{padding:10px 16px;background:rgba(246,246,248,.85);backdrop-filter:blur(20px) saturate(1.8);-webkit-backdrop-filter:blur(20px) saturate(1.8);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-wrap:wrap}\n  header h1{font-size:14.5px;margin:0;font-weight:600;letter-spacing:-.1px;color:var(--ink)}\n  .mac-spacer{flex:1}\n  .howto{padding:8px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--mut);align-items:center}\n  .howto b{color:var(--ink)} .howto span{max-width:340px}\n  .pill{font-size:10.5px;padding:2.5px 9px;border-radius:99px;border:1px solid var(--line);color:var(--mut);background:var(--panel2)}\n  .pill.live{color:var(--ink);cursor:pointer;user-select:none;transition:border-color .1s,color .1s,background .1s}\n  .pill.live:hover{border-color:var(--acc);color:var(--acc);background:#eaf3ff}\n  .pill.live:focus-visible{outline:2px solid rgba(0,122,255,.35);outline-offset:1px}\n  .pill.live.busy{border-color:var(--acc);color:var(--acc);cursor:progress}\n  @keyframes pullspin{from{transform:rotate(0)}to{transform:rotate(360deg)}}\n  .spinning{display:inline-block;animation:pullspin .8s linear infinite}\n  .pill.live.locked{border-color:#ff9500;color:#ff9500;background:#fff6ea}\n  .status{font-size:11px;color:var(--mut)}.status.err{color:var(--closing)}\n  select,input,textarea,button{font:inherit}\n  select,input{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:var(--radius-ctrl);padding:5.5px 9px;box-shadow:var(--shadow-ctrl)}\n  /* Seta nativa do <select> cola na borda direita sem respiro nenhum — appearance:none tira a seta\n     do sistema e desenha uma própria, com o mesmo respiro (9px) do padding horizontal do resto. */\n  select{appearance:none;-webkit-appearance:none;-moz-appearance:none;padding-right:26px;\n    background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5.5 8L10 12.5L14.5 8' fill='none' stroke='%238e8e93' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n    background-repeat:no-repeat;background-position:right 9px center;background-size:13px}\n  select:focus,input:focus{outline:2px solid rgba(0,122,255,.35);outline-offset:0;border-color:var(--acc)}\n  /* Native <select>/<datalist> OPEN-LIST popups are drawn by the browser itself and cannot be\n     restyled on any engine — only the closed control can be skinned. .macsel repaints the open list\n     as a real DOM element (an actual AppKit-style popover) while the underlying <select>/<input>\n     stays the real state — same .value/.onchange/.options, every existing read/write untouched. */\n  .macsel{position:relative}\n  .macsel-btn{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;text-align:left;background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:var(--radius-ctrl);padding:5.5px 9px;box-shadow:var(--shadow-ctrl);cursor:pointer}\n  .macsel-btn:hover{background:var(--panel2)}\n  .macsel-btn:focus-visible{outline:2px solid rgba(0,122,255,.35);outline-offset:0;border-color:var(--acc)}\n  .macsel-btn .macsel-lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n  .macsel-btn .macsel-car{color:#8e8e93;flex:0 0 auto;font-size:11px;transform:translateY(1px)}\n  /* .macsel-panel/.macsel-opt/.macsel-empty are styled from a plain <style> injected inside\n     macPortal() (mounted(), below), not here — panels live in a document.body portal, which can't\n     see CSS scoped to this shadow root. Body, not this shadow tree: a shadow-root-relative fixed\n     portal empirically lost the stacking fight against the \"Adicionar movimentação\" modal's own\n     backdrop-filter layer despite a far higher z-index; swapping to a body portal (the platform's\n     documented overlay pattern) painted correctly on the first try. */\n  #moveCode{padding-right:26px;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5.5 8L10 12.5L14.5 8' fill='none' stroke='%238e8e93' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");background-repeat:no-repeat;background-position:right 9px center;background-size:13px}\n  button.act{background:var(--acc);color:#fff;border:1px solid var(--acc2);border-radius:var(--radius-ctrl);padding:6px 13px;font-weight:600;cursor:pointer;box-shadow:var(--shadow-ctrl);transition:background .1s}\n  button.act:hover{background:var(--acc2)}\n  button.act:active{background:#0052c4;box-shadow:inset 0 1px 2px rgba(0,0,0,.25)}\n  button.ghost{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:var(--radius-ctrl);padding:5.5px 12px;cursor:pointer;box-shadow:var(--shadow-ctrl);transition:background .1s}\n  button.ghost:hover{background:var(--panel2)}\n  button.ghost:active{background:#e5e5ea;box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}\n  button.ghost.on{border-color:var(--acc);color:var(--acc);background:#eaf3ff}\n  button.ghost:disabled,button.act:disabled{opacity:.4;cursor:not-allowed;box-shadow:none}\n  .sessbar{padding:9px 20px;background:var(--panel2);border-bottom:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap;align-items:center}\n  .rail{padding:9px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:8px;align-items:center;overflow-x:auto}\n  #movementChips{display:flex;gap:8px;flex:0 0 auto}\n  #addMovementGroup{display:flex;gap:8px;align-items:center;flex:0 0 auto;margin-left:auto;padding-left:12px;border-left:1px solid var(--line)}\n  #addMovementGroup input{min-width:150px}\n  .tchip{display:flex;align-items:center;gap:7px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;cursor:pointer;font-size:12px;flex:0 0 auto}\n  .tchip.on{border-color:var(--acc);background:#eaf3ff}\n  .tchip.stale{border-style:dashed;opacity:.85}\n  .tchip .tstale{color:#ff9500;font-weight:700}\n  .tchip .tnum{background:var(--ink);color:#fff;border-radius:99px;width:16px;height:16px;font-size:9.5px;display:flex;align-items:center;justify-content:center;font-weight:700}\n  .tchip .tsec{font-weight:600}\n  .tchip .tstat{color:var(--mut)}\n  .tchip .trm{color:var(--mut);font-weight:700;padding:0 3px}\n  .tchip .trm:hover{color:var(--closing)}\n  .tchip.tchip-transfer{background:var(--panel)}\n  .tchip.tchip-transfer .tsec{font-weight:500}\n  .tchip .tarw{color:var(--mut);font-weight:700;font-size:11px}\n  .rail .empty{color:var(--mut);font-size:12px}\n  .wrap{display:grid;grid-template-columns:minmax(360px,1fr) 430px;gap:14px;padding:14px 16px;align-items:start}\n  @media(max-width:1150px){.wrap{grid-template-columns:1fr}}\n  .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:14px;box-shadow:0 1px 3px rgba(0,0,0,.08)}\n  .card h2{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);margin:0 0 9px;font-weight:600}\n  /* user-select, not pointer-events. .lbl already sets pointer-events:none, which stops the text from\n     RECEIVING clicks but does not exclude it from the browser's selection range — so cycling a\n     município's destination with repeated clicks kept dragging a double-click selection across the\n     labels. Nothing drawn in an svg here is text anyone needs to copy. */\n  svg{width:100%;height:auto;display:block;user-select:none;-webkit-user-select:none}\n  /* O <svg> tinha width:100%;height:auto — a altura era só uma função da LARGURA da coluna, então\n     numa tela larga e curta o mapa virava mais alto que o espaço vertical sobrando e a página\n     inteira crescia além da tela. max-height no wrapper + height:100%;width:auto;max-width:100% no\n     próprio svg é o equivalente de object-fit:contain: o navegador encaixa o viewBox 700x500 (via\n     preserveAspectRatio, que já existia) dentro do que sobrar de altura OU largura, o que for menor\n     primeiro, sem distorcer nem cortar nada. */\n  /* Altura real vem de fitMapHeight() (JS): mede quanto sobrou de viewport abaixo do topo do\n     card e aplica isso como altura inline — assim o mapa para exatamente na borda da tela\n     independente do que tem acima (cabeçalho, trilho de etapas, toolbar) ou abaixo dele.\n     60vh aqui é só o valor antes do primeiro paint do JS rodar. */\n  .map-wrap{position:relative;height:60vh;display:flex;justify-content:center;overflow:hidden}\n  #map{cursor:grab;touch-action:none;width:100%;height:100%}\n  #map.dragging{cursor:grabbing}\n  .zoom-pill{position:absolute;left:10px;bottom:10px;display:flex;align-items:stretch;background:var(--panel);border:1px solid var(--line);border-radius:99px;box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);overflow:hidden;z-index:3}\n  .zoom-pill button{width:30px;height:30px;border:0;background:transparent;color:var(--ink);font:16px/1 -apple-system,BlinkMacSystemFont,sans-serif;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center}\n  .zoom-pill button:hover{background:var(--panel2)}\n  .zoom-pill button:active{background:#e5e5ea}\n  .zoom-pill .zoom-div{width:1px;background:var(--line)}\n  .osm-panel{position:absolute;left:10px;top:10px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);padding:8px 10px;font-size:11.5px;z-index:3;min-width:104px;max-height:calc(100% - 20px);overflow-y:auto}\n  .osm-panel-icon{position:absolute;left:10px;top:10px;width:30px;height:30px;display:flex;align-items:center;justify-content:center;background:var(--panel);border:1px solid var(--line);border-radius:9px;box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);cursor:pointer;color:var(--ink);z-index:3;transition:background .1s}\n  .osm-panel-icon:hover{background:var(--panel2)}\n  .osm-panel-icon:active{background:#e5e5ea}\n  .osm-panel-hd{font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between}\n  .osm-group-hd{font-size:9.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600;margin:2px 0 4px}\n  .osm-group-hd.sep{margin-top:8px;padding-top:8px;border-top:1px solid var(--line)}\n  .osm-row{display:flex;align-items:center;gap:6px;padding:2px 0;cursor:pointer;color:var(--ink)}\n  .osm-row.osm-sub{padding-left:14px;font-size:10.5px;color:var(--mut)}\n  .osm-row input{accent-color:var(--acc);margin:0}\n  .osm-status{margin-top:6px;font-size:10px;color:var(--mut);max-width:150px;line-height:1.4}\n  /* Small/short windows (a laptop at 100% zoom, a split-screen browser): the layers panel can list\n     a couple dozen rows (todo nível viário + aeroportos + legendas) and, at full size, run past the\n     bottom of a short map area. max-height+overflow above is the hard backstop so it never draws\n     outside the map no matter what; this shrinks the type scale first so a short-but-not-tiny screen\n     fits everything WITHOUT needing to scroll at all. */\n  @media (max-height:760px){\n    .osm-panel{padding:6px 8px;font-size:10.5px;min-width:92px}\n    .osm-panel-hd{font-size:9px;margin-bottom:4px}\n    .osm-group-hd{font-size:8.5px;margin:1px 0 3px}\n    .osm-row{padding:1px 0;gap:5px}\n    .osm-row.osm-sub{padding-left:12px;font-size:9.5px}\n    .osm-status{font-size:9px;max-width:130px;margin-top:4px}\n  }\n  /* White hairlines inside the closing sector: the municipios read as one territory cut into parts\n     rather than as separate outlined units, and the saturated fills carry the grouping instead of the\n     ink. Neighbour sectors keep their own coloured strokes (.sect) — that contrast is what separates\n     what is being reallocated from what is only context. Selection no longer draws an outline; the\n     white border was the click affordance AND the default is now white, so it had nothing left to say. */\n  .muni{stroke:#fff;stroke-width:1.1;stroke-linejoin:round;stroke-linecap:round;cursor:pointer;transition:filter .1s}.muni:hover{filter:brightness(1.15)}.muni.munihl{filter:brightness(1.15)}\n  /* GV/mercado closing (\"Contorno de Setores\" toggle): traces one unit's (setor's) DISSOLVED\n     territory as a whole, never a per-município line — drawn once per member município, but always\n     BEFORE that unit's own opaque fills, so every internal edge (shared by two municípios of the\n     SAME unit) gets painted over twice by those fills and only the unit's true external boundary\n     survives. Same border-before-fill trick already used above for .sect-rejected-border. */\n  .unit-outline{fill:none;stroke:#fff;stroke-width:1.1;stroke-linejoin:round;stroke-linecap:round;pointer-events:none}\n  .sect{stroke-width:1;stroke-linejoin:round;stroke-linecap:round;fill-opacity:.32;stroke-opacity:.6}\n  /* .sect's normal look is translucent (fill-opacity .32) — the plain brightness bump .muni.munihl\n     uses would barely register on top of that, so this highlight also raises opacity so the piece\n     visibly pops when a fix-card plan hovers it (see moveWork/setPlanHl). */\n  .sect.munihl{fill-opacity:.68;filter:brightness(1.12)}\n  /* Mesmo truque do .sect.rejected acima — stroke = fill, não uma cor de borda diferente. O bucket\n     __MULTI_NOVALID__ pode cobrir VÁRIOS pedaços já pré-recortados do mesmo município compartilhado\n     (mv.sharedMuniSlices fatia geometricamente uma vez, antes de saber quais fatias são válidas) —\n     um stroke diferente do fill desenhava uma costura visível entre cada pedaço mesmo todos pintados\n     do mesmo cinza. */\n  .sect.plain{fill:#e8e8ec;fill-opacity:1}\n  .sect.closed-ctx{fill:#e2e2e7;stroke:#aeaeb2;stroke-dasharray:3 2;fill-opacity:1;stroke-opacity:1}\n  /* stroke igual ao fill, não \"sem stroke\": dois preenchimentos opacos idênticos lado a lado ainda\n     rasterizam com uma costura fina de antialiasing na borda compartilhada — um traço fino da MESMA\n     cor cobre exatamente esse pixel de costura sem acrescentar nenhuma borda visível de verdade.\n     Diferente do truque \"sem stroke\" usado no destino (fill-opacity .32, translúcido): lá, stroke\n     igual ao fill dobra a composição e escurece; aqui, opaco sobre opaco é só a mesma cor de novo. */\n  .sect.rejected{fill:#e4e4ea;stroke:#e4e4ea;stroke-width:0.6;fill-opacity:1;cursor:help}\n  .sect-rejected-border{fill:none;stroke:#b6b6bd;stroke-width:1;stroke-opacity:.6;stroke-linejoin:round;pointer-events:none}\n  /* Same halo trick used by .sect-rejected-border, drawn for the OPPOSITE reason: a município with\n     >1 real sector already gets the diagonal hatch overlay (see multiSectorHatch) — this border makes\n     that same município's outline read clearly against whatever sits behind/around it, since the\n     hatch alone is easy to miss at a glance. */\n  .sect-multi-border{fill:none;stroke:#1c1c1e;stroke-width:0.6;stroke-opacity:.4;stroke-linejoin:round;pointer-events:none}\n  .sect.unresolved{fill:#fbeceb;stroke:#e8a29c;stroke-dasharray:2 2;fill-opacity:1;stroke-opacity:1;cursor:help}\n  .lbl{pointer-events:none;filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.16))}\n  /* No plate behind the text: a halo painted UNDER the glyphs (paint-order) keeps a name legible on any\n     fill without stamping an opaque box over the territory it names. Ink and halo are chosen per label\n     from the luminance of the fill underneath, so they are deliberately NOT declared here — a\n     stylesheet rule outranks a presentation attribute and would silently override that choice. */\n  .lbl text{text-anchor:middle;paint-order:stroke fill;stroke-width:1.8px;stroke-linejoin:round;stroke-linecap:round;letter-spacing:-.1px;font-family:-apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif}\n  .lbl.big text{stroke-width:2.4px}\n  .lbl line.lead{stroke-opacity:.6;stroke-width:.8;stroke-linecap:round}\n  .legend{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;font-size:12px}\n  .legend b{display:inline-block;width:11px;height:11px;border-radius:4px;margin-right:5px;vertical-align:-1px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}\n  .kpis{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-bottom:12px}\n  .kpi{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:9px 11px}\n  .kpi .n{font-size:19px;font-weight:700}.kpi .l{font-size:10.5px;color:var(--mut);margin-top:2px}\n  .kpi.warn{border-color:#ffd1cc}.kpi.warn .n{color:var(--closing)}.kpi.ok{border-color:#bdeecb}.kpi.ok .n{color:var(--ok)}\n  .bar-row{display:flex;align-items:center;gap:8px;margin:6px 0}\n  .bar-row .tag{width:42px;font-weight:600;font-size:12px}\n  .bar-track{flex:1;height:18px;background:var(--panel2);border-radius:5px;overflow:hidden;display:flex;position:relative}\n  .bar-mark{position:absolute;top:0;bottom:0;width:2px;opacity:.9;z-index:2}\n  .bar-base{height:100%;opacity:.4}.bar-inc{height:100%}\n  /* Faixa listrada pra diferenciar um ganho vindo de uma TRANSFERÊNCIA manual de município do ganho\n     normal de uma etapa de fechamento (sólido) — mesma cor do setor por baixo, só o padrão muda.\n     !important pra vencer o style inline (shorthand 'background', que já zera background-image). */\n  .bar-inc.xfer{background-image:repeating-linear-gradient(135deg,rgba(255,255,255,.55) 0 5px,rgba(255,255,255,0) 5px 10px)!important}\n  .bar-val{width:132px;text-align:right;font-size:11px;color:var(--mut);font-variant-numeric:tabular-nums}\n  table{border-collapse:collapse;width:100%;font-size:11.5px}\n  th,td{padding:5px 6px;text-align:right;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}\n  th:first-child,td:first-child{text-align:left}\n  #matrixTobe th,#matrixTobe td{text-align:center}\n  #matrix th,#matrix td{text-align:center}\n  #matrix th:first-child,#matrix td:first-child,#matrixTobe th:first-child,#matrixTobe td:first-child{text-align:left}\n  thead th{color:var(--mut);font-weight:600;font-size:10.5px;position:sticky;top:0;background:var(--panel)}\n  .home{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:4px}\n  .muni-item{display:flex;flex-direction:column;gap:6px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin-bottom:6px}\n  .muni-item-hd{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}\n  .muni-item .nm{font-weight:600;font-size:12px}.muni-item .ct{flex:0 0 auto;color:var(--mut);font-size:11px}\n  .muni-item>.badge{align-self:flex-start}\n  /* Nome + contagem moram na propria linha (.muni-item-hd), separada da linha de destino — nao mais\n     uma unica linha disputando espaco entre nome, contagem e os botoes de setor. GV/mercado pode\n     oferecer bem mais que os 6 destinos do setor (todo irmao da mercado, as vezes dezenas): a linha\n     de destino agora usa a LARGURA INTEIRA do card (stretch do flex-column do .muni-item), e um nome\n     de municipio comprido cresce pra baixo (flex-wrap na linha de cima) em vez de espremer o destino.\n     .seg mantem scroll horizontal proprio (min-width:0 e o que deixa um item flex encolher alem do\n     tamanho do conteudo, liberando a barra) pros casos com destinos demais mesmo com a largura cheia. */\n  .seg{display:flex;background:var(--panel2);border:1px solid rgba(0,0,0,.22);border-radius:7px;overflow-x:auto;overflow-y:hidden;min-width:0}\n  /* Without this, .seg's overflow-x:auto falls back to the OS/browser's native scrollbar — full-size\n     track + arrow buttons on Windows Chromium, dwarfing the 26px-tall control it belongs to. A thin\n     WebKit scrollbar (no buttons, no track chrome) reads as part of the control instead of a foreign\n     OS widget bolted under it. */\n  .seg::-webkit-scrollbar{height:5px}\n  .seg::-webkit-scrollbar-button{display:none;width:0;height:0}\n  .seg::-webkit-scrollbar-track{background:transparent}\n  .seg::-webkit-scrollbar-thumb{background:rgba(0,0,0,.18);border-radius:99px}\n  .seg::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3)}\n  .seg button{border:0;border-right:1px solid var(--line);background:transparent;color:var(--mut);padding:4px 8px;font-size:11px;font-weight:600;cursor:pointer;flex:1 1 auto;min-width:42px;white-space:nowrap}\n  .seg button:last-child{border-right:0}\n  .seg button.on{color:#fff}\n  .toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}\n  /* Segmented control de verdade: trilho cinza (panel2) + o segmento ativo vira uma cápsula branca\n     flutuando por cima (shadow-ctrl), não só uma mudança de cor plana. */\n  .toggle{display:flex;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:2px;gap:1px;font-size:12px}\n  .toggle button{background:transparent;border:0;border-radius:6px;color:var(--mut);padding:5px 10px;cursor:pointer;font-weight:600;transition:background .12s,box-shadow .12s,color .12s}\n  .toggle button.on{background:#fff;color:var(--ink);box-shadow:var(--shadow-ctrl)}\n  .note{color:var(--mut);font-size:10.5px;margin-top:7px;line-height:1.5}\n  .rule-item{padding:9px 0}\n  .rule-item+.rule-item{border-top:1px solid var(--line);margin-top:2px;padding-top:11px}\n  .rule-cap{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin-bottom:5px}\n  .rule-ok{color:var(--ok);font-size:12.5px}\n  .rule-issues{display:flex;flex-direction:column;gap:8px}\n  .rule-issue-row{display:flex;flex-direction:column;gap:7px}\n  .rule-issue-hd{display:flex;align-items:flex-start;gap:6px}\n  .rule-issue-hd button.rule-issue{flex:1 1 auto;min-width:0}\n  button.fixdismiss{flex:0 0 auto;background:transparent;border:1px solid var(--line);color:var(--mut);font-weight:600;font-size:12px;line-height:1;padding:5px 8px;border-radius:var(--radius-ctrl);cursor:pointer;transition:background .1s,color .1s}\n  button.fixdismiss:hover{background:var(--panel2);color:var(--ink)}\n  button.rule-issue{align-self:flex-start;background:#fdeceb;border:1px solid #f3b4ae;color:var(--closing);font-weight:600;font-size:12px;padding:5px 10px;border-radius:var(--radius-ctrl);box-shadow:var(--shadow-ctrl);cursor:pointer;text-align:left;transition:background .1s;max-width:100%;white-space:normal}\n  button.rule-issue:hover{background:#fbdedb}\n  button.rule-issue:disabled{opacity:.6;cursor:progress}\n  .fixresult{display:flex;flex-direction:column;gap:8px}\n  .fixcard{border:1px solid var(--line);background:var(--panel2);border-radius:10px;padding:10px 12px;font-size:12px;line-height:1.55;display:flex;flex-direction:column;gap:7px}\n  .fixcard .chips{display:flex;flex-wrap:wrap;gap:5px}\n  .chip{display:inline-flex;align-items:center;font-size:11px;font-variant-numeric:tabular-nums;padding:2.5px 8px;border-radius:99px;background:var(--panel);border:1px solid var(--line);color:var(--ink)}\n  .chip.bad{color:var(--closing);border-color:#f3b4ae;background:#fdeceb;font-weight:600}\n  .fixcard .verdict{font-weight:600}\n  .fixcard .verdict.ok{color:var(--ok)}\n  .fixcard .verdict.bad{color:var(--closing)}\n  .fixcard .hint{color:var(--mut);font-size:11px}\n  .fixcard .applyfix{align-self:flex-start}\n  .cell{font-variant-numeric:tabular-nums}\n  .lider-foot td{background:var(--panel2);font-weight:600;font-size:11px;color:var(--ink);border-bottom:2px solid var(--line);padding:5px 6px;text-align:left}\n  .scroll{max-height:240px;overflow:auto}\n  /* Tables meant to be READ in full rather than sampled. Only the vertical cap is dropped — overflow\n     stays auto so the wide grupo grid can still scroll sideways inside its card, which is the one axis\n     that genuinely cannot fit. */\n  .scroll.grow{max-height:none}\n  .tip{position:fixed;pointer-events:none;background:#1c1c1e;color:#fff;border-radius:10px;padding:8px 10px;font-size:11px;line-height:1.5;opacity:0;transition:opacity .1s;z-index:9;max-width:280px;max-height:min(420px,80vh);overflow:auto;box-shadow:0 4px 16px rgba(0,0,0,.35)}\n  .tipmuni{margin-top:6px;padding-top:6px;border-top:1px solid rgba(255,255,255,.25)}\n  .tiprow{padding:3px 0}\n  .tiprow-hd{display:flex;align-items:baseline;gap:5px}\n  .tipdot{flex:none;width:11px;text-align:center;font-size:10px;color:#fff}\n  .tiplbl{font-size:9.5px;color:rgba(255,255,255,.65);line-height:1.35}\n  .tipcodes{display:block;margin-left:16px;margin-top:1px;color:#fff;font-weight:600;font-size:12px}\n  .spark{display:flex;align-items:flex-end;gap:3px;height:44px}\n  .spark .b{flex:1;background:var(--acc);border-radius:2px 2px 0 0;min-height:2px;opacity:.85}\n  .delta{font-size:10px}.delta.up{color:var(--ok)}.delta.dn{color:var(--closing)}\n  .tabs{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}\n  .tabs button{background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--mut);padding:6px 12px;cursor:pointer;font-weight:600;box-shadow:var(--shadow-ctrl);transition:background .1s,box-shadow .1s}\n  .tabs button.on{background:#fff;color:var(--ink);border-color:var(--acc);box-shadow:0 1px 3px rgba(0,0,0,.2)}\n  .hidden{display:none}\n  .sqlbox{width:100%;height:60px;font-family:ui-monospace,Menlo,monospace;font-size:11px;resize:vertical}\n  .badge{font-size:10px;padding:1px 6px;border-radius:5px;background:var(--panel2);border:1px solid var(--line);color:var(--mut)}\n  /* #optBadge é o único badge que costuma quebrar em várias linhas (resumo inteiro do otimizador) —\n     o padding vertical de 1px do .badge genérico (pensado pra um badge de uma linha só) deixa o\n     texto quase colado nas bordas de cima/baixo quando quebra; display:inline-block faz o padding\n     valer pra caixa inteira, não só a ponta da primeira/última linha de um span inline. */\n  #optBadge{display:inline-block;padding:5px 8px;line-height:1.5}\n  .sheet-bg{position:fixed;inset:0;background:rgba(28,28,30,.34);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;z-index:40;padding:24px}\n  .sheet-bg.hidden{display:none}\n  .sheet{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:0 24px 60px rgba(0,0,0,.22);width:min(580px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden}\n  .sheet-hd{display:flex;align-items:center;gap:9px;padding:12px 16px;border-bottom:1px solid var(--line)}\n  .sheet-hd h3{margin:0;font-size:14px;font-weight:600;letter-spacing:-.1px}\n  .sheet-x{background:transparent;border:0;color:var(--mut);font-size:13px;cursor:pointer;padding:3px 7px;border-radius:6px}\n  .sheet-x:hover{background:var(--panel2);color:var(--ink)}\n  .sheet-bd{padding:14px 16px;overflow:auto}\n  .sheet-ft{display:flex;align-items:center;gap:8px;padding:11px 16px;border-top:1px solid var(--line);background:var(--panel2)}\n  .opt-lbl{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600}\n  .opt-sub{font-size:11.5px;color:var(--mut);margin:4px 0 10px;line-height:1.5}\n  .opt-row{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-bottom:8px;cursor:pointer;background:var(--panel)}\n  .opt-row:hover{background:var(--panel2)}\n  .opt-row.on{border-color:var(--acc);background:#eaf3ff}\n  .opt-row:has(input:disabled){opacity:.5;cursor:not-allowed}\n  .opt-row input[type=radio]{margin:2px 0 0;accent-color:var(--acc);flex:0 0 auto}\n  .opt-t{font-size:12.5px;font-weight:600;display:flex;align-items:center;gap:6px;flex-wrap:wrap}\n  .opt-d{font-size:11.5px;color:var(--mut);margin-top:3px;line-height:1.55}\n  .opt-inline{font-size:11.5px;color:var(--mut);margin-top:9px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}\n  .opt-inline.off{opacity:.4;pointer-events:none}\n  .opt-note{border:1px solid var(--line);background:var(--panel2);border-radius:10px;padding:9px 11px;font-size:11.5px;color:var(--mut);line-height:1.6;margin-top:12px}\n  .opt-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:2px}\n  .opt-fld{display:flex;flex-direction:column;gap:5px;border:1px solid var(--line);border-radius:9px;padding:8px 10px;background:var(--panel2)}\n  .opt-fld span{font-size:11px;color:var(--mut);font-weight:600;line-height:1.35}\n  .opt-fld i{font-style:normal;font-weight:400;opacity:.72}\n  .opt-fld input{width:100%;background:var(--panel)}\n  .opt-err{margin-top:9px;font-size:11.5px;color:var(--closing);font-weight:600}\n  .opt-hr{height:1px;background:var(--line);margin:17px 0 0}\n  .shareurl{margin-top:9px;padding:9px 10px;border-radius:8px;background:var(--panel);border:1px solid var(--acc);\n    font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--ink);word-break:break-all;user-select:all}\n`,\n  render(d2){\n    return `<div class=\"simroot\"><header id=\"topHeader\" class=\"hidden\">\n  <span class=\"pill live\" id=\"liveState\" role=\"button\" tabindex=\"0\" title=\"Clique pra atualizar o snapshot para o mais recente\">● iniciando</span>\n  <span class=\"status\" id=\"ds\"></span>\n  <div class=\"mac-spacer\"></div>\n  <select id=\"sessionPick\" style=\"min-width:200px\"></select>\n  <button class=\"ghost\" id=\"renameSession\" title=\"Renomear esta sessão\">✎</button>\n  <button class=\"ghost\" id=\"openOptions\" title=\"Configurações desta sessão\">⚙ Opções</button>\n  <button class=\"act\" id=\"saveSession\">Salvar sessão</button>\n  <button class=\"ghost\" id=\"dupSession\" title=\"Cria uma cópia independente desta sessão — a original fica intacta e a cópia passa a ser a sessão ativa\">⧉ Duplicar</button>\n  <button class=\"ghost\" id=\"openShare\" title=\"Publicar uma cópia congelada desta sessão em modo leitura\">⇪ Compartilhar</button>\n  <span class=\"status\" id=\"sessStatus\"></span>\n</header>\n\n<div class=\"rail hidden\" id=\"timelineRail\">\n  <div id=\"movementChips\"></div>\n  <div id=\"addMovementGroup\">\n    <button class=\"act\" id=\"openMove\">Adicionar movimentação</button>\n  </div>\n</div>\n\n<div class=\"card\" id=\"confirmCard\" style=\"margin:14px 20px\">\n  <h2 style=\"margin:0 0 9px\">Confirmar snapshot antes de simular</h2>\n  <div class=\"note\" id=\"confirmBody\">congelando snapshot…</div>\n  <div class=\"opt-hr\"></div>\n  <div class=\"opt-lbl\">Ciclo operacional (ELO)</div>\n  <div class=\"opt-sub\">Usado no relatório de tendência (ELO) por etapa. Escolha antes de simular — digite um ciclo específico ou use o mais recente fechado.</div>\n  <div class=\"opt-inline\">\n    <input type=\"number\" id=\"eloCycleInput\" placeholder=\"ex: 202608\" style=\"min-width:140px\">\n    <button class=\"ghost\" id=\"eloCycleApply\">Usar este ciclo</button>\n    <button class=\"ghost\" id=\"eloCycleAuto\">Usar o ciclo mais recente</button>\n  </div>\n  <div class=\"status\" id=\"eloCycleStatus\" style=\"margin-top:4px\"></div>\n  <button class=\"act\" id=\"confirmSnap\" style=\"margin-top:10px\">Confirmar snapshot ▶ ir pra simulação</button>\n  <div class=\"note\" style=\"margin-top:16px\">ou continue uma sessão salva:</div>\n  <select id=\"confirmSessionPick\" style=\"min-width:260px;margin-top:4px\"></select>\n  <div class=\"status\" id=\"confirmStatus\" style=\"margin-top:8px\"></div>\n</div>\n\n<div class=\"wrap hidden\" id=\"mainWrap\">\n  <div>\n    <div class=\"card\">\n      <div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:9px\">\n        <div class=\"toggle\" id=\"viewToggle\" title=\"ASIS = estrutura do setor antes desta etapa (já considerando etapas anteriores) · TOBE = após a redistribuição desta etapa\">\n          <button data-v=\"asis\">◧ ASIS (antes desta etapa)</button>\n          <button data-v=\"tobe\" class=\"on\">◨ TOBE (simulação)</button>\n        </div>\n      </div>\n      <div class=\"map-wrap\">\n        <svg id=\"map\" viewBox=\"0 0 700 500\" preserveAspectRatio=\"xMidYMid meet\"></svg>\n        <button class=\"osm-panel-icon hidden\" id=\"osmPanelIcon\" title=\"Mostrar camadas\">\n          <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\">\n            <path d=\"M12 2 2 7l10 5 10-5-10-5Z\" fill=\"currentColor\"/>\n            <path d=\"m2 12 10 5 10-5\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" opacity=\".55\"/>\n            <path d=\"m2 17 10 5 10-5\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" opacity=\".3\"/>\n          </svg>\n        </button>\n        <div class=\"osm-panel\" id=\"osmPanel\" title=\"Camadas exibidas no mapa, limitadas à área desta etapa\">\n          <div class=\"osm-panel-hd\">Camadas<button class=\"sheet-x osm-panel-x\" id=\"osmPanelX\" title=\"Recolher\">✕</button></div>\n          <div class=\"osm-group-hd\">Navegação</div>\n          <label class=\"osm-row\" title=\"Marca liga todos os níveis viários de uma vez; depois de carregado, esconda um nível por vez abaixo\"><input type=\"checkbox\" id=\"osmRodovias\"><span>Rodovias</span></label>\n          <label class=\"osm-row osm-sub\" title=\"highway=motorway\"><input type=\"checkbox\" id=\"osmMotorway\"><span>Rodovia expressa</span></label>\n          <label class=\"osm-row osm-sub\" title=\"highway=trunk\"><input type=\"checkbox\" id=\"osmTrunk\"><span>Via arterial</span></label>\n          <label class=\"osm-row osm-sub\" title=\"highway=primary\"><input type=\"checkbox\" id=\"osmPrimary\"><span>Rodovia principal</span></label>\n          <label class=\"osm-row osm-sub\" title=\"highway=secondary\"><input type=\"checkbox\" id=\"osmSecondary\"><span>Rodovia secundária</span></label>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"osmRivers\"><span>Rios</span></label>\n          <div class=\"osm-group-hd sep\">Outros</div>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"osmAirports\"><span>Aeroportos</span></label>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"muniOutline\"><span id=\"muniOutlineLbl\">Contorno de municípios</span></label>\n          <div class=\"osm-group-hd sep\">Legendas</div>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"legendMuni\"><span id=\"legendMuniLbl\">Nome de municípios</span></label>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"legendCtx\"><span>Código de setores adjacentes</span></label>\n          <div class=\"osm-status\" id=\"osmStatus\"></div>\n        </div>\n        <div class=\"zoom-pill\" id=\"mapZoom\">\n          <button type=\"button\" id=\"mapZoomOut\" title=\"Diminuir zoom\">−</button>\n          <span class=\"zoom-div\"></span>\n          <button type=\"button\" id=\"mapZoomIn\" title=\"Aumentar zoom\">+</button>\n        </div>\n      </div>\n      <div class=\"legend\" id=\"legend\"></div>\n      <div class=\"note\" id=\"mapLegendNote\">Coloridos = municípios do setor fechado nesta etapa (cor = destino). Ao redor, cada área é um <b>setor vizinho inteiro</b> com o código no meio — não municípios soltos: cor = setor destino, cinza = contexto, cinza tracejado = setor já fechado numa etapa anterior. Shapes reais IBGE/tbrugz.</div>\n      <div class=\"note\" id=\"mapIgnoredNote\"></div>\n    </div>\n\n    <div class=\"card\">\n      <h2 id=\"leadersTitle\">Líderes — destino e CBs deslocadas (nesta etapa)</h2>\n      <div class=\"note\" id=\"leadersNote\" style=\"margin:-4px 0 9px\"><b>Home</b> = setor que ficou com a maioria das CBs da líder. <b style=\"color:var(--closing)\">Deslocadas</b> = CBs dela que caíram noutro setor.</div>\n      <div class=\"scroll grow\"><table id=\"leaders\"></table></div>\n    </div>\n\n    <div class=\"card hidden\" id=\"leadersXferBlock\">\n      <h2 id=\"leadersXferTitle\">Líder da transferência manual</h2>\n      <div class=\"scroll grow\"><table id=\"leadersXfer\"></table></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"tabs\">\n        <button data-tab=\"matrix\" class=\"on\">Matriz ASIS</button>\n        <button data-tab=\"tobe\">Matriz TOBE</button>\n        <button data-tab=\"report\">Relatório de impacto (etapa)</button>\n        <button data-tab=\"cumulative\">Impacto acumulado (timeline)</button>\n        <button data-tab=\"sql\">Console SQL</button>\n      </div>\n      <div id=\"tab-matrix\"><div class=\"scroll grow\"><table id=\"matrix\"></table></div>\n        <div id=\"matrixXferBlock\" class=\"hidden\" style=\"margin-top:14px\">\n          <h2 id=\"matrixXferTitle\">Município transferido</h2>\n          <div class=\"scroll grow\"><table id=\"matrixXfer\"></table></div>\n        </div>\n      </div>\n      <div id=\"tab-tobe\" class=\"hidden\"><div class=\"scroll grow\"><table id=\"matrixTobe\"></table></div>\n        <div id=\"matrixTobeXferBlock\" class=\"hidden\" style=\"margin-top:14px\">\n          <h2 id=\"matrixTobeXferTitle\">Município transferido</h2>\n          <div class=\"scroll grow\"><table id=\"matrixTobeXfer\"></table></div>\n        </div>\n        <div class=\"note\" id=\"tobeNote\"></div></div>\n      <div id=\"tab-report\" class=\"hidden\"></div>\n      <div id=\"tab-cumulative\" class=\"hidden\"></div>\n      <div id=\"tab-sql\" class=\"hidden\">\n        <textarea class=\"sqlbox\" id=\"sqlIn\">SELECT DS_MUNICIPIO, COUNT(DISTINCT CD_CONSULTORA) qtd FROM product.perfil_cb.crm_perfil_cb_diaria_historico WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=3504 GROUP BY DS_MUNICIPIO ORDER BY qtd DESC</textarea>\n        <div class=\"toolbar\" style=\"margin:8px 0\"><button class=\"act\" id=\"runSql\">▶ Rodar</button><span class=\"status\" id=\"sqlStatus\"></span></div>\n        <div class=\"note\" style=\"margin:-4px 0 8px\">Roda contra o snapshot congelado desta sessão (não \"ao vivo\") — mesma coerência da timeline.</div>\n        <div class=\"scroll\"><table id=\"sqlOut\"></table></div>\n      </div>\n    </div>\n  </div>\n\n  <div>\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"margin-bottom:11px\">\n        <button class=\"ghost\" id=\"optim\" title=\"testa alocações que mantêm cada setor conectado e escolhe a que equilibra a carga entre setores + minimiza a quebra (evita empilhar tudo num setor)\">★ Modelo ÓTIMO (equilíbrio + quebra)</button>\n        <button class=\"ghost\" id=\"autoTobe\" title=\"preenche cada município pelo setor vizinho mais próximo (geografia)\">TOBE por adjacência</button>\n        <span class=\"badge\" id=\"optBadge\"></span>\n      </div>\n      <div class=\"note\" style=\"margin:-4px 0 10px\"><b style=\"color:var(--ink)\">★ ÓTIMO</b> = equilibra a carga entre os setores <b>e</b> minimiza a quebra (mantém o território conectado; não empilha tudo num setor). <b style=\"color:var(--ink)\">TOBE</b> = sugestão por vizinhança. Editar esta etapa recalcula automaticamente as etapas seguintes da timeline.</div>\n      <div class=\"note\" id=\"ruleNote\" style=\"margin:-6px 0 11px;padding:7px 9px;background:var(--panel2);border:1px solid var(--line);border-radius:8px\"></div>\n      <div class=\"kpis\" id=\"kpis\"></div>\n      <div id=\"contig\"></div>\n      <div id=\"spanRule\"></div>\n      <h2 id=\"spansHd\">Span por setor (CBs) — base + entrantes</h2>\n      <div id=\"spans\"></div>\n      <div id=\"policySection\">\n      <div style=\"display:flex;justify-content:space-between;align-items:center;margin:14px 0 7px\">\n        <h2 style=\"margin:0\">CBs deslocadas</h2>\n        <div class=\"toggle\" id=\"policy\">\n          <button data-p=\"fora\" class=\"on\">= fora do setor</button>\n          <button data-p=\"quebra\">= quebra de relação</button>\n        </div>\n      </div>\n      <div class=\"note\" id=\"policyNote\"></div>\n      </div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"justify-content:space-between\">\n        <h2 id=\"muniListTitle\" style=\"margin:0\">Municípios → setor destino</h2>\n        <button class=\"ghost\" id=\"togglePull\" title=\"Habilitar movimentação de municípios de outros setores\">✎</button>\n      </div>\n      <div class=\"note\" id=\"muniListNote\" style=\"margin:-4px 0 9px\">Clique no <b>código do setor</b> pra mandar o município <b>inteiro</b> pra lá (regra de negócio). A cor acompanha o mapa.</div>\n      <div id=\"muniList\"></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"justify-content:space-between\">\n        <h2 style=\"margin:0\">Plano de migração (etapa selecionada)</h2>\n        <div><button class=\"ghost\" id=\"copyPlan\">Copiar</button> <button class=\"ghost\" id=\"csvPlan\">CSV</button></div>\n      </div>\n      <div id=\"plan\" class=\"note\"></div>\n    </div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"shareBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"shareTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"shareTitle\">Compartilhar sessão</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"shareX\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\" id=\"shareBody\"></div>\n    <div class=\"sheet-ft\">\n      <span class=\"status\" id=\"shareStatus\"></span>\n      <div class=\"mac-spacer\"></div>\n      <button class=\"ghost hidden\" id=\"shareCopy\">Copiar link</button>\n      <button class=\"ghost hidden\" id=\"shareOpen\">Abrir</button>\n      <button class=\"ghost\" id=\"shareCancel\">Cancelar</button>\n      <button class=\"act\" id=\"shareGo\">Compartilhar</button>\n    </div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"optionsBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"optTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"optTitle\">Opções da sessão</h3><span class=\"badge\" id=\"optSessName\"></span><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"optClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-lbl\">Pré-requisitos do destino — tamanho em CBs</div>\n      <div class=\"opt-sub\">Vale pro tamanho de cada unidade receptora <b>depois</b> da redistribuição (base + entrantes) — uma escala por nível de fechamento. Em branco = desligado.</div>\n      <div class=\"opt-t\" style=\"margin-top:2px\">Setor</div>\n      <div class=\"opt-grid\">\n        <label class=\"opt-fld\"><span>Span alvo <i>(tamanho ideal)</i></span><input type=\"number\" id=\"optTargetSpan_setor\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"livre\"></label>\n        <label class=\"opt-fld\"><span>Span máximo <i>(nenhum setor acima)</i></span><input type=\"number\" id=\"optMaxSpan_setor\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n        <label class=\"opt-fld\"><span>Span mínimo <i>(nenhum setor abaixo)</i></span><input type=\"number\" id=\"optMinSpan_setor\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n      </div>\n      <div class=\"opt-t\" style=\"margin-top:12px\">GV</div>\n      <div class=\"opt-grid\">\n        <label class=\"opt-fld\"><span>Span alvo <i>(tamanho ideal)</i></span><input type=\"number\" id=\"optTargetSpan_gv\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"livre\"></label>\n        <label class=\"opt-fld\"><span>Span máximo <i>(nenhuma GV acima)</i></span><input type=\"number\" id=\"optMaxSpan_gv\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n        <label class=\"opt-fld\"><span>Span mínimo <i>(nenhuma GV abaixo)</i></span><input type=\"number\" id=\"optMinSpan_gv\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n      </div>\n      <div class=\"opt-hr\"></div>\n      <div class=\"opt-lbl\" style=\"margin-top:14px\">Conectividade de região</div>\n      <div class=\"opt-sub\">Vale para fechamento de Setor, GV e Mercado. Controla o que acontece quando um clique no mapa deixaria alguma unidade (município, setor ou GV, conforme o nível) com o território partido em pedaços desconectados — o aviso <b style=\"color:var(--closing)\">⚠ Território desconectado</b> abaixo do mapa.</div>\n      <label class=\"opt-row\"><input type=\"radio\" name=\"forceContig\" value=\"0\"><div><div class=\"opt-t\">Livre</div><div class=\"opt-d\">Clicar avança pro próximo destino da lista, mesmo que isso deixe o território de alguma unidade desconectado. Resolva depois com ★ ÓTIMO ou reassociando manualmente no mapa.</div></div></label>\n      <label class=\"opt-row\"><input type=\"radio\" name=\"forceContig\" value=\"1\"><div><div class=\"opt-t\">Forçar conectividade de território <i style=\"font-weight:400;color:var(--mut)\">(padrão)</i></div><div class=\"opt-d\">Clicar nunca deixa uma unidade desconectada: pula o(s) destino(s) da lista que quebrariam a conectividade e escolhe o próximo que mantém tudo conectado. Se nenhum destino for seguro, o clique não muda nada.</div></div></label>\n      <div class=\"opt-err hidden\" id=\"optSpanErr\"></div>\n      <div class=\"opt-note\" id=\"optPreview\"></div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"optStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"optCancel\">Cancelar</button><button class=\"act\" id=\"optApply\">Aplicar</button></div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"moveBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"moveTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"moveTitle\">Adicionar movimentação</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"moveClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-lbl\">Tipo de movimentação</div>\n      <div class=\"opt-sub\" id=\"moveLockNote\">Escolha o nível a fechar. Você pode fechar setores à vontade até fechar uma GV; aí só GV (ou mercado) daqui pra frente. Fechar um mercado trava a sessão em mercado.</div>\n      <label class=\"opt-row\" data-lvl=\"setor\"><input type=\"radio\" name=\"moveLevel\" value=\"setor\"><div><div class=\"opt-t\">Fechar Setor</div><div class=\"opt-d\">Redistribui os municípios do setor entre setores vizinhos da mesma GV.</div></div></label>\n      <label class=\"opt-row\" data-lvl=\"gv\"><input type=\"radio\" name=\"moveLevel\" value=\"gv\"><div><div class=\"opt-t\">Fechar GV</div><div class=\"opt-d\">Redistribui os setores (com municípios e CBs) da GV entre GVs vizinhas do mesmo mercado.</div></div></label>\n      <label class=\"opt-row\" data-lvl=\"mercado\"><input type=\"radio\" name=\"moveLevel\" value=\"mercado\"><div><div class=\"opt-t\">Fechar Mercado</div><div class=\"opt-d\">Redistribui as GVs (com setores, municípios e CBs) do mercado entre mercados vizinhos.</div></div></label>\n      <div class=\"opt-hr\"></div>\n      <div class=\"opt-lbl\" style=\"margin-top:15px\" id=\"moveCodeLbl\">Setor a fechar</div>\n      <div class=\"opt-inline\" id=\"moveCodeRow\">\n        <input id=\"moveCode\" list=\"moveCodeList\" placeholder=\"código\" autocomplete=\"off\" style=\"min-width:150px\">\n        <datalist id=\"moveCodeList\"></datalist>\n        <button class=\"ghost\" id=\"moveRand\" title=\"Aleatório\">🎲</button>\n      </div>\n      <div class=\"opt-err hidden\" id=\"moveErr\"></div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"moveStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"moveCancel\">Cancelar</button><button class=\"act\" id=\"moveGo\">Fechar ▶ (nova etapa)</button></div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"pullBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"pullTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"pullTitle\">Mover município</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"pullClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-lbl\">Município</div>\n      <div class=\"opt-sub\" id=\"pullMuniInfo\"></div>\n      <div class=\"opt-hr\"></div>\n      <div class=\"opt-lbl\" style=\"margin-top:15px\">Mandar pra</div>\n      <div class=\"seg\" id=\"pullDestSeg\"></div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"pullStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"pullCancel\">Cancelar</button><button class=\"act\" id=\"pullGo\">Aplicar</button></div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"lockedBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"lockedTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"lockedTitle\">Sessão travada num snapshot antigo</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"lockedClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-sub\" id=\"lockedBody\">Esta sessão foi carregada a partir dos dados já calculados e congelados quando ela foi salva — nenhuma consulta rodou ainda. Atualizar o snapshot agora recalcula a timeline inteira contra os dados mais recentes, o que pode mudar os resultados desta simulação (destinos, spans, CBs deslocadas etc).</div>\n      <div class=\"opt-note\">Depois de atualizar, a sessão deixa de estar travada e você pode repetir a ação que tentou fazer.</div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"lockedStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"lockedCancel\">Cancelar</button><button class=\"act\" id=\"lockedGo\">Atualizar snapshot</button></div>\n  </div>\n</div>\n\n\n\n\n\n<div class=\"tip\" id=\"tip\"></div>\n<span id=\"ready\" style=\"display:none\"></span></div>`;\n  },\n  mounted(d2, root){\n\nconst $=s=>root.querySelector(s);\nconst fmt=n=>n==null?'–':Math.round(+n).toLocaleString('pt-BR');\nconst money=n=>{n=+n||0;return n>=1e6?'R$'+(n/1e6).toFixed(1)+'M':n>=1e3?'R$'+(n/1e3).toFixed(0)+'k':'R$'+Math.round(n)};\nconst norm=s=>(s||'').normalize('NFKD').replace(/[̀-ͯ]/g,'').toUpperCase().replace(/[^A-Z0-9]+/g,' ').trim();\nconst esc=s=>(s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\n// Single #tip element shared by every hover surface on the map (setores, municípios, aeroportos,\n// descartadas). Positioned relative to the cursor with a small offset, then clamped against the\n// viewport so a long list (a shared município with a couple dozen setores) never gets cut off past\n// the right/bottom edge — offsetWidth/Height are read AFTER innerHTML is set, since the box's size\n// depends on its content, not on wherever it was last drawn.\nconst showTip=(e,html)=>{\n  const t=$('#tip'); if(!t) return;\n  t.innerHTML=html;\n  t.style.opacity=1;\n  let x=e.clientX+12, y=e.clientY+12;\n  const w=t.offsetWidth, h=t.offsetHeight;\n  if(x+w>window.innerWidth-8) x=e.clientX-w-12;\n  if(y+h>window.innerHeight-8) y=e.clientY-h-12;\n  t.style.left=Math.max(8,x)+'px';\n  t.style.top=Math.max(8,y)+'px';\n};\n// ---------- macOS-style dropdown ----------\n// A native <select>/<datalist> OPEN-LIST popup is drawn by the browser itself and cannot be\n// restyled on any engine — only the closed control can be skinned. Everything below repaints the\n// open list as a real DOM element (an actual AppKit-style popover) while the underlying\n// <select>/<input> stays the real state — same .value/.onchange/.options, every existing\n// read/write call site untouched.\nlet macGlobalCloseBound=false;\n// Panels used to be a plain child of their .macsel wrapper, positioned relative to it — fine until\n// one landed inside the movimentação modal (.sheet, overflow:hidden ⊃ .sheet-bd, overflow:auto),\n// which clipped it to the modal's own box the moment it tried to extend past it. A first fix (a\n// fixed-position portal still inside this shadow root) escaped the clipping but NOT the stacking\n// fight against the modal's own backdrop-filter layer — panels rendered behind it despite a far\n// higher z-index. macPortal() lives in document.body instead, the platform's own documented\n// pattern for any overlay a code component draws (see code-component-overlays): body content\n// stacks against the WHOLE page, immune to any shadow-DOM/backdrop-filter compositing quirk, and\n// isn't reachable by this shadow tree's scoped `css`, so its rules are injected as a plain <style>\n// right here instead — colours hardcoded since var(--ink) etc. only resolve inside :host.\nfunction macPortal(){\n  let p=document.getElementById('macPortal');\n  if(p) return p;\n  p=document.createElement('div'); p.id='macPortal';\n  // isolation:isolate + its own z-index, not just relying on DOM order/appendChild-puts-it-last:\n  // without this, #macPortal (z-index:auto) never forms its own stacking context, so its child\n  // .macsel-panel's z-index couldn't be trusted to outrank whatever ELSE the host page already has\n  // sitting at the same fixed/root level (verified empirically — the panel rendered fully invisible,\n  // behind an unrelated body-level layer belonging to the app's own chrome, despite a much higher\n  // z-index on the panel itself, until the portal was made its own isolated context here).\n  p.style.cssText='position:fixed;left:0;top:0;width:0;height:0;z-index:2147483000;isolation:isolate;';\n  const style=document.createElement('style');\n  style.textContent=`\n    #macPortal .macsel-panel{position:fixed;min-width:160px;max-width:min(420px,90vw);max-height:280px;overflow:auto;z-index:2147483000;\n      background:rgba(255,255,255,.96);backdrop-filter:blur(30px) saturate(180%);-webkit-backdrop-filter:blur(30px) saturate(180%);\n      border:1px solid rgba(0,0,0,.08);border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.20);padding:5px;\n      font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;color:#1c1c1e;-webkit-font-smoothing:antialiased}\n    #macPortal .macsel-panel.hidden{display:none}\n    #macPortal .macsel-opt{padding:6px 9px;border-radius:6px;cursor:pointer;font-size:13px;white-space:nowrap}\n    #macPortal .macsel-opt:hover{background:rgba(0,0,0,.05)}\n    #macPortal .macsel-opt.sel{background:#0064e1;color:#fff}\n    #macPortal .macsel-empty{padding:6px 9px;font-size:12px;color:#6e6e73}\n  `;\n  p.appendChild(style);\n  document.body.appendChild(p);\n  if(d2.onUnmount) d2.onUnmount(()=>p.remove());\n  return p;\n}\n// #loader lived inside .simroot (position:relative;height:100%;overflow:auto) as position:absolute;inset:0 —\n// anchored to .simroot's own (one-screenful) padding box, not to its taller scrollable CONTENT, so it only\n// ever dimmed the top screenful and never moved with scroll, leaving real content exposed below it once the\n// user scrolled past that first screen. Same class of bug macPortal() above already fixed for select panels:\n// portal it to document.body as a true position:fixed;inset:0 overlay, immune to any shadow-DOM containing\n// block or .simroot's own scrolling.\nfunction loaderPortal(){\n  let p=document.getElementById('loaderPortal');\n  if(p) return p;\n  p=document.createElement('div'); p.id='loaderPortal'; p.className='hidden';\n  p.style.cssText='position:fixed;inset:0;z-index:2147483000;';\n  const style=document.createElement('style');\n  style.textContent=`\n    #loaderPortal{background:rgba(245,245,247,.85);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;color:#007aff;font-weight:600;text-align:center;padding:20px}\n    #loaderPortal.hidden{display:none}\n  `;\n  document.body.appendChild(style);\n  document.body.appendChild(p);\n  if(d2.onUnmount) d2.onUnmount(()=>{ p.remove(); style.remove(); });\n  return p;\n}\nfunction ensureMacGlobalClose(){\n  if(macGlobalCloseBound) return; macGlobalCloseBound=true;\n  // e.target is retargeted to the shadow HOST for a listener bound on document for any click that\n  // originated inside the shadow tree (the trigger buttons/inputs still live there). composedPath()\n  // carries the real, un-retargeted ancestor chain through the shadow boundary, so checking it\n  // instead is what lets the click that OPENS a panel not immediately close it again on the very\n  // same tick. Panels live in macPortal() (document.body), detached from their trigger's DOM\n  // subtree entirely — panel and button are in different documents-worth of nesting, not\n  // parent/child — so p.closest('.macsel') can never answer \"was this click on the trigger\"; each\n  // panel keeps its trigger on ._macTrigger at creation time instead, checked against the same path.\n  document.addEventListener('click',e=>{\n    const path=e.composedPath();\n    document.querySelectorAll('#macPortal .macsel-panel').forEach(p=>{ if(!path.includes(p)&&!(p._macTrigger&&path.includes(p._macTrigger))) p.classList.add('hidden'); });\n  });\n}\nfunction closeAllMacSelects(){ document.querySelectorAll('#macPortal .macsel-panel').forEach(p=>p.classList.add('hidden')); }\nfunction positionMacPanel(trigger,panel){\n  const r=trigger.getBoundingClientRect();\n  const up=window.innerHeight-r.bottom<280 && r.top>280;\n  panel.classList.toggle('up', up);\n  panel.style.left=Math.round(r.left)+'px';\n  panel.style.minWidth=Math.round(r.width)+'px';\n  if(up){ panel.style.bottom=Math.round(window.innerHeight-r.top+4)+'px'; panel.style.top=''; }\n  else{ panel.style.top=Math.round(r.bottom+4)+'px'; panel.style.bottom=''; }\n}\nfunction buildMacPanel(sel,panel){\n  const opts=[...sel.options];\n  panel.innerHTML=opts.length?'':'<div class=\"macsel-empty\">nenhuma opção</div>';\n  opts.forEach(o=>{\n    const row=document.createElement('div'); row.className='macsel-opt'+(o.selected?' sel':''); row.setAttribute('role','option');\n    row.textContent=o.textContent;\n    row.onmousedown=e=>{ e.preventDefault(); sel.value=o.value; panel.classList.add('hidden'); syncMacSelect(sel); sel.dispatchEvent(new Event('change')); };\n    panel.appendChild(row);\n  });\n}\nfunction syncMacSelect(sel){\n  const wrap=sel.closest('.macsel'); if(!wrap) return;\n  const lbl=wrap.querySelector('.macsel-lbl'); if(!lbl) return;\n  const cur=sel.options[sel.selectedIndex];\n  lbl.textContent=cur?cur.textContent:'';\n}\nfunction initMacSelect(sel){\n  if(!sel) return;\n  if(sel.dataset.macInit){ syncMacSelect(sel); return; }\n  sel.dataset.macInit='1';\n  const wrap=document.createElement('div'); wrap.className='macsel';\n  wrap.style.cssText=sel.getAttribute('style')||'';\n  sel.parentNode.insertBefore(wrap,sel); wrap.appendChild(sel);\n  sel.style.display='none';\n  const btn=document.createElement('button'); btn.type='button'; btn.className='macsel-btn';\n  btn.innerHTML='<span class=\"macsel-lbl\"></span><span class=\"macsel-car\">▾</span>';\n  const panel=document.createElement('div'); panel.className='macsel-panel hidden'; panel.setAttribute('role','listbox');\n  panel._macTrigger=btn;\n  wrap.appendChild(btn); macPortal().appendChild(panel);\n  btn.onclick=()=>{\n    const willOpen=panel.classList.contains('hidden');\n    closeAllMacSelects();\n    if(!willOpen) return;\n    buildMacPanel(sel,panel); panel.classList.remove('hidden'); positionMacPanel(btn,panel);\n  };\n  ensureMacGlobalClose();\n  syncMacSelect(sel);\n}\nfunction initMacCombo(inp,dl){\n  if(!inp||!dl||inp.dataset.macInit) return;\n  inp.dataset.macInit='1';\n  inp.removeAttribute('list');\n  const wrap=document.createElement('div'); wrap.className='macsel'; wrap.style.display='inline-block';\n  inp.parentNode.insertBefore(wrap,inp); wrap.appendChild(inp);\n  const panel=document.createElement('div'); panel.className='macsel-panel hidden'; panel.setAttribute('role','listbox');\n  panel._macTrigger=inp;\n  macPortal().appendChild(panel);\n  const buildPanel=()=>{\n    const qv=norm(inp.value);\n    const opts=[...dl.options].filter(o=>!qv||norm(o.value+' '+o.textContent).indexOf(qv)>=0);\n    // Options come from an async load (ensureDominantes) that can take a moment on a cold cache —\n    // an empty <datalist> mid-load looked identical to a genuinely empty result set, so opening the\n    // combo right after the modal appeared always read \"nenhum resultado\" instead of \"ainda\n    // carregando\". dl.dataset.loading (set by loadMoveOptions) is what tells the two apart.\n    panel.innerHTML=opts.length?'':(dl.dataset.loading==='1'?'<div class=\"macsel-empty\">carregando…</div>':'<div class=\"macsel-empty\">nenhum resultado</div>');\n    opts.forEach(o=>{\n      const row=document.createElement('div'); row.className='macsel-opt'+(o.value===inp.value?' sel':'');\n      row.textContent=o.textContent;\n      // dispatchEvent runs synchronously — firing it BEFORE hiding meant our own 'input' listener\n      // (openFiltered) reopened the panel on the same tick, right after this handler had just\n      // closed it: picking a row looked like it never closed. Hiding last, after the dispatch has\n      // already run and reopened it, is what actually wins.\n      row.onmousedown=e=>{ e.preventDefault(); inp.value=o.value; inp.dispatchEvent(new Event('input')); panel.classList.add('hidden'); };\n      panel.appendChild(row);\n    });\n  };\n  const openFiltered=()=>{\n    // Defensive: nothing else in this file re-adds `list`, but this is the one control whose panel\n    // reopens on every keystroke — cheap insurance against the native popup ever winning a race\n    // against the attribute removal that runs once at init, above.\n    inp.removeAttribute('list');\n    buildPanel();\n    panel.classList.remove('hidden'); positionMacPanel(inp,panel);\n  };\n  inp.addEventListener('focus',openFiltered);\n  inp.addEventListener('input',openFiltered);\n  ensureMacGlobalClose();\n  // Lets an async option load (loadMoveOptions) push a refreshed panel once it lands, WITHOUT\n  // force-opening a panel the user never touched — only rebuilds when the panel is already visible.\n  inp._macRefresh=()=>{ if(!panel.classList.contains('hidden')) buildPanel(); };\n}\nconst blankM=()=>({cbs:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0,ativas:0});\n// ---------- município identity ----------\n// Brazil has many municipalities sharing a name (Bom Jesus alone exists in PI, RN, RS, SC, PB, GO).\n// A stage loads the dominant UF PLUS every bordering UF, so those collisions land in the SAME\n// feature set routinely: keyed by name alone the last shape loaded won, the map could draw a\n// município from the wrong state, buildAdj merged two unrelated municipalities into one node, and\n// GROUP BY DS_MUNICIPIO summed two real municipalities into one row. The key is NAME/UF everywhere\n// — SQL rows, GeoJSON features, the dominantes base, assignments, persisted sessions. norm() maps\n// every non-alphanumeric to a space, so '/' can never occur inside a name and the split is exact.\n// A missing UF degrades to a bare NAME key rather than inventing one.\n// A handful of municípios carry two spellings in the wild — an older one still used internally (CRM\n// export, this dominantes CSV) and the current official IBGE spelling used by the geodata mirror.\n// Left alone, the two spellings key to different places: the shape draws with no dominance entry\n// (\"setor não encontrado\") even though the base has it under the other spelling. Canonicalized here,\n// once, to whichever spelling the CRM base uses — so display stays consistent with every other\n// município name in this app, which comes from the CRM, never from IBGE.\n// Aliases base (embutidos, sempre valem mesmo se a tabela abaixo estiver vazia ou fora do ar).\n// A tabela Data2 (MUNI_ALIAS_DS) deixa o time acrescentar/corrigir grafias sem precisar mexer\n// neste componente — carregada uma vez no boot (loadMuniAliases) e mesclada por cima destes.\nlet MUNI_ALIAS={BRASOPOLIS:'BRAZOPOLIS','DONA EUSEBIA':'DONA EUZEBIA'};\nconst MUNI_ALIAS_DS='gb9Mu-5_9AcCDK-myWwSyDataSet';\n// This table's whole point is letting someone WITHOUT code access fix a município name mismatch —\n// a failure that only ever shows up in devtools defeats that. It has in fact sat silently broken\n// before (a policy with no linked auth strategy, fixed separately) with zero visible signal anywhere.\n// Kept module-scope so drawMap's mapIgnoredNote (below) can render it next to the other \"why is this\n// município missing/wrong\" notes, not just log it.\nlet MUNI_ALIAS_ERROR=null;\nasync function loadMuniAliases(){\n  // A freshly policy-gated DataSet's backend definition can take a moment to finish registering\n  // after the policy lands — d2.data() returns null until it does (\"Definition not found:\n  // UserApplicationLayer <ref>\" in the console — the SAME warning fires once for the Databricks\n  // rush listener too, which plainly works, because THAT one gets called repeatedly over a session\n  // and gets more than one shot at winning the race). This function is a single boot-time call with\n  // no natural second attempt, so it needs its own bounded retry to get the same fair shot.\n  let ds=null;\n  for(let attempt=0; attempt<5 && !ds; attempt++){\n    if(attempt) await new Promise(r=>setTimeout(r,1200));\n    ds=(d2.data('muniAliasDataset')||d2.data(MUNI_ALIAS_DS));\n  }\n  if(!ds){ MUNI_ALIAS_ERROR='dataset de correções não resolvido após 5 tentativas (muniAliasDataset/'+MUNI_ALIAS_DS+' indisponível nesta instância)'; console.warn('[muniAlias] '+MUNI_ALIAS_ERROR); return; }\n  try{\n    await ds.refresh();\n    const recs=await ds.loadAll();\n    recs.forEach(r=>{ const de=norm(r.de), pa=norm(r.para); if(de&&pa) MUNI_ALIAS[de]=pa; });\n    MUNI_ALIAS_ERROR=null;\n  }catch(e){\n    // tabela de aliases é um complemento opcional — falha aqui não trava o boot, mas também não pode\n    // desaparecer sem rastro: guardada pra aparecer no mapa em vez de só no console, que ninguém\n    // sem devtools abertos jamais vai olhar.\n    MUNI_ALIAS_ERROR=(e&&e.message)||'falha desconhecida ao carregar';\n    console.warn('[muniAlias] falha ao carregar tabela de correções de grafia:',e);\n  }\n}\nconst muniN=s=>{const n=norm(s);return MUNI_ALIAS[n]||n;};\nconst mkey=(name,uf)=>{const n=muniN(name),u=String(uf==null?'':uf).trim().toUpperCase();return u?n+'/'+u:n;};\nconst mName=k=>{const s=String(k==null?'':k),i=s.lastIndexOf('/');return i<0?s:s.slice(0,i);};\nconst mUf=k=>{const s=String(k==null?'':k),i=s.lastIndexOf('/');return i<0?'':s.slice(i+1);};\nconst mDisp=k=>{const u=mUf(k);return u?mName(k)+' ('+u+')':mName(k);};\n// The base CSV is the ONLY source of a município's UF now, so its column is accepted in whatever\n// shape the base is maintained in: sigla, IBGE numeric code, or full state name. Anything that does\n// not resolve to a real sigla is treated as UNKNOWN rather than passed through — a bogus UF would key\n// a município to a shape that does not exist and drop it off the map without a word.\nconst UF_OF_CODE={}; Object.keys(UF_CODE).forEach(k=>{ UF_OF_CODE[UF_CODE[k]]=k; });\nconst UF_OF_NAME={ACRE:'AC',ALAGOAS:'AL',AMAPA:'AP',AMAZONAS:'AM',BAHIA:'BA',CEARA:'CE','DISTRITO FEDERAL':'DF','ESPIRITO SANTO':'ES',GOIAS:'GO',MARANHAO:'MA','MATO GROSSO':'MT','MATO GROSSO DO SUL':'MS','MINAS GERAIS':'MG',PARA:'PA',PARAIBA:'PB',PARANA:'PR',PERNAMBUCO:'PE',PIAUI:'PI','RIO DE JANEIRO':'RJ','RIO GRANDE DO NORTE':'RN','RIO GRANDE DO SUL':'RS',RONDONIA:'RO',RORAIMA:'RR','SANTA CATARINA':'SC','SAO PAULO':'SP',SERGIPE:'SE',TOCANTINS:'TO'};\nconst ufNorm=v=>{const s=norm(v); if(!s) return '';\n  if(UF_CODE[s]) return s;\n  if(UF_OF_CODE[s]) return UF_OF_CODE[s];\n  return UF_OF_NAME[s]||'';};\nconst loader=(on,t)=>{const l=loaderPortal();l.classList.toggle('hidden',!on);if(t)l.textContent=t;const sr=$('.simroot');if(sr)sr.style.overflow=on?'hidden':'';};\nconst setError=(msg)=>{ const t=$('#ds'); if(t){t.textContent=msg;t.classList.add('err');} const c=$('#confirmStatus'); if(c){c.textContent=msg;c.classList.add('err');} };\n\n// ---------- base de municípios dominantes (CSV) ----------\n// WHICH municipalities belong to a sector is a business fact, not something to infer from CB volume:\n// it comes from a maintained CSV (one row per setor x município) linked through the `dominantesCsvUrl`\n// param. Anything the closing sector has CBs in but that the base does NOT assign to it is spillover:\n// not realocated, not drawn on the map, and its CBs follow their leader. Loaded once per URL and\n// cached for the whole session; a failure is thrown, never swallowed — a silent fallback here would\n// quietly change every number on the screen.\nconst DOM={url:null,promise:null,bySetor:null,ufBySetorMuni:null,rows:0,setores:0,hasUf:false,ufCol:null,badUf:0,clashes:0,headers:null,error:null};\nfunction parseCsv(text){\n  const out=[]; let row=[],cur='',q=false;\n  const push=()=>{ row.push(cur); cur=''; };\n  const endRow=()=>{ push(); if(row.length>1||row[0]!=='') out.push(row); row=[]; };\n  for(let i=0;i<text.length;i++){ const ch=text[i];\n    if(q){ if(ch==='\"'){ if(text[i+1]==='\"'){ cur+='\"'; i++; } else q=false; } else cur+=ch; }\n    else if(ch==='\"') q=true;\n    else if(ch===',') push();\n    else if(ch==='\\n') endRow();\n    else if(ch!=='\\r') cur+=ch;\n  }\n  if(cur!==''||row.length) endRow();\n  return out;\n}\n// `dominantesCsvUrl` is declared as a FileComponentUrlReference, so the instance points at the Drive\n// FILE, not at a URL literal — the Drive is policy-gated behind the app's auth strategy and a baked\n// string would be both stale and unauthorised. What the resolver hands back varies by where it is\n// resolved (URL string / file record / bare reference), so accept every shape instead of silently\n// reading undefined and falling through to the fallback.\n// The Drive is gated by the app's AuthenticationStrategy, so this read has to travel as the LOGGED-IN\n// USER — an anonymous CDN GET is exactly what the policy is there to refuse. `<system>.data2.media` is\n// only a cache in front of the SSP: on a cache miss it 302s to `<system>.data2apis.com`, which is where\n// the policy is actually evaluated.\n// COOKIES CANNOT CARRY THAT SESSION, which is why the previous `credentials:'include'` attempt could\n// never have worked: the app runs on <app>.<system>.data2.app and the SSP on <system>.data2apis.com —\n// cross-site — and the SSP answers `Access-Control-Allow-Origin: *` with no `Allow-Credentials`, so the\n// browser rejects a credentialed CORS read before it ever leaves the page. What the SSP actually reads\n// is `Authorization: Bearer <token>`: the runtime keeps that token in localStorage under\n// `__data2-session-<systemDomain>` (`__data2-session-data2` inside the builder preview) and replays it\n// on every dataset and rush call — same token, same header, same session, so the Drive policy sees the\n// logged-in user. Preflight passes because the SSP echoes whatever header the request asks for.\nfunction d2SessionToken(sysDomain){\n  const read=k=>{ try{ return (k&&window.localStorage.getItem(k))||''; }catch(e){ return ''; } };\n  return read(sysDomain?('__data2-session-'+sysDomain):'')||read('__data2-session-data2')||'';\n}\nfunction domCsvSystemDomain(url){\n  const m=/^https?:\\/\\/([^.\\/]+)\\.data2\\.(?:media|app)\\//i.exec(url)||/^https?:\\/\\/([^.\\/]+)\\.data2apis\\.com\\//i.exec(url);\n  return m?m[1]:'';\n}\nfunction domCsvCandidates(url){\n  const out=[];\n  const isMedia=/^https?:\\/\\/[^.\\/]+\\.data2\\.media\\//i.test(url);\n  const api=isMedia?url.replace(/^(https?:\\/\\/[^.\\/]+)\\.data2\\.media\\//i,'$1.data2apis.com/'):null;\n  const tok=d2SessionToken(domCsvSystemDomain(url));\n  const auth=tok?{authorization:'Bearer '+tok}:null;\n  // Authenticated origin read first — the only candidate a GATED Drive can actually answer.\n  if(api&&auth) out.push({url:api,headers:auth,label:'api+sessão'});\n  if(!api&&auth) out.push({url:url,headers:auth,label:'origem+sessão'});\n  // Anonymous last: only ever succeeds on an UNGATED Drive — or on a stale CDN cache hit, which is NOT\n  // proof the file is public (the origin keeps answering 401 once the edge entry expires).\n  if(api) out.push({url:api,headers:null,label:'api anônimo'});\n  out.push({url:url,headers:null,label:'origem anônima'});\n  return out;\n}\nfunction domCsvUrl(){\n  const pick=v=>{\n    if(!v) return '';\n    if(typeof v==='string') return v;\n    const q=v.params||{};\n    const ref=q.reference||v.reference;\n    return q.fileUrl||q.url||v.fileUrl||v.url||(ref?DOM_MEDIA_BASE+ref:'');\n  };\n  return String(pick(d2.param('dominantesCsvUrl'))||DOM_CSV_FALLBACK||'').trim();\n}\nasync function ensureDominantes(){\n  const url=domCsvUrl();\n  if(!url) throw new Error('base de municípios dominantes não configurada — ligue o arquivo CSV no parâmetro dominantesCsvUrl da instância do componente');\n  if(DOM.url===url && DOM.bySetor) return DOM.bySetor;\n  if(DOM.url===url && DOM.promise) return DOM.promise;\n  DOM.url=url; DOM.error=null;\n  DOM.promise=(async()=>{\n    // Self-diagnosing on purpose: every candidate reports its own outcome and the message names the\n    // session it was carrying. \"Unauthorized\" alone cannot distinguish \"not logged into THIS strategy\"\n    // from \"cookie not sent cross-site\" from \"param never bound, so it tried the fallback URL\" — and\n    // guessing between those from the outside costs a round trip each time.\n    let r=null; const tried=[];\n    for(const c of domCsvCandidates(url)){\n      let res=null;\n      try{ res=await fetch(c.url,{cache:'no-store',credentials:'omit',headers:c.headers||undefined}); }\n      catch(e){ tried.push(c.label+' → rede/CORS'); continue; }\n      if(res.ok){ r=res; break; }\n      tried.push(c.label+' → '+res.status);\n    }\n    if(!r){\n      let sess='sessão desconhecida';\n      try{\n        const a=d2.auth(AUTH_REF);\n        sess=(a&&a.isAuthed)?('logado como '+((a.user&&(a.user.email||a.user.nome))||'?')):('NÃO logado na estratégia '+AUTH_REF);\n      }catch(e){ sess='estratégia '+AUTH_REF+' não resolvida'; }\n      const tk=d2SessionToken(domCsvSystemDomain(url));\n      throw new Error('base de municípios dominantes: não foi possível ler o arquivo — '+sess+' · token de sessão '+(tk?'presente':'AUSENTE no localStorage')+'. Tentativas: '+tried.join(' · '));\n    }\n    const table=parseCsv(await r.text());\n    if(table.length<2) throw new Error('base de municípios dominantes vazia');\n    const head=table[0].map(h=>norm(h));\n    const iS=head.indexOf('CD SETOR'), iM=head.indexOf('MUNICIPIO');\n    if(iS<0||iM<0) throw new Error('base de municípios dominantes sem as colunas CD_SETOR / MUNICIPIO');\n    // CD_GV is the gerência de vendas the setor answers to, and it is the authority for the hard rule\n    // that a closing sector only ever hands territory to sectors of its OWN gerência. Its absence is\n    // fatal rather than a warning: with no GV there is no way to PROVE two sectors share one, and\n    // falling through unchecked is exactly the leak this column exists to close.\n    const iG=['CD GV','CDGV','GV','CD GERENCIA','CD GERENCIA VENDAS','CD GERENCIA DE VENDAS','GERENCIA DE VENDAS','GERENCIA'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    if(iG==null) throw new Error('base de municípios dominantes sem a coluna CD_GV — a regra de gerência de vendas não pode ser aplicada. Colunas lidas: '+head.slice(0,40).join(', '));\n    // MERCADO fica ACIMA de GV na hierarquia (mercado > GV > setor) — fechamento de Mercado e a regra\n    // \"GV só fecha pra GV do mesmo mercado\" dependem dela do mesmo jeito que a regra de GV depende\n    // de CD_GV, então a ausência é fatal pelo mesmo motivo.\n    const iK=['MERCADO','MERCADO REGIONAL','MKT','MARKET'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    if(iK==null) throw new Error('base de municípios dominantes sem a coluna MERCADO — fechamento de GV/Mercado precisa dela. Colunas lidas: '+head.slice(0,40).join(', '));\n    // Nomes de exibição — opcionais: a hierarquia funciona só com os códigos, mas o código sozinho\n    // não diz nada pro operador escolhendo o que fechar.\n    const iDS=['DC SETOR','NOME SETOR','DESCRICAO SETOR','NM SETOR'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    const iDG=['DC GV','NOME GV','DESCRICAO GV','NM GV','GV NOME'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    // The UF column is what the whole keying now rests on — SQL no longer carries DS_ESTADO — so its\n    // absence is a first-class finding, not a footnote. The header actually matched is reported on the\n    // base pill, and when nothing matches the pill lists every column read, because guessing a header\n    // name from the outside costs a round trip each time.\n    const iU=['UF','SIGLA UF','SG UF','CD UF','ESTADO','DS ESTADO','SIGLA','ESTADO SIGLA','UF MUNICIPIO'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    const by={},ufBy={},gvBy={},mkBySetor={},setorLabel={},gvLabel={}; let n=0,bad=0; const clash={},gvClash={},mkClash={};\n    for(let i=1;i<table.length;i++){\n      const line=table[i]; if(!line) continue;\n      const s=String(line[iS]==null?'':line[iS]).trim(), m=muniN(line[iM]);\n      if(!s||!m) continue;\n      // One setor spans many rows (one per município) and every one of them repeats its CD_GV. Two\n      // DIFFERENT values for the same setor means the base itself disagrees about which gerência owns\n      // it — counted and reported, never keyed to whichever row happened to be read last.\n      const gvv=String(line[iG]==null?'':line[iG]).trim();\n      if(gvv){ if(gvBy[s]!==undefined && gvBy[s]!==gvv) gvClash[s]=true; else gvBy[s]=gvv; }\n      const mkv=String(line[iK]==null?'':line[iK]).trim();\n      if(mkv){ if(mkBySetor[s]!==undefined && mkBySetor[s]!==mkv) mkClash[s]=true; else mkBySetor[s]=mkv; }\n      if(iDS!=null){ const lbl=String(line[iDS]==null?'':line[iDS]).trim(); if(lbl && !setorLabel[s]) setorLabel[s]=lbl; }\n      if(iDG!=null && gvv){ const lbl=String(line[iDG]==null?'':line[iDG]).trim(); if(lbl && !gvLabel[gvv]) gvLabel[gvv]=lbl; }\n      const raw=(iU==null)?'':line[iU];\n      const u=(iU==null)?'':ufNorm(raw);\n      if(iU!=null && String(raw==null?'':raw).trim() && !u) bad++;\n      // The same name twice in ONE setor under two UFs is the single case the reverted SQL cannot\n      // survive: mSQL groups by DS_MUNICIPIO alone, so both municipalities arrive summed into one row\n      // and no lookup can split them again. Counted and reported — never quietly keyed to whichever\n      // UF happened to be read last.\n      const kk=s+'|'+m;\n      // An UNRESOLVED uf must never outrank a resolved one. Reading a blank/bogus UF row first wrote ''\n      // here, and the next row carrying the real sigla was then rejected as a clash — so the key stayed\n      // falsy for good, ufFor() fell through, and the município degraded to a bare NAME key that matches\n      // no shape and no dominance entry. With DISTRITO/ZONA in the base a município spans several rows,\n      // so which row lands first is pure luck. A clash is only two RESOLVED siglas that disagree.\n      if(!u){ if(ufBy[kk]===undefined) ufBy[kk]=''; }\n      else if(!ufBy[kk]) ufBy[kk]=u;\n      else if(ufBy[kk]!==u) clash[kk]=true;\n      (by[s]=by[s]||new Set()).add(u?m+'/'+u:m); n++;\n    }\n    if(!n) throw new Error('base de municípios dominantes sem linhas válidas');\n    DOM.bySetor=by; DOM.ufBySetorMuni=ufBy; DOM.rows=n; DOM.setores=Object.keys(by).length;\n    // Reverse município→setor index (static, built once from the same CSV) — lets the GV-closing\n    // map gray out a geographically-touching GV with no live query, matching the \"GV/mercado\n    // hierarchy is organizational, read from the CSV only\" rule the rest of this level already follows.\n    { const revBy={},revAll={}; Object.entries(by).forEach(([s,ms])=>ms.forEach(m=>{ if(!revBy[m]) revBy[m]=s; (revAll[m]=revAll[m]||new Set()).add(s); })); DOM.setorByMuni=revBy; DOM.setoresByMuni=revAll; }\n    DOM.gvBySetor=gvBy; DOM.gvClashes=Object.keys(gvClash).length; DOM.gvCol=String(table[0][iG]||'').trim();\n    DOM.gvSetores=Object.keys(gvBy).length;\n    DOM.hasUf=(iU!=null); DOM.ufCol=(iU==null?null:String(table[0][iU]||'').trim());\n    DOM.badUf=bad; DOM.clashes=Object.keys(clash).length; DOM.headers=head.slice(0,40); DOM.error=null;\n    // ---- hierarquia: setor ⊂ GV ⊂ mercado, tudo derivado do MESMO csv que a regra de setor já\n    // confia — fechamento de GV/Mercado lê a mesma fonte, nunca uma segunda base paralela.\n    DOM.mercadoBySetor=mkBySetor; DOM.mercadoClashes=Object.keys(mkClash).length; DOM.mercadoCol=String(table[0][iK]||'').trim();\n    DOM.setorLabel=setorLabel; DOM.gvLabel=gvLabel;\n    const sectorsByGv={},sectorsByMercado={},gvsByMercado={},mercadoByGv={},gvMercClash={};\n    Object.keys(by).forEach(s=>{\n      const gv=gvBy[s], mk=mkBySetor[s];\n      if(gv) (sectorsByGv[gv]=sectorsByGv[gv]||new Set()).add(s);\n      if(mk) (sectorsByMercado[mk]=sectorsByMercado[mk]||new Set()).add(s);\n      if(gv&&mk){ if(mercadoByGv[gv]!==undefined && mercadoByGv[gv]!==mk) gvMercClash[gv]=true; else mercadoByGv[gv]=mk; }\n    });\n    Object.entries(mercadoByGv).forEach(([gv,mk])=>(gvsByMercado[mk]=gvsByMercado[mk]||new Set()).add(gv));\n    DOM.sectorsByGv=sectorsByGv; DOM.sectorsByMercado=sectorsByMercado; DOM.gvsByMercado=gvsByMercado;\n    DOM.mercadoByGv=mercadoByGv; DOM.gvMercadoClashes=Object.keys(gvMercClash).length;\n    return by;\n  })();\n  DOM.promise.catch(e=>{ DOM.promise=null; DOM.bySetor=null; DOM.error=e.message; renderAll(); });\n  return DOM.promise;\n}\n\nlet SESS={ snapshotRaw:null, snapshotSyncedAt:null, snapshotPinnedDate:null, snapLiteral:null, eloMaxCycle:null, timeline:[], selected:0,\n           sessionId:null, sessionLabel:null, sessionsList:[], sectorsList:[], sectorBaseCache:{}, stage:'loading', dirtyFrom:null, locked:false,\n           snapBusy:false, settings:{...DEFAULT_SETTINGS} };\n\nasync function q(sql){\n  let dbx,res;\n  try{\n    res=await d2.rush('AwW_11Nkg-dKksP7ic8V8').invoke({sql});\n  }catch(e){\n    throw new Error('Databricks indisponível (rush AwW_11Nkg-dKksP7ic8V8): '+e.message);\n  }\n  // The listener answers 200 with {sql} and NO `result` when its upstream probe times out: the\n  // handler builds `result: $result.data`, so an empty upstream simply drops the key rather than\n  // failing. Reading straight through to res.data.result.data reported \"Cannot read properties of\n  // undefined (reading 'data')\" — which reads like a bug in THIS component and sent a debugging\n  // session after the wrong thing. Name the layer that actually failed instead.\n  const payload=res&&res.data;\n  if(!payload) throw new Error('a sonda Databricks não devolveu corpo — listener fora do ar?');\n  if(!payload.result) throw new Error('a sonda Databricks não respondeu: a requisição devolveu 200 sem `result`, o que normalmente é timeout do upstream (natura-x-app). O SQL não chegou a rodar.');\n  dbx=payload.result.data;\n  if(!dbx) throw new Error('sonda Databricks respondeu sem dados (result.data vazio).');\n  if(dbx.state && dbx.state!=='SUCCEEDED') throw new Error('Databricks '+dbx.state+': '+(dbx.error?.message||JSON.stringify(dbx.error)||'erro desconhecido'));\n  return dbx;\n}\nfunction rows(resp){const c=(resp.columns||[]).map(x=>x.name);\n  return (resp.rows||[]).map(a=>Object.fromEntries(c.map((k,i)=>[k,a[i]])));}\n// Stamps the UF onto every feature at load: the fetch is per-UF, so this is the ONLY point where a\n// shape's state is known for certain. Downstream the features are flattened into one array and the\n// origin would be unrecoverable. Persisted with the feature in saved sessions.\nasync function geoUF(uf){const code=UF_CODE[uf]||uf;const r=await fetch('https://raw.githubusercontent.com/tbrugz/geodata-br/master/geojson/geojs-'+code+'-mun.json');if(!r.ok)throw new Error('geo '+uf);\n  const g=await r.json(); const U=String(uf).toUpperCase();\n  (g.features||[]).forEach(f=>{ f.__uf=U; });\n  return g;}\n\nfunction buildAdj(features){\n  // Encaixe exato de vértice (grid ~111m) resolve vizinhança DENTRO de uma UF, porque todos os\n  // municípios de uma UF saem do MESMO arquivo geoJSON, com a mesma malha de pontos. Atravessando\n  // fronteira de UF, os dois lados vêm de arquivos buscados/digitalizados SEPARADAMENTE — a mesma\n  // linha real de fronteira quase nunca cai nos mesmos vértices nos dois arquivos, e alargar o grid\n  // pra compensar (tentativa anterior) só troca \"não encontra vizinho de UF diferente\" por \"pode\n  // grudar município que só está perto, dentro da própria UF\" — não é uma correção geométrica de\n  // verdade, só empurra o problema. A solução de verdade é uma segunda checagem, geométrica (menor\n  // distância real entre os dois contornos, em km), aplicada SÓ a pares de UF diferente que o\n  // encaixe exato não achou — dentro da mesma UF o encaixe exato continua sendo a única fonte,\n  // sem alteração de comportamento nem de peso de voto.\n  const pts=f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates],s=new Set();\n    ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>s.add(Math.round(p[0]*1000)/1000+','+Math.round(p[1]*1000)/1000))));return s;};\n  const rawPts=f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates],out=[];\n    ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>out.push(p))));return out;};\n  const feat={},idx={};\n  features.forEach(f=>{\n    const nm=mkey(f.properties.name||f.properties.NOME||f.properties.nome,f.__uf);\n    const raw=rawPts(f);\n    let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n    raw.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; });\n    feat[nm]={f,pts:pts(f),raw,bbox:{mnx,mny,mxx,mxy},uf:f.__uf};\n  });\n  for(const nm in feat) feat[nm].pts.forEach(p=>(idx[p]=idx[p]||[]).push(nm));\n  // Limiar de \"encosta de verdade\" cruzando UF: cobre a folga típica entre dois arquivos\n  // digitalizados separadamente pra MESMA linha real de fronteira, sem juntar município que só\n  // está na vizinhança geral mas não faz fronteira de fato.\n  const TOUCH_KM=1.2;\n  const nbrs=nm=>{\n    const A=feat[nm]; if(!A) return [];\n    const c={};\n    A.pts.forEach(p=>idx[p].forEach(o=>{if(o!==nm)c[o]=(c[o]||0)+1;}));\n    const exact=Object.entries(c).filter(([,n])=>n>=2).map(([o,n])=>({o,w:n}));\n    const seen=new Set(exact.map(e=>e.o));\n    const latK=Math.max(0.2,Math.cos(((A.bbox.mny+A.bbox.mxy)/2)*Math.PI/180));\n    const padLon=TOUCH_KM/111/latK, padLat=TOUCH_KM/111;\n    const extra=[];\n    for(const o in feat){\n      if(o===nm||seen.has(o)) continue;\n      const B=feat[o];\n      if(B.uf===A.uf) continue; // mesma UF: se o encaixe exato não achou, não está encostando\n      if(B.bbox.mxx<A.bbox.mnx-padLon||B.bbox.mnx>A.bbox.mxx+padLon||B.bbox.mxy<A.bbox.mny-padLat||B.bbox.mny>A.bbox.mxy+padLat) continue;\n      let best=Infinity;\n      outer: for(const p of A.raw){\n        for(const q of B.raw){\n          const d=Math.hypot((p[0]-q[0])*latK*111,(p[1]-q[1])*111);\n          if(d<best){ best=d; if(best<0.05) break outer; }\n        }\n      }\n      if(best<=TOUCH_KM) extra.push({o,w:1});\n    }\n    return exact.concat(extra);\n  };\n  return {feat,nbrs};\n}\nfunction centroidLL(f){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  ps.forEach(poly=>poly[0].forEach(p=>{x+=p[0];y+=p[1];n++;}));return[x/n,y/n];}\n\n// ---------- snapshot pinning ----------\nasync function resolveSnapshotPin(range){\n  // range: {from,to} (both optional YYYY-MM-DD strings, from the snapshot sheet's período search),\n  // or omitted entirely for the unconditional \"latest available\" (live) pin. This used to filter\n  // with CAST(DT_HISTORICO AS DATE)<=DATE'...' directly in SQL — and matched ZERO rows for every\n  // date tested. DT_HISTORICO's real underlying type is uncertain enough that even an EXACT\n  // equality match against it (the candidates/tests loop below) already has to empirically try\n  // three different literal forms rather than trust one; a CAST hit that same uncertainty, and\n  // Spark returns NULL — not an error — from a CAST it can't make sense of, so NULL<=anything\n  // silently matched nothing, for every date, with no exception to reveal why. Fixed by never\n  // comparing dates in SQL at all: SQL's own JSON serialization always normalizes a DATE/TIMESTAMP\n  // column's OUTPUT to an ISO-shaped string regardless of its real underlying type (exactly how\n  // `raw` already round-trips safely in the unconditional/live case below), so GROUP BY hands back\n  // every distinct snapshot with its row count, and the target is picked by comparing that string's\n  // first 10 characters (YYYY-MM-DD) in JS — no CAST, no SQL-side range comparison, no ambiguity.\n  const from=range&&range.from, to=range&&range.to;\n  let raw, liveCount, diag=null;\n  if(from||to){\n    // Direct SQL-side range filter, mirroring the SAME self-referential-subquery pattern the\n    // live/unconditional branch below already relies on for a safe exact-match count (comparing\n    // DT_HISTORICO against a SUBQUERY's own re-derived MAX, never against an externally re-injected\n    // literal for the COUNT step) — only the boundary comparison itself needs a literal, so that's\n    // the one spot the three candidate forms tried for equality elsewhere in this function get\n    // tried again. An earlier version used COUNT(*) over the WHOLE RANGE just to check \"did this\n    // literal flavor match anything\" — wrong, because that range-wide count then got compared\n    // downstream against a SINGLE exact date's row count, which a multi-week/month window will\n    // essentially never equal. Half-open interval on the upper bound (< first instant after `to`,\n    // not <= 'to 23:59:59.999') so a DT_HISTORICO with finer-than-millisecond precision can't\n    // silently land just outside an inclusive boundary.\n    const fromB=from?from+' 00:00:00.000':null;\n    const toExclusive=to?(()=>{ const d=new Date(to+'T00:00:00Z'); d.setUTCDate(d.getUTCDate()+1); return d.toISOString().slice(0,10)+' 00:00:00.000'; })():null;\n    const flavors=[v=>`TIMESTAMP'${v}'`, v=>`'${v}'`, v=>`DATE'${v.slice(0,10)}'`];\n    for(const mk of flavors){\n      const clauses=[]; if(fromB) clauses.push(`DT_HISTORICO>=${mk(fromB)}`); if(toExclusive) clauses.push(`DT_HISTORICO<${mk(toExclusive)}`);\n      const whereRange=' AND '+clauses.join(' AND ');\n      const maxR=await q(`SELECT MAX(DT_HISTORICO) snap FROM ${T} WHERE CD_PAIS=1${whereRange}`).then(rows).then(rs=>rs[0]).catch(()=>null);\n      const candRaw=maxR&&maxR.snap;\n      if(!candRaw) continue;\n      const cntR=await q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1${whereRange})`).then(rows).then(rs=>rs[0]).catch(()=>null);\n      const n=cntR?+cntR.n:0;\n      if(n>0){ raw=candRaw; liveCount=n; break; }\n    }\n    if(!raw){\n      // A clean, single-row aggregate (MIN/MAX/COUNT DISTINCT) to get the TRUE bounds of what's\n      // actually in the table, so a genuinely-empty period is distinguishable from a filter that\n      // silently matched nothing.\n      const b=await q(`SELECT MIN(DT_HISTORICO) mn, MAX(DT_HISTORICO) mx, COUNT(DISTINCT DT_HISTORICO) nd FROM ${T} WHERE CD_PAIS=1`).then(rows).then(rs=>rs[0]).catch(()=>null);\n      diag = b ? `base cobre de ${String(b.mn||'?').slice(0,10)} a ${String(b.mx||'?').slice(0,10)} (${b.nd||'?'} data(s) distinta(s))` : 'não foi possível checar o alcance da base';\n    }\n  } else {\n    const [rawR,liveCountR]=await Promise.all([\n      q(`SELECT COALESCE((SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO<(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)),(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)) snap`).then(rows),\n      q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=COALESCE((SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO<(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)),(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1))`).then(rows),\n    ]);\n    raw=rawR[0]&&rawR[0].snap; liveCount=+((liveCountR[0]||{}).n||0);\n  }\n  if(!raw) return {snapshotRaw:null, snapLiteral:null, syncedAt:new Date().toISOString(), pinnedDate:null, diag};\n  const LIVE_SUBQ=`(SELECT COALESCE((SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO<(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)),(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)))`;\n  const dbTs=(raw||'').replace('T',' ').replace(/Z$/,'');\n  const candidates=[`TIMESTAMP'${dbTs}'`,`'${dbTs}'`,`DATE'${dbTs}'`];\n  const tests=await Promise.all(candidates.map(c=>\n    q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=${c}`).then(rows).then(r=>+((r[0]||{}).n||0)).catch(()=>-1)\n  ));\n  // LIVE_SUBQ (unconditionally re-deriving MAX) is only a safe fallback for the live/unconditional\n  // case — for a historical pin it would just silently re-resolve to today's snapshot, discarding\n  // the picked range, so the fallback there is the best-guess candidate literal instead.\n  let chosen=(from||to)?candidates[0]:LIVE_SUBQ;\n  for(let i=0;i<candidates.length;i++){ if(liveCount>0 && tests[i]===liveCount){ chosen=candidates[i]; break; } }\n  return {snapshotRaw:raw, snapLiteral:chosen, syncedAt:new Date().toISOString(), pinnedDate:(from||to)?String(raw).slice(0,10):null};\n}\n// DISPLAY CONTRACT: the UI never prints the snapshot's own DT_HISTORICO (snapshotRaw). What the user\n// sees is `snapshotSyncedAt` — the date AND hour at which THIS instance last pulled/pinned the\n// snapshot it is querying. snapshotRaw is still resolved, still drives every SQL literal, and is\n// still persisted by saveSession/doShare; it is only kept off the screen.\nfunction fmtSyncedAt(iso){\n  if(!iso) return '—';\n  const d=new Date(iso);\n  if(isNaN(d.getTime())) return String(iso);\n  return d.toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});\n}\nasync function ensurePinned(){\n  if(SESS.snapLiteral) return;\n  const p=await resolveSnapshotPin();\n  SESS.snapshotRaw=p.snapshotRaw; SESS.snapLiteral=p.snapLiteral;\n  SESS.snapshotSyncedAt=p.syncedAt; SESS.snapshotPinnedDate=p.pinnedDate;\n}\n// ---------- operational cycle (ELO) ----------\n// Deliberately NOT auto-resolved alongside the DT_HISTORICO snapshot above anymore: the operator\n// picks it explicitly on the confirm screen — either this same auto-detect query (same one that\n// always ran here before) via the \"usar o mais recente\" button, or a specific cycle typed by hand.\n// product.elo_modelo_combinado keys by an opaque operational_cycle number this component has no\n// established mapping from a calendar date to, so leaving the choice explicit avoids silently\n// mis-scoping the \"últimos N ciclos\" trend chart against a cycle the operator didn't ask for.\nasync function queryEloCycle(){\n  // Fase 15 (pedido do usuário): MAX(operational_cycle)-1 dava o penúltimo ciclo que aparece na\n  // tabela do ELO — mas um ciclo pode já ter ALGUMAS linhas processadas sem estar FECHADO ainda\n  // (a maioria das CBs continua no ciclo anterior no CRM). A query nova acha o ciclo FECHADO de\n  // verdade: pega o ciclo que a MAIORIA das CBs ainda registra como atual no CRM (na mesma data\n  // de snapshot que resolveSnapshotPin usa — penúltimo DT_HISTORICO, com o mesmo COALESCE de\n  // fallback pro mais recente) e devolve o maior ciclo do ELO que seja ANTERIOR a esse — ou\n  // seja, o último que já fechou pra maioria, não o mais recente que só começou a preencher.\n  // Fase 17 (pedido do usuário): sem desempate, \"maioria\" podia trocar de ciclo entre duas\n  // execuções quando dois ciclos ficam empatados (ou quase) em contagem — um município cujas CBs\n  // ainda estão no ciclo anterior no ELO então saía com 0 CBs numa execução e correto na outra,\n  // sem nada mudar de verdade nos dados. Empate agora resolve sempre para o ciclo mais recente.\n  const eloR=await q(`WITH ciclos AS (SELECT DISTINCT operational_cycle c FROM ${ELO} WHERE country_code=1), maioria AS (SELECT CD_CICLO_ATUAL c FROM ${T} WHERE CD_PAIS=1 AND CD_SETOR>0 AND CD_GRUPO>0 AND DT_HISTORICO=COALESCE((SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO<(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)),(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)) GROUP BY CD_CICLO_ATUAL ORDER BY COUNT(*) DESC, CD_CICLO_ATUAL DESC LIMIT 1) SELECT MAX(c) AS last_closed_cycle FROM ciclos WHERE c<(SELECT c FROM maioria)`).then(rows).catch(()=>[]);\n  return +((eloR[0]||{}).last_closed_cycle||0)||null;\n}\n\n// ---------- world-state fold ----------\nfunction accMetrics(base,add){\n  const out=base?{...base}:blankM();\n  Object.keys(blankM()).forEach(k=>{ out[k]=(out[k]||0)+((add&&add[k])||0); });\n  return out;\n}\nfunction foldWorldState(timeline,uptoIdx){\n  const closedSectors=new Set(),muniOverride={},muniGeo={},muniRow={},muniLevel={},sectorInflow={},sectorOutflow={},sectorInflowLevel={},sectorOrphanRows={};\n  // GV/mercado: setores e GVs não se dissolvem quando o dono deles fecha — eles só passam a\n  // responder a um destino novo. Por isso o rastro do cascade mora numa camada PRÓPRIA\n  // (secOverride/gvOverride), separada da camada de município do fechamento de setor — a\n  // hierarquia trava a sessão num nível só, então nunca as duas camadas têm conteúdo ao mesmo tempo.\n  const closedGvs=new Set(),secOverride={},gvInflow={};\n  const closedMercados=new Set(),gvOverride={},mercadoInflow={};\n  for(let k=0;k<uptoIdx;k++){\n    const mv=timeline[k]; if(!mv) continue;\n    if(mv.kind==='transfer'){\n      muniOverride[mv.xferMuni]=mv.xferTo;\n      sectorInflow[mv.xferTo]=accMetrics(sectorInflow[mv.xferTo],mv.xferMetrics);\n      sectorOutflow[mv.xferFrom]=accMetrics(sectorOutflow[mv.xferFrom],mv.xferMetrics);\n      continue;\n    }\n    if(!mv.sector||!mv.munis) continue;\n    const level=mv.level||'setor';\n    if(level==='gv'){\n      closedGvs.add(String(mv.sector));\n      Object.keys(mv.munis).forEach(u=>{\n        if(u==='__OUTROS__') return;\n        const dest=mv.assign[u]; if(!dest) return;\n        secOverride[u]=String(dest);\n        gvInflow[dest]=accMetrics(gvInflow[dest],mv.munis[u]);\n      });\n      continue;\n    }\n    if(level==='mercado'){\n      closedMercados.add(String(mv.sector));\n      Object.keys(mv.munis).forEach(u=>{\n        if(u==='__OUTROS__') return;\n        const dest=mv.assign[u]; if(!dest) return;\n        gvOverride[u]=String(dest);\n        mercadoInflow[dest]=accMetrics(mercadoInflow[dest],mv.munis[u]);\n      });\n      continue;\n    }\n    closedSectors.add(String(mv.sector));\n    Object.keys(mv.munis).forEach(m=>{\n      if(m==='__OUTROS__') return;\n      const dest=mv.assign[m]; if(!dest) return;\n      muniOverride[m]=String(dest);\n      if(mv.geoFeat&&mv.geoFeat[m]) muniGeo[m]=mv.geoFeat[m];\n      const rws=(mv.matrix||[]).filter(r=>mkey(r.municipio,r.uf)===m);\n      muniRow[m]=(muniRow[m]||[]).concat(rws);\n      if(mv.levelByMuni&&mv.levelByMuni[m]) muniLevel[m]={...(muniLevel[m]||{}),...mv.levelByMuni[m]};\n      sectorInflow[dest]=accMetrics(sectorInflow[dest],mv.munis[m]);\n      const lv=(mv.levelByMuni&&mv.levelByMuni[m])||{};\n      sectorInflowLevel[dest]=sectorInflowLevel[dest]||{};\n      Object.entries(lv).forEach(([nv,c])=>{ sectorInflowLevel[dest][nv]=(sectorInflowLevel[dest][nv]||0)+c; });\n    });\n    // Orphan CBs follow their leader's home sector, so later etapas must inherit them the same way\n    // the allocated municipalities are inherited — otherwise a stage's span would not match the\n    // base carried into the next one.\n    if(mv.orphanMetricsByGroup && Object.keys(mv.orphanMetricsByGroup).length){\n      const d=computeDerived(mv);\n      d.leaders.forEach(L=>{\n        if(!L.home) return;\n        const om=mv.orphanMetricsByGroup[L.grupo];\n        if(om) sectorInflow[L.home]=accMetrics(sectorInflow[L.home],om);\n        // sectorInflow only reaches a sector while it is a DESTINATION (it lands in baseMetrics).\n        // Keep the rows as well, so the sector still carries these CBs on the day it is the one\n        // being closed — that read goes through mv.matrix, which baseMetrics never touches.\n        const orws=mv.orphanRowsByGroup&&mv.orphanRowsByGroup[L.grupo];\n        if(orws&&orws.length){\n          sectorOrphanRows[L.home]=(sectorOrphanRows[L.home]||[]).concat(orws);\n          // Orphan municípios never reach the muniOverride[m]=dest write above — aggregateMovement\n          // folds them into '__OUTROS__' in mv.munis, which that loop explicitly skips — so a later\n          // GV/mercado closure's resolveUnit() had no override to redirect them and kept resolving\n          // them back to THIS (now-closed) setor, via their still-live CD_SETOR. Recorded here too,\n          // keyed by the real município, so resolveUnit() redirects them the same as any other one.\n          orws.forEach(r=>{ const m=mkey(r.municipio,r.uf); if(!muniOverride[m]) muniOverride[m]=String(L.home); });\n        }\n        const ol=mv.orphanLevelByGroup&&mv.orphanLevelByGroup[L.grupo];\n        if(ol){ sectorInflowLevel[L.home]=sectorInflowLevel[L.home]||{};\n          Object.entries(ol).forEach(([nv,c])=>{ sectorInflowLevel[L.home][nv]=(sectorInflowLevel[L.home][nv]||0)+c; }); }\n      });\n    }\n  }\n  return {closedSectors,muniOverride,muniGeo,muniRow,muniLevel,sectorInflow,sectorOutflow,sectorInflowLevel,sectorOrphanRows,\n          closedGvs,secOverride,gvInflow,closedMercados,gvOverride,mercadoInflow};\n}\n\n// ---------- per-movement compute ----------\nfunction aggregateMovement(mv,world){\n  const level=mv.level||'setor';\n  const munis={},leaders={};\n  const exSet=new Set((mv.excludedMunis||[]).map(x=>x.muni));\n  const acc=(o,r)=>{o.cbs+=+r.cbs;o.receita+=+r.receita||0;o.rec_nat+=+r.rec_nat||0;o.rec_avon+=+r.rec_avon||0;o.pontos+=+r.pontos||0;o.pedidos+=+r.pedidos||0;o.ativas+=+r.ativas||0;};\n  // Orphans = CBs in municipalities outside the sector's dominance base. They are NOT allocated\n  // geographically; they follow their own leader's home sector. Kept per leader group so every\n  // consumer (span, cascade, impact report) can route them the same way. Só existe no nível setor —\n  // GV/mercado não têm spillover (todo setor pertence a exatamente uma GV/mercado na base).\n  mv.orphanByGroup={}; mv.orphanMetricsByGroup={}; mv.orphanRowsByGroup={};\n  // unidade que este nível redistribui: a própria município (setor fecha), o setor da linha (GV\n  // fecha), a GV do setor da linha (mercado fecha).\n  // Same redirect resolveUnit() already applies for geometry (mv.unitOfMuni, in computeMovement):\n  // a setor/GV that already closed EARLIER in this session no longer really owns this row — its\n  // live CD_SETOR just hasn't caught up (the CRM snapshot never learns about a simulated closure).\n  // Without this, the closed unit's own rows kept grouping under ITS OWN code here, in the MATRIX\n  // (mv.munis/mv.leaders — what the UI actually renders as a unit's row), reappearing as if it were\n  // still open, even though the map/resolveUnit already redirected it correctly.\n  const unitOf=r=>{\n    if(level==='setor') return null;\n    let sOf=String(r.setor);\n    const m=mkey(r.municipio,r.uf);\n    if(world&&world.closedSectors.has(sOf)){\n      // Closed AND unresolved (no override was ever recorded for this município — e.g. it had no\n      // shape yet during the setor's OWN closure, so it never entered mv.munis/mv.assign back then)\n      // must NOT fall through as sOf itself: that resurrects the closed setor as a live, assignable\n      // unit here. Empty instead, same as anything else aggregateMovement can't place — it falls to\n      // the '__OUTROS__' bucket below, already excluded from optimizeFor/autoAssignFor everywhere.\n      sOf=world.muniOverride[m]||'';\n    }\n    if(!sOf) return '';\n    if(level==='gv') return sOf;\n    return (world&&world.secOverride&&world.secOverride[sOf])||DOM.gvBySetor[sOf]||'';\n  };\n  mv.matrix.forEach(r=>{\n    const m=mkey(r.municipio,r.uf);\n    r._out=!mv.geoFeat[m];   // assign, never just set: absorbed rows are reused across etapas\n    const mm=level==='setor'?(r._out?'__OUTROS__':m):(unitOf(r)||'__OUTROS__');\n    munis[mm]=munis[mm]||{...blankM(),name:mm}; acc(munis[mm],r);\n    const g=String(r.grupo); leaders[g]=leaders[g]||{grupo:g,lider:r.lider,total:0,byMuni:{}};\n    leaders[g].total+=+r.cbs; leaders[g].byMuni[mm]=(leaders[g].byMuni[mm]||0)+ +r.cbs;\n    if(level==='setor' && r._out && exSet.has(m)){\n      mv.orphanByGroup[g]=(mv.orphanByGroup[g]||0)+ +r.cbs;\n      // The ROW too, not just the totals: when the leader's home sector is itself closed in a later\n      // etapa, that etapa rebuilds its matrix from SQL + absorbed municípios, and these CBs are in\n      // neither (their município never moved). Without the row they silently vanish from the sector\n      // they were just credited to — see sectorOrphanRows in foldWorldState.\n      (mv.orphanRowsByGroup[g]=mv.orphanRowsByGroup[g]||[]).push(r);\n      mv.orphanMetricsByGroup[g]=accMetrics(mv.orphanMetricsByGroup[g],\n        {cbs:+r.cbs,receita:+r.receita||0,rec_nat:+r.rec_nat||0,rec_avon:+r.rec_avon||0,pontos:+r.pontos||0,pedidos:+r.pedidos||0,ativas:+r.ativas||0});\n    }\n  });\n  // Level counts only exist per municipality, never per leader, so an excluded municipality's level\n  // mix is credited to the group holding most of its CBs (sub-threshold by definition, so usually one).\n  mv.orphanLevelByGroup={};\n  (mv.excludedMunis||[]).forEach(({muni})=>{\n    const lv=(mv.levelByMuni&&mv.levelByMuni[muni])||{}; if(!Object.keys(lv).length) return;\n    let bg=null,bc=-1;\n    mv.matrix.forEach(r=>{ if(mkey(r.municipio,r.uf)!==muni) return; const cc=+r.cbs; if(cc>bc){bc=cc;bg=String(r.grupo);} });\n    if(!bg) return;\n    mv.orphanLevelByGroup[bg]=mv.orphanLevelByGroup[bg]||{};\n    Object.entries(lv).forEach(([nv,cc])=>{ mv.orphanLevelByGroup[bg][nv]=(mv.orphanLevelByGroup[bg][nv]||0)+cc; });\n  });\n  mv.munis=munis;\n  // Todo município com geoFeat (inclusive os sem CB injetados acima, em withShape/closingNames) tem\n  // de aparecer em mv.munis — optimizeFor()/autoAssignFor() só enxergam Object.keys(mv.munis) pra\n  // decidir quem recebe destino. Sem isto, um município sem nenhuma linha em mv.matrix nunca entra\n  // em mv.assign, mesmo desenhado no mapa e votando por vizinhança — fica com destino colorido na\n  // legenda mas span/CBs realocadas/desequilíbrio tudo zerado, porque nada foi de fato atribuído.\n  if(level==='setor'){\n    Object.keys(mv.geoFeat||{}).forEach(n=>{ if(!munis[n]) munis[n]={...blankM(),name:n}; });\n  } else {\n    // Same guarantee, one level up: every UNIT (setor closing a GV, GV closing a mercado) that owns\n    // at least one drawn município has to appear in mv.munis even if every one of its municípios is\n    // zero-CB — mv.unitMunis already grouped them by unit; without a blank entry here that unit is\n    // invisible to optimizeFor()/autoAssignFor() and never gets a real mv.assign, exactly like the\n    // setor-level município case above.\n    Object.keys(mv.unitMunis||{}).forEach(u=>{ if(!munis[u]) munis[u]={...blankM(),name:u}; });\n  }\n  mv.leaders=Object.values(leaders).sort((a,b)=>b.total-a.total);\n}\nfunction autoAssignFor(mv){\n  const a={};\n  Object.keys(mv.munis).forEach(m=>{ if(m==='__OUTROS__')return;\n    const v=mv.allowed[m]||{}; const votes=Object.keys(v);\n    if(votes.length){ let best=votes[0],bw=-1; for(const s of votes) if(v[s]>bw){bw=v[s];best=s;} a[m]=best; }\n    else if(mv.dest.length){ const c=mv.muniCentroid[m]; let best=mv.dest[0],bd=Infinity;\n      for(const s of mv.dest){ const sc=mv.secCentroid[s]; if(!sc||!c)continue; const d=(c[0]-sc[0])**2+(c[1]-sc[1])**2; if(d<bd){bd=d;best=s;} }\n      a[m]=best; }\n  });\n  return a;\n}\nfunction deslocFor(mv,assign){\n  let d=0;\n  mv.leaders.forEach(L=>{const byS={};let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=assign[m];if(s){byS[s]=(byS[s]||0)+c;tot+=c;}}\n    const home=mv.dest.reduce((a,s)=>(byS[s]||0)>(byS[a]||0)?s:a,mv.dest[0]);\n    d+=tot-(byS[home]||0);});\n  return d;\n}\nfunction spanMapFor(mv,a){const inc={};mv.dest.forEach(s=>inc[s]=0);\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=a[m];if(s&&inc[s]!=null)inc[s]+=o.cbs;});\n  // Orphan CBs follow their leader's home sector, and that home depends on THIS candidate\n  // assignment — so they have to be counted here, or the optimizer would score and constrain a\n  // span that differs from the one the UI shows. Skipped entirely when there are no orphans.\n  if(mv.orphanByGroup&&Object.keys(mv.orphanByGroup).length){\n    mv.leaders.forEach(L=>{\n      const orphan=mv.orphanByGroup[L.grupo]||0; if(!orphan) return;\n      const byS={}; let tot=0;\n      for(const [m,c] of Object.entries(L.byMuni)){ if(m==='__OUTROS__') continue; const s=a[m]; if(s&&inc[s]!=null){ byS[s]=(byS[s]||0)+c; tot+=c; } }\n      if(!tot) return;   // no home to follow -> stranded, counted nowhere\n      const home=mv.dest.reduce((x,s)=>(byS[s]||0)>(byS[x]||0)?s:x,mv.dest[0]);\n      if(inc[home]!=null) inc[home]+=orphan;\n    });\n  }\n  const sp={};mv.dest.forEach(s=>sp[s]=(+(mv.baseMetrics[s]?.cbs||0))+inc[s]);return sp;}\nfunction maxSpanOfFor(mv,a){return Math.max(...Object.values(spanMapFor(mv,a)),0);}\nfunction mergedSpanAll(){\n  const st=(SESS.settings&&SESS.settings.span)||{};\n  return {\n    setor:{...DEFAULT_SETTINGS.span.setor,...(st.setor||{})},\n    gv:{...DEFAULT_SETTINGS.span.gv,...(st.gv||{})},\n    mercado:{...DEFAULT_SETTINGS.span.mercado,...(st.mercado||{})},\n  };\n}\nfunction spanShortAll(){\n  const sp=mergedSpanAll();\n  const parts=['setor','gv','mercado'].map(lvl=>{ const s=spanShort(sp[lvl]); return s==='livre'?null:(LEVELS[lvl].label+' '+s); }).filter(Boolean);\n  return parts.length?parts.join(' · '):'livre';\n}\n// Sessões salvas antes de existir escala por nível guardavam targetSpan/maxSpan/minSpan soltos no\n// topo de settings, e esses três campos sempre foram só do setor — migra pra span.setor uma vez,\n// preservando null explícito (desligado) em vez de recair no padrão 2700/3500/2200.\nfunction migrateSpanSettings(raw){\n  raw=raw||{};\n  if(raw.span) return {\n    setor:{...DEFAULT_SETTINGS.span.setor,...raw.span.setor},\n    gv:{...DEFAULT_SETTINGS.span.gv,...raw.span.gv},\n    mercado:{...DEFAULT_SETTINGS.span.mercado,...raw.span.mercado},\n  };\n  const legacy=('targetSpan' in raw)||('maxSpan' in raw)||('minSpan' in raw);\n  return {\n    setor: legacy?{targetSpan:raw.targetSpan??null,maxSpan:raw.maxSpan??null,minSpan:raw.minSpan??null}:{...DEFAULT_SETTINGS.span.setor},\n    gv:{...DEFAULT_SETTINGS.span.gv},\n    mercado:{...DEFAULT_SETTINGS.span.mercado},\n  };\n}\nfunction spanLimits(mv){\n  // Span alvo/máximo/mínimo é uma escala POR NÍVEL de fechamento — cada nível olha só a sua.\n  const lvl=(mv&&mv.level)||'setor';\n  const st=mergedSpanAll()[lvl];\n  return {target:numOrNull(st.targetSpan),max:numOrNull(st.maxSpan),min:numOrNull(st.minSpan)};}\nfunction spanLimitsOn(lim){return !!(lim.target||lim.max||lim.min);}\nfunction spanViolationsOf(sp,lim){const over=[],under=[];\n  Object.entries(sp).forEach(([s,v])=>{ if(lim.max&&v>lim.max)over.push(s); if(lim.min&&v<lim.min)under.push(s); });\n  return {over,under};}\nfunction spanViolationsFor(mv,a){return spanViolationsOf(spanMapFor(mv,a),spanLimits(mv));}\n// With a target span the objective becomes RMS distance from the ideal size (punishes too big AND\n// too small). With no target we keep the legacy objective: shave the biggest sector.\nfunction spanCostFor(mv,a){const lim=spanLimits(mv),vals=Object.values(spanMapFor(mv,a));\n  if(!vals.length) return 0;\n  if(lim.target) return Math.sqrt(vals.reduce((s,v)=>s+(v-lim.target)**2,0)/vals.length);\n  return Math.max(...vals);}\n// ---------- ÓTIMO: objetivo multi-critério ----------\n// Three normalised objectives, each 0..1, so the weights below are directly comparable. The old\n// score added a span cost measured in CBs to a desloc measured in CBs, so on a large sector the\n// balance term was numerically ~100x the desloc term and silently decided everything on its own.\n//   balance — RMS spread of the resulting spans (against the target span when one is set,\n//             otherwise against their own mean), over that reference. 0 = every destination\n//             sector ends up the same size.\n//   dist    — how far each município travels, CB-weighted: half from the share of its outer\n//             border that faces the sector it was given to, half from centroid distance. 0 =\n//             every município went to a sector it physically touches. Adjacency used to be a\n//             pure yes/no constraint (contiguity); this is what makes it a preference too.\n//   orphan  — stranded orphan CBs (leader left without a home sector, so they land nowhere at\n//             all) plus CBs split away from their leader's home, over the CBs at stake.\n//             Stranded ones count OPT_STRAND_K times: a stranded CB leaves the model entirely,\n//             a deslocated one merely changes hands.\nconst OPT_W={balance:1, dist:0.85, orphan:1, split:1, cap:4};\nconst OPT_STRAND_K=3;\nconst OPT_SIMS=24;             // simultaneous simulations per run, scaled down on big stages\nconst OPT_PASSES=14;           // local-search sweeps inside one simulation\nconst OPT_EVAL_BUDGET=120000;  // total candidate evaluations across the whole portfolio\nconst OPT_EXHAUSTIVE_MAX=60000;\nfunction optDistPrep(mv){\n  let mx=0;\n  Object.keys(mv.munis).forEach(m=>{ if(m==='__OUTROS__') return; const c=mv.muniCentroid[m]; if(!c) return;\n    mv.dest.forEach(s=>{ const sc=mv.secCentroid[s]; if(!sc) return;\n      const d=Math.hypot(c[0]-sc[0],c[1]-sc[1]); if(d>mx) mx=d; }); });\n  return mx||1;\n}\nfunction optDistCost(mv,m,s,dmax){\n  const v=mv.allowed[m]||{}; let tot=0; for(const k in v) tot+=v[k];\n  const share=tot?((v[s]||0)/tot):0;\n  const c=mv.muniCentroid[m], sc=mv.secCentroid[s];\n  // Missing centroid on either side is neutral (0.5), never 0 — an unmeasurable move must not\n  // score as the cheapest one on the board.\n  const geo=(c&&sc)?Math.min(1,Math.hypot(c[0]-sc[0],c[1]-sc[1])/dmax):0.5;\n  return 0.5*(1-share)+0.5*geo;\n}\nfunction optEval(mv,a,dmax){\n  const lim=spanLimits(mv);\n  const sp=spanMapFor(mv,a), vals=Object.values(sp);\n  let balance=0;\n  if(vals.length){\n    const ref=lim.target||(vals.reduce((x,y)=>x+y,0)/vals.length);\n    if(ref) balance=Math.min(1,Math.sqrt(vals.reduce((s,v)=>s+(v-ref)**2,0)/vals.length)/ref);\n  }\n  // cap — how far any sector's span sits past the hard max, or short of the hard min, over the\n  // reference size. `balance` alone is a poor proxy for staying under max: it measures RMS distance\n  // from the *target*, so a candidate that blows past max on one sector can still score a lower\n  // (better) balance than a fully compliant one if it has less quebra/orphan elsewhere — the\n  // hill-climb below then happily accepts that move, since span feasibility otherwise plays no part\n  // in the per-move cost (it was only ever checked AFTER a winner was already picked, via the tier\n  // ladder in optimizeFor). This term makes crossing max/min actively costly during the search\n  // itself, not just visible in the badge once it's too late.\n  let capExcess=0;\n  if(lim.max||lim.min) vals.forEach(v=>{ if(lim.max&&v>lim.max) capExcess+=v-lim.max; if(lim.min&&v<lim.min) capExcess+=lim.min-v; });\n  const capRef=lim.target||lim.max||lim.min||(vals.length?vals.reduce((x,y)=>x+y,0)/vals.length:0);\n  const cap=capRef?Math.min(1,capExcess/capRef):0;\n  let cbs=0,dist=0;\n  for(const m in a){ const o=mv.munis[m]; if(!o) continue; const w=+o.cbs||0; cbs+=w; dist+=w*optDistCost(mv,m,a[m],dmax); }\n  dist=cbs?(dist/cbs):0;\n  let stranded=0,desloc=0,tot=0;\n  mv.leaders.forEach(L=>{\n    const byS={}; let t=0;\n    for(const [m,c] of Object.entries(L.byMuni)){ if(m==='__OUTROS__') continue; const s=a[m]; if(s){ byS[s]=(byS[s]||0)+c; t+=c; } }\n    const orphan=(mv.orphanByGroup&&mv.orphanByGroup[L.grupo])||0;\n    tot+=t+orphan;\n    if(!t){ stranded+=orphan; return; }   // no allocated CBs -> no home -> these orphans land nowhere\n    const home=mv.dest.reduce((x,s)=>(byS[s]||0)>(byS[x]||0)?s:x,mv.dest[0]);\n    desloc+=t-(byS[home]||0);\n  });\n  const orphanTerm=tot?Math.min(1,(desloc+OPT_STRAND_K*stranded)/tot):0;\n  // split — a município that shares a real sector with ANOTHER live destination candidate (São\n  // Paulo etc.) should consolidate back onto that sibling rather than scatter to an unrelated\n  // sector: leaving it split across multiple sectors after this closure is a structural defect on\n  // its own, independent of balance/adjacência/orphan. Only counts a município where a sibling\n  // destination is actually reachable — nothing to prefer, nothing penalised, when it isn't.\n  let splitBad=0,splitElig=0;\n  for(const m in a){\n    const sib=mv.muniSiblings&&mv.muniSiblings[m]; if(!sib||!sib.length) continue;\n    const avail=sib.filter(s=>mv.dest.includes(s));\n    if(!avail.length) continue;\n    splitElig++;\n    if(!avail.includes(a[m])) splitBad++;\n  }\n  const splitTerm=splitElig?splitBad/splitElig:0;\n  return { cost:OPT_W.balance*balance+OPT_W.dist*dist+OPT_W.orphan*orphanTerm+OPT_W.split*splitTerm+OPT_W.cap*cap,\n           balance, dist, desloc, stranded, sp, viol:spanViolationsOf(sp,lim), splitTerm, cap };\n}\nfunction optScoreFor(mv,a){ return optEval(mv,a,mv.__optDmax||optDistPrep(mv)).cost; }\n// Seeded PRNG on purpose. computeMovement runs the optimizer for EVERY stage and recomputeFrom\n// re-runs every stage downstream of an edit, so a Math.random() portfolio would hand back a\n// different allocation on each pass and a saved timeline would never reproduce.\nfunction optRng(seed){ let a=seed>>>0;\n  return ()=>{ a=(a+0x6D2B79F5)>>>0; let t=Math.imul(a^(a>>>15),1|a);\n    t=(t+Math.imul(t^(t>>>7),61|t))^t; return ((t^(t>>>14))>>>0)/4294967296; }; }\nfunction optSeed(mv,ms){ let h=2166136261>>>0;\n  const s=String(mv.sector)+'|'+ms.join(',')+'|'+mv.dest.join(',');\n  for(let i=0;i<s.length;i++) h=Math.imul(h^s.charCodeAt(i),16777619);\n  return h>>>0; }\n// Cor estável por código de setor — mesmo hash FNV-1a já usado em optSeed, só que aqui memoizado por\n// sessão inteira (não por etapa): o mesmo código sempre cai na mesma cor da PALETTE em QUALQUER\n// etapa do mapa, ao contrário de mv.color (que reindexa por etapa). É o que deixa um município\n// realocado pra um setor que não é candidato agora (ou já foi fechado numa etapa anterior) mostrar\n// uma cor de verdade em vez de cair em cinza \"sem info\".\nconst STABLE_SECTOR_COLOR={};\nfunction colorForSector(code){\n  const k=String(code);\n  if(STABLE_SECTOR_COLOR[k]) return STABLE_SECTOR_COLOR[k];\n  let h=2166136261>>>0;\n  for(let i=0;i<k.length;i++) h=Math.imul(h^k.charCodeAt(i),16777619);\n  const col=PALETTE[(h>>>0)%PALETTE.length];\n  STABLE_SECTOR_COLOR[k]=col;\n  return col;\n}\n// Assigns colors for ONE movement's destination list, guaranteeing no two of them share a color\n// when destList.length<=PALETTE.length. colorForSector alone can't promise that: it's a hash mod\n// PALETTE.length, so two different sector codes routinely land on the same slot (the reported bug —\n// two destinations in the same span/legend/map view painted the same color). First pass keeps each\n// dest's normal stable hash color where it's still free; only actual collisions get bumped to an\n// unused palette slot. Falls back to the stable hash color (repeats become unavoidable) once\n// destList has more entries than PALETTE has colors.\nfunction assignDestColors(destList){\n  const colors={}; const used=new Set();\n  destList.forEach(code=>{\n    const c=colorForSector(code);\n    if(!used.has(c)){ colors[code]=c; used.add(c); }\n  });\n  destList.forEach(code=>{\n    if(colors[code]) return;\n    const free=PALETTE.find(c=>!used.has(c));\n    const chosen=free||colorForSector(code);\n    colors[code]=chosen; used.add(chosen);\n  });\n  return colors;\n}\nfunction contigBadFor(mv,assign){\n  // Connectivity is enforced at every level now: a setor closing redistributes municípios, a GV\n  // closing redistributes setores, a mercado closing redistributes GVs — whichever grain, every\n  // unit sent to the same destination must reach it through mv.closeAdj without leaving the group,\n  // or that destination counts as broken. mv.closeAdj/mv.allowed are already built at unit grain\n  // for every level (see computeMovement), so the same BFS below just works unchanged.\n  const bySec={}; for(const m in assign){(bySec[assign[m]]=bySec[assign[m]]||[]).push(m);}\n  const bad=[];\n  for(const s in bySec){const grp=bySec[s],inGrp=new Set(grp);\n    // A município already split across several real sectors (mv.muniSiblings) is inherently\n    // \"connected\" to any of its own other real owners — sending more of an already-shared\n    // município to a sector that already owns part of it isn't fragmenting new territory, so it\n    // seeds (and never itself breaks) that destination's group even with zero same-sector\n    // closeAdj neighbours of its own. The general chain-connectivity rule below is meant for a\n    // whole, single-sector município moving as one piece; it was never meant to gate this case —\n    // without this, a click on a shared município's own slice (São Paulo etc.) could only ever\n    // land on whichever one destination happened to pass the general geographic-chain check.\n    // mv.realTouch (GV/mercado only, ver computeMovement) substitui mv.allowed como sinal de\n    // semente ali: mv.allowed[u][s] é 1 pra TODO irmão organizacionalmente válido nesse nível (sem\n    // voto geográfico), o que tornava esta checagem um no-op pra fechamento de GV/mercado — todo\n    // setor “semeava” todo destino, nada nunca vinha desconectado. Setor continua lendo mv.allowed\n    // exatamente como antes (mv.realTouch não existe nesse nível).\n    const seeds=grp.filter(m=>{\n      if(mv.realTouch) return !!(mv.realTouch[m]&&mv.realTouch[m][s]);\n      return (mv.allowed[m]||{})[s]||(mv.muniSiblings&&mv.muniSiblings[m]&&mv.muniSiblings[m].includes(s));\n    });\n    if(!seeds.length){bad.push(s);continue;}\n    const seen=new Set(seeds),st=[...seeds];\n    while(st.length){const m=st.pop();(mv.closeAdj[m]||[]).forEach(o=>{if(inGrp.has(o)&&!seen.has(o)){seen.add(o);st.push(o);}});}\n    if(seen.size!==grp.length)bad.push(s);}\n  return bad;\n}\n// Usada pelo clique no mapa quando \"forçar conectividade de território\" está ligado: em vez de\n// avançar cegamente pro próximo destino da lista, pula candidatos que introduziriam uma quebra NOVA\n// (compara contra o baseBad de ANTES do clique, então uma quebra pré-existente alheia a este\n// município não trava o ciclo pra sempre). Sem candidato seguro, mantém o destino atual.\nfunction nextContigSafeDest(mv,m){\n  const n=mv.dest.length; if(!n) return mv.assign[m];\n  const cur=mv.assign[m];\n  const baseBad=new Set(contigBadFor(mv,mv.assign));\n  const i0=mv.dest.indexOf(cur);\n  for(let k=1;k<=n;k++){\n    const cand=mv.dest[((i0<0?0:i0)+k)%n];\n    if(cand===cur) continue;\n    const trial={...mv.assign,[m]:cand};\n    if(!contigBadFor(mv,trial).some(s=>!baseBad.has(s))) return cand;\n  }\n  return cur;\n}\nfunction greedyFor(mv,ms,opts){const lim=spanLimits(mv);const a={};\n  ms.forEach((m,i)=>{let best=opts[i][0],bd=Infinity;\n    for(const s of opts[i]){a[m]=s;\n      // over-max only grows as we keep assigning, so it is safe to penalise mid-build;\n      // under-min shrinks, so penalising it here would just chase noise.\n      const over=lim.max?spanViolationsOf(spanMapFor(mv,a),{max:lim.max,min:null}).over.length:0;\n      const d=optScoreFor(mv,a)+(contigBadFor(mv,a).length?1e9:0)+over*1e6;\n      if(d<bd){bd=d;best=s;}}\n    a[m]=best;});\n  return a;}\n// Fase 16 (pedido do usuário — conectividade nunca mais pode quebrar, nem no pior caso: \"é melhor\n// quebrar uma regra de span do que quebrar uma regra de conectividade\"): antes, quando a busca\n// exaustiva/simulada de optimizeFor não achava NENHUMA divisão 100% conectada dentro do espaço que\n// explorou, ela desistia e devolvia greedyFor puro — que só PENALIZA contigBadFor no score, nunca\n// impede o resultado, e foi exatamente essa desistência que deixou o setor 3637 sair do ★ ÓTIMO com\n// \"Território desconectado\". growConnectedFor substitui esse fallback por uma construção que É\n// conectada por definição: cresce cada destino a partir do território que ele JÁ TEM — os mesmos\n// municípios que contigBadFor aceita como semente válida (mv.allowed[m][s], voto de adjacência a\n// setor vizinho já existente, ou mv.muniSiblings) — e só anexa um município novo quando ele encosta\n// (mv.closeAdj) em algo que aquele destino já ganhou. Nada nunca entra desconectado do resto da\n// própria região; o desempate entre destinos concorrentes por um mesmo município usa o mesmo sinal\n// de equilíbrio que o resto do otimizador (span = base já existente + o que for entrando).\nfunction growConnectedFor(mv,ms,opts){\n  const msSet=new Set(ms);\n  const optsOf={}; ms.forEach((m,i)=>{ optsOf[m]=opts[i]||[]; });\n  const a={};\n  const load={}; mv.dest.forEach(s=>{ load[s]=+(mv.baseMetrics[s]&&mv.baseMetrics[s].cbs)||0; });\n  const cbOf=m=>+(mv.munis[m]&&mv.munis[m].cbs)||0;\n  const pick=cands=>{ let best=cands[0],bl=load[best]; for(const s of cands){ if(load[s]<bl){bl=load[s];best=s;} } return best; };\n\n  // Rodada 0 — sementes: todo município que já encosta no território ATUAL de algum destino\n  // (mv.allowed[m][s]) ou é irmão de município já dividido com ele (mv.muniSiblings) começa ligado\n  // direto a esse destino — os mesmos casos que contigBadFor já trata como semente válida. Empate\n  // (município que encosta em mais de um destino já-existente) vai pro que estiver com menos carga.\n  let frontier=[];\n  ms.forEach(m=>{\n    const allow=optsOf[m]; if(!allow.length) return;\n    // mv.realTouch (GV/mercado only) substitui mv.allowed aqui pelo mesmo motivo do contigBadFor:\n    // mv.allowed[u][s] é 1 pra TODO irmão organizacionalmente válido nesse nível, então ler ele\n    // direto fazia a Rodada 0 \"semear\" TODA unidade em TODO destino de uma vez (sem checar\n    // adjacência nenhuma) — a fase de crescimento por mv.closeAdj logo abaixo nunca chegava a\n    // rodar de verdade, porque nada sobrava pra crescer. Resultado: growConnectedFor, o fallback\n    // que devia ser a garantia final de conectividade (Fase 16), na prática só balanceava carga\n    // sem nenhuma checagem geográfica pra fechamento de GV/mercado — exatamente o bug relatado.\n    const votes=mv.realTouch\n      ? Object.keys(mv.realTouch[m]||{}).filter(s=>allow.includes(s))\n      : Object.keys(mv.allowed[m]||{}).filter(s=>allow.includes(s));\n    const sibs=(mv.muniSiblings&&mv.muniSiblings[m]||[]).filter(s=>allow.includes(s));\n    const cands=[...new Set([...votes,...sibs])];\n    if(!cands.length) return;\n    const s=pick(cands);\n    a[m]=s; load[s]+=cbOf(m); frontier.push(m);\n  });\n\n  // Crescimento em ondas: qualquer município ainda sem destino que seja vizinho (mv.closeAdj) de um\n  // município já atribuído entra na rodada seguinte, ligado a algum destino vizinho que seja opção\n  // legal pra ele (opts) — isto é o que garante, por construção, que a região final de cada destino\n  // é uma peça só: nada é assinalado sem tocar em algo que aquele destino já tem.\n  while(frontier.length){\n    const arrivals={};\n    frontier.forEach(m=>{\n      const s=a[m];\n      (mv.closeAdj[m]||[]).forEach(nb=>{\n        if(!msSet.has(nb)||a[nb]!=null) return;\n        if(optsOf[nb].includes(s)) (arrivals[nb]=arrivals[nb]||new Set()).add(s);\n      });\n    });\n    const next=[];\n    Object.keys(arrivals).forEach(m=>{\n      if(a[m]!=null) return;\n      const s=pick([...arrivals[m]]);\n      a[m]=s; load[s]+=cbOf(m); next.push(m);\n    });\n    frontier=next;\n  }\n\n  // Município isolado do território de todos os destinos por mv.closeAdj, dentro das opções legais\n  // dele (raro — só quando o vizinho já atribuído acabou noutro destino fora do `opts` deste\n  // município): relaxa a restrição de opts e liga a qualquer vizinho já atribuído, de qualquer\n  // destino — ainda uma peça conectada, só não necessariamente na opção preferida.\n  let leftover=ms.filter(m=>a[m]==null);\n  while(leftover.length){\n    let progressed=false; const next=[];\n    leftover.forEach(m=>{\n      const nbDest=(mv.closeAdj[m]||[]).map(nb=>a[nb]).filter(Boolean);\n      if(nbDest.length){ const s=pick(nbDest); a[m]=s; load[s]+=cbOf(m); progressed=true; }\n      else next.push(m);\n    });\n    leftover=next;\n    if(!progressed) break;\n  }\n  // Último recurso genuinamente teórico: município sem nenhum vizinho ligado a nada (ilha total\n  // dentro do próprio fechamento — não deveria existir, já que o setor e seus destinos formam uma\n  // única massa territorial por definição de adjacência). Não há como conectar isto a nada; cai no\n  // destino mais próximo por centróide (optDistCost), a mesma heurística de distância que o resto\n  // do otimizador já usa — a única quebra de conectividade que pode sobrar aqui é real e inevitável,\n  // não fabricada por uma busca que desistiu cedo.\n  if(leftover.length){\n    const dmax=mv.__optDmax||optDistPrep(mv);\n    leftover.forEach(m=>{\n      const allow=optsOf[m].length?optsOf[m]:mv.dest;\n      let best=allow[0],bd=Infinity;\n      for(const s of allow){ const d=optDistCost(mv,m,s,dmax); if(d<bd){bd=d;best=s;} }\n      a[m]=best; load[best]+=cbOf(m);\n    });\n  }\n  return a;\n}\n// TOBE por adjacência: sugestão pura por vizinhança, sem balancear span (isso é trabalho do ★\n// ÓTIMO) — mas nunca pode devolver território quebrado (mesma regra \"conectividade nunca quebra\"\n// da Fase 16, ver growConnectedFor). Trocar autoAssignFor inteiro por growConnectedFor (tentativa\n// anterior) resolvia a quebra mas DEFORMAVA o formato: growConnectedFor cresce cada destino pela\n// MENOR carga atual, sem nenhuma noção de compacidade, e podia esticar um setor inteiro num\n// \"osso\" alongado em vez de manter o formato natural que o voto por adjacência já produzia. Esta\n// versão parte do voto natural (autoAssignFor — já a escolha geograficamente mais razoável) e só\n// realoca município a município enquanto isso reduzir o número de setores quebrados (nunca por\n// equilíbrio de carga); em empate de quebra, prefere o município menos deformado (optDistCost).\n// Só cai no growConnectedFor puro se essa busca local não conseguir zerar a quebra sozinha — o\n// mesmo último recurso do ★ ÓTIMO, garantindo que conectividade nunca deixa de ser respeitada.\nfunction tobeConnectedFor(mv){\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__');\n  if(!ms.length||!mv.dest.length) return {};\n  const dmax=mv.__optDmax||optDistPrep(mv); mv.__optDmax=dmax;\n  const opts=ms.map(m=>{const al=(mv.reachable[m]&&mv.reachable[m].length)?mv.reachable[m]:mv.dest.slice();return al.length?al:mv.dest.slice();});\n  const a=autoAssignFor(mv);\n  ms.forEach((m,i)=>{ if(!opts[i].includes(a[m])) a[m]=opts[i][0]; });\n  const costOf=cand=>{ let tot=0; ms.forEach(m=>{ tot+=optDistCost(mv,m,cand[m],dmax); }); return tot; };\n  for(let pass=0;pass<20;pass++){\n    if(!contigBadFor(mv,a).length) break;\n    let improved=false;\n    for(let i=0;i<ms.length;i++){\n      const m=ms[i], cur=a[m];\n      const curBad=contigBadFor(mv,a).length, curCost=costOf(a);\n      let bestS=cur,bestBad=curBad,bestCost=curCost;\n      opts[i].forEach(s=>{\n        if(s===cur) return;\n        a[m]=s;\n        const nb=contigBadFor(mv,a).length, nc=costOf(a);\n        if(nb<bestBad||(nb===bestBad&&nc<bestCost-1e-9)){ bestS=s; bestBad=nb; bestCost=nc; }\n        a[m]=cur;\n      });\n      if(bestS!==cur){ a[m]=bestS; improved=true; }\n    }\n    if(!improved) break;\n  }\n  if(contigBadFor(mv,a).length) return growConnectedFor(mv,ms,opts);\n  return a;\n}\nfunction optimizeFor(mv){\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__');\n  if(!ms.length||!mv.dest.length){ mv.optBadge=mv.dest.length?'':'sem setor destino identificado'; return {}; }\n  // mv.reachable[m] restringe as opções de m ao que SÓ o voto de adjacência (dele + de quem tá no\n  // mesmo grupo conectado dentro do que está fechando) já alcançou — mais estreito que mv.dest (a\n  // lista completa de destinos legais: mesma GV, ainda não fechado). Ficava faltando exatamente a\n  // opção que o usuário conseguia escolher manualmente na lista \"Municípios → setor destino\" (que\n  // lê mv.dest inteiro, sem essa restrição) — setor 3341, 2 municípios: o pequeno só enxergava\n  // {3499,3409} pelo voto, nunca {3311,3801}, mesmo os dois sendo destinos legítimos e MELHORES\n  // (span final bem menor). Contiguidade continua garantida do mesmo jeito de sempre: contigBadFor,\n  // logo abaixo em offer(), já rejeita qualquer candidato que quebre — nunca foi mv.reachable quem\n  // garantia isso, só reduzia o espaço de busca. Usar mv.dest inteiro pra cada município apenas dá\n  // ao ★ ÓTIMO acesso às mesmas opções que o clique manual sempre teve.\n  const opts=ms.map(()=>mv.dest.slice());\n  const combos=opts.reduce((a,o)=>a*o.length,1);\n  const lim=spanLimits(mv);\n  const dmax=optDistPrep(mv); mv.__optDmax=dmax;\n\n  // Feasibility ladder — same meaning as before, because renderSpanRule and the saved sessions\n  // both read mv.optTier: 0 = contíguo + max + min span, 1 = contíguo + max span, 2 = contíguo\n  // only. Every candidate any simulation produces is offered to all three tiers; we ship the\n  // strictest tier that got a solution, so an unreachable min/max relaxes loudly instead of\n  // stranding the stage with nothing.\n  const tier=[null,null,null],tSc=[Infinity,Infinity,Infinity],tEv=[null,null,null];\n  // tExcess: soma (em CBs) de quanto cada span passa do máximo ou fica abaixo do mínimo, pro\n  // candidato guardado em cada tier. Só muda alguma coisa no tier 2 (nenhum candidato respeita os\n  // limites) — ali, ANTES de decidir por equilíbrio/adjacência/quebra (ev.cost), a busca agora\n  // prioriza o menor estouro TOTAL de span. Sem isto, dois candidatos igualmente \"sem limite\n  // respeitado\" eram desempatados só pelo custo multi-critério — e como o peso de adjacência\n  // (OPT_W.dist=0.85) é quase do tamanho do peso de equilíbrio (1), um candidato que empilhava\n  // TUDO num único setor (estouro de 1.462 CBs, setor 3341→3499) podia vencer um candidato que\n  // distribuía entre dois setores (estouro de só 753 CBs) apenas por ficar geograficamente mais\n  // \"puro\" — o oposto do que ★ ÓTIMO promete (\"equilibra a carga entre os setores\"). Nos tiers 0/1\n  // todo candidato que os alcança já tem estouro de MÁXIMO zero por definição (put(1)/put(0) só\n  // rodam quando ev.viol.over está vazio), então o comportamento ali não muda pra esse caso.\n  const tExcess=[Infinity,Infinity,Infinity];\n  let feasible=0,evals=0;\n  let ctgBest=null,ctgCost=Infinity,ctgEv=null,ctgBad=null;\n  const excessOf=sp=>{ let e=0; Object.values(sp).forEach(v=>{ if(lim.max&&v>lim.max)e+=v-lim.max; if(lim.min&&v<lim.min)e+=lim.min-v; }); return e; };\n  const offer=(a,ev)=>{\n    const badSectors=contigBadFor(mv,a);\n    if(badSectors.length){\n      if(ev.cost<ctgCost){ ctgCost=ev.cost; ctgBest={...a}; ctgEv=ev; ctgBad=badSectors; }\n      return false;\n    }\n    feasible++;\n    const exc=excessOf(ev.sp);\n    const put=t=>{ if(exc<tExcess[t]-1e-9||(Math.abs(exc-tExcess[t])<=1e-9&&ev.cost<tSc[t])){ tExcess[t]=exc; tSc[t]=ev.cost; tier[t]={...a}; tEv[t]=ev; } };\n    put(2);\n    if(!ev.viol.over.length){ put(1); if(!ev.viol.under.length) put(0); }\n    return true;\n  };\n  const evalOf=a=>{ evals++; return optEval(mv,a,dmax); };\n\n  // Small enough to settle the question outright: enumerate every contiguous allocation and the\n  // winner is provably the best one under this objective. No search needed, so none is run.\n  const exhaustive=combos<=OPT_EXHAUSTIVE_MAX;\n  let sims=0;\n  if(exhaustive){\n    const cur={};\n    const rec=i=>{ if(i===ms.length){ offer(cur,evalOf(cur)); return; }\n      for(const s of opts[i]){ cur[ms[i]]=s; rec(i+1); } };\n    rec(0);\n  } else {\n    // Too big to enumerate, so run a portfolio of independent simulations instead. Each one is a\n    // seed assignment, then hill-climbing on single-município reassignments, then one perturbation\n    // and another climb to escape whatever local optimum it settled into. Different seeds land in\n    // different basins; the ladder above keeps the best across all of them.\n    const improve=a=>{\n      let ev=evalOf(a); offer(a,ev);\n      for(let pass=0;pass<OPT_PASSES;pass++){\n        let moved=false;\n        for(let i=0;i<ms.length;i++){\n          const m=ms[i],cur=a[m]; let best=cur,bev=ev;\n          for(const s of opts[i]){\n            if(s===cur) continue;\n            a[m]=s;\n            const e=evalOf(a);\n            // contiguity is a hard constraint, and the cheaper test runs first on purpose\n            if(e.cost<bev.cost-1e-12 && !contigBadFor(mv,a).length){ best=s; bev=e; }\n          }\n          a[m]=best;\n          if(best!==cur){ ev=bev; moved=true; offer(a,ev); }\n        }\n        if(!moved) break;\n      }\n    };\n    const rng=optRng(optSeed(mv,ms));\n    const perSim=Math.max(1,ms.length*Math.max(1,mv.dest.length)*OPT_PASSES);\n    const budget=Math.max(4,Math.min(OPT_SIMS,Math.floor(OPT_EVAL_BUDGET/perSim)||4));\n    const seeds=[];\n    if(mv.assign&&ms.every(m=>mv.assign[m])) seeds.push({...mv.assign});   // never regress on what is already on screen\n    seeds.push(autoAssignFor(mv));                                        // adjacency-first seed\n    seeds.push(greedyFor(mv,ms,opts));                                    // balance-first seed\n    while(seeds.length<budget){\n      // randomised construction biased toward nearby sectors — the bias keeps each seed sane, the\n      // randomness is what sends the runs into different basins\n      const a={};\n      ms.forEach((m,i)=>{ let bs=opts[i][0],bw=-Infinity;\n        for(const s of opts[i]){ const w=(1-optDistCost(mv,m,s,dmax))+rng()*0.9; if(w>bw){bw=w;bs=s;} }\n        a[m]=bs; });\n      seeds.push(a);\n    }\n    seeds.forEach(seed=>{\n      const a={...seed};\n      // a restored or legacy assign can still name a sector this stage no longer offers\n      ms.forEach((m,i)=>{ if(!opts[i].includes(a[m])) a[m]=opts[i][0]; });\n      improve(a);\n      const kick=Math.max(1,Math.round(ms.length*0.25));\n      for(let k=0;k<kick;k++){ const i=Math.floor(rng()*ms.length); a[ms[i]]=opts[i][Math.floor(rng()*opts[i].length)]; }\n      improve(a);\n    });\n    sims=seeds.length;\n  }\n\n  const t=tier.findIndex(x=>x);\n  if(t<0){\n    // Fase 16: nunca mais cai pro greedyFor puro aqui — growConnectedFor sempre devolve uma\n    // partição conectada por construção. Pior equilíbrio de span que o ★ ÓTIMO quando ele acha\n    // solução, mas conectividade nunca quebra — passa a valer mais que span, por pedido explícito.\n    const grown=growConnectedFor(mv,ms,opts);\n    const ev=evalOf(grown);\n    mv.optTier=-1;\n    mv.optStats={balance:ev.balance,dist:ev.dist,desloc:ev.desloc,stranded:ev.stranded,sims,evals,feasible,combos,exhaustive,split:ev.splitTerm};\n    mv.optBadge=`ÓTIMO por adjacência (conectado) · equilíbrio ±${Math.round(ev.balance*100)}% · adjacência ${Math.round((1-ev.dist)*100)}%`\n      +` · quebra ${fmt(ev.desloc)} CBs · ${fmt(ev.stranded)} fora do setor sem destino · pico ${fmt(maxSpanOfFor(mv,grown))} CBs`;\n    mv.optAlternatives={ tiers:[], contigBroken:null };\n    return grown;\n  }\n  const best=tier[t], ev=tEv[t]; mv.optTier=t;\n  const relax=[];\n  if(t>0 && lim.min) relax.push('span mínimo '+fmt(lim.min));\n  if(t>1 && lim.max) relax.push('span máximo '+fmt(lim.max));\n  mv.optStats={balance:ev.balance,dist:ev.dist,desloc:ev.desloc,stranded:ev.stranded,sims,evals,feasible,combos,exhaustive,split:ev.splitTerm};\n  const hasSplitMunis=Object.keys(mv.muniSiblings||{}).some(m=>(mv.muniSiblings[m]||[]).some(s=>mv.dest.includes(s)));\n  mv.optBadge=`ótimo · ${exhaustive?`espaço completo (${fmt(combos)})`:`${fmt(sims)} simulações`}`\n    +` · equilíbrio ±${Math.round(ev.balance*100)}% · adjacência ${Math.round((1-ev.dist)*100)}%`\n    +` · quebra ${fmt(ev.desloc)} CBs · ${fmt(ev.stranded)} fora do setor sem destino`\n    +(hasSplitMunis?` · consolidação de município compartilhado ${Math.round((1-ev.splitTerm)*100)}%`:'')\n    +` · pico ${fmt(maxSpanOfFor(mv,best))} CBs`\n    +(lim.target?` · alvo ${fmt(lim.target)}`:'')\n    +(relax.length?` · ⚠ inatingível: ${relax.join(' + ')}`:(spanLimitsOn(lim)?' · limites ok':''));\n  mv.optAlternatives={\n    tiers:[0,1,2].filter(i=>i!==t && tier[i]).map(i=>({assign:tier[i], ev:tEv[i], tierIndex:i})),\n    contigBroken: ctgBest?{assign:ctgBest, ev:ctgEv, badSectors:ctgBad}:null,\n  };\n  return best;\n}\nfunction computeDerived(mv){\n  const inc={},gained={},orph={};mv.dest.forEach(s=>{inc[s]=0;gained[s]=0;orph[s]=0;});\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(s&&inc[s]!=null)inc[s]+=o.cbs;});\n  let desloc=0,orphanTotal=0,orphanStranded=0;\n  const L2=mv.leaders.map(L=>{const byS={};mv.dest.forEach(s=>byS[s]=0);let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=mv.assign[m];if(s&&byS[s]!=null){byS[s]+=c;tot+=c;}}\n    // no allocated CBs => no home. Picking dest[0] by default would silently dump this leader's\n    // orphan CBs into an arbitrary sector, so we leave it null and report them as stranded.\n    const home=(tot>0&&mv.dest.length)?mv.dest.reduce((a,s)=>byS[s]>byS[a]?s:a,mv.dest[0]):null;\n    const dl=tot-(home?byS[home]:0);desloc+=dl;if(home)gained[home]++;\n    const orphan=(mv.orphanByGroup&&mv.orphanByGroup[L.grupo])||0;\n    orphanTotal+=orphan;\n    if(orphan){ if(home&&orph[home]!=null) orph[home]+=orphan; else orphanStranded+=orphan; }\n    return {...L,byS,home,desloc:dl,assignedTotal:tot,orphan,orphanHome:(orphan&&home)?home:null};});\n  const span={};mv.dest.forEach(s=>span[s]=(+(mv.baseMetrics[s]?.cbs||0))+inc[s]+orph[s]);\n  return {inc,gained,desloc,leaders:L2,span,orph,orphanTotal,orphanStranded};\n}\n\nasync function computeMovement(code,level,world){\n  level=level||'setor';\n  code=String(code);\n  const mv={sector:code,level,policy:'fora',view:'tobe',selMuni:null,optBadge:''};\n  const SNAP=SESS.snapLiteral;\n  // GV/mercado precisam da base ANTES de montar a query (pra saber quais CD_SETOR entram no IN);\n  // setor não precisa — mantém o timing original (a query dispara sem esperar o CSV).\n  let domBase=level==='setor'?null:await ensureDominantes();\n  // GV/mercado destinations are ORGANIZATIONAL siblings (same mercado / any mercado at all), never\n  // discovered by geographic adjacency the way setor destinations are — a sibling can sit UFs away\n  // from the closing unit's own footprint, in territory the geo-fetch below (scoped to the CLOSING\n  // unit's own UFs, see the comment further down) never requests a shape for. Resolved here, early,\n  // because everything it needs (DOM.*, world) is already available before any SQL/geo fetch runs —\n  // folded into ufSet alongside the closing unit's own UFs so a valid sibling's territory always has\n  // a shape to draw instead of leaving a hole exactly where it sits.\n  const extraDestUFs=new Set();\n  if(level!=='setor' && domBase){\n    const ownParentEarly=parentOf(level,code);\n    const siblingsEarly = level==='gv' ? [...(DOM.gvsByMercado[ownParentEarly]||[])] : Object.keys(DOM.sectorsByMercado||{});\n    siblingsEarly.filter(s=>s!==code).forEach(destCode=>{\n      memberSectorsOf(level,destCode,world).forEach(s=>{\n        (domBase[String(s)]||new Set()).forEach(entry=>{ const u=mUf(entry); if(u) extraDestUFs.add(u); });\n      });\n    });\n  }\n  const memberSectors=level==='setor'?[code]:memberSectorsOf(level,code,world);\n  if(!memberSectors.length) throw new Error(`${LEVELS[level].label} ${code} não existe na base de municípios dominantes (CSV) — atualize a base antes de fechar`);\n  const secList=memberSectors.join(',');\n\n  // PERIODO (18/09, medido no 3225): receita/pontos/pedidos vinham dos campos *_CICLO do CRM, que sao\n  // ACUMULADORES DO CICLO ABERTO, enquanto cbs/ativas ja vinham do ELO no ciclo FECHADO. A mesma linha\n  // da matriz misturava dois periodos. Medido: no snapshot de hoje (ciclo atual 202614, recem-comecado)\n  // o CRM do 3225 da R$ 66.742 e 73 pedidos, onde o ciclo fechado 202613 tem R$ 914.761 e 1.352 pedidos\n  // - 13,7x menos. Era isso que produzia \"1.005 ativas com 73 pedidos\" (contradicao por definicao) e\n  // dois setores de mesmo numero de CBs com receitas muito diferentes: um a 9 dias do ciclo, o outro a 2.\n  // O comentario antigo (ver bSQL) dizia que os campos do ELO nao batiam, \"~2-2,4x, outra definicao de\n  // metrica\". Nao e definicao, e o ciclo parcial: medindo o MESMO ciclo eles batem.\n  // MARCA: revenue_sd_ss_amt e o global (mesmo campo do trendSQL, indicado pelo time de dados da\n  // Natura); revenue_nat_ss_amt / revenue_avon_ss_amt preservam a separacao por marca, que e pedida.\n  // Conferido que (person_code, operational_cycle, country_code) e unico no ELO - 2.002 linhas para\n  // 2.002 pessoas no 3225/202613 - entao o LEFT JOIN soma sem duplicar. CB sem linha no ELO daquele\n  // ciclo entra como NULL e soma zero, que e o correto: nao teve atividade no ciclo fechado.\n  // Reconciliacao a confirmar com a Natura: no 202613, nat+avon = 918.062 e revenue_sd_ss_amt = 914.761\n  // (-0,36%); revenue_ss_amt daria 939.786 (+2,4%, inclui canal online). Mantido o sd_ss por coerencia\n  // com o grafico de tendencia; a diferenca de 0,36% entre o global e a soma das marcas segue aberta.\n  const mSQL=`SELECT t.DS_MUNICIPIO municipio, t.CD_SETOR setor, t.CD_GRUPO grupo, MAX(t.DS_LEADER_NAME) lider,\n     COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs, ROUND(SUM(e.revenue_sd_ss_amt),0) receita,\n     ROUND(SUM(e.revenue_nat_ss_amt),0) rec_nat, ROUND(SUM(e.revenue_avon_ss_amt),0) rec_avon,\n     ROUND(SUM(e.points_ss_amt),0) pontos, ROUND(SUM(e.orders_cnt),0) pedidos,\n     SUM(e.active_bc_ind) ativas\n    FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n    WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR IN (${secList}) AND t.DT_HISTORICO=${SNAP}\n    GROUP BY t.DS_MUNICIPIO, t.CD_SETOR, t.CD_GRUPO`;\n  const ufSQL=`SELECT DS_ESTADO uf, COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${secList}) AND DT_HISTORICO=${SNAP} GROUP BY DS_ESTADO ORDER BY n DESC`;\n  // MARCA (19/09, medido no 3225 ciclo 202613): revenue_nat_ss_amt e points_nat_ss_amt são Natura\n  // PURA. A tendência mostrava 795.098 de receita onde o setor fez 914.761 — perdia os 122.964 da\n  // Avon inteira, 13% do setor; em pontos, 131.661 contra 161.153, 22% a menos. Pior que o erro em\n  // si: a tabela \"antes → depois\" logo acima usa VL_PEDIDOS_CICLO do CRM, que SOMA as marcas, então\n  // as duas leituras do mesmo setor conviviam na mesma tela com definições diferentes e nada dizendo.\n  // revenue_sd_ss_amt e points_ss_amt são os campos globais indicados pelo time de dados da Natura.\n  // active_bc_ind JÁ é global e fica como está: medido, nat_active(852) + avon_active(543)\n  // − mista_active(390) = 1.005 = active_bc_ind. NÃO trocar por combined_active (315), que conta\n  // outra coisa — quem é ativa nas duas marcas no mesmo ciclo.\n  // A separação por marca de rec_nat/rec_avon nas outras queries é deliberada e permanece.\n  const trendSQL=`SELECT operational_cycle ciclo, COUNT(DISTINCT CASE WHEN business_status_code=3 THEN person_code END) cbs,\n     SUM(active_bc_ind) ativas,\n     ROUND(SUM(revenue_sd_ss_amt),0) receita, ROUND(SUM(points_ss_amt),0) pontos\n    FROM ${ELO} WHERE country_code=1 AND sector_code IN (${secList}) AND operational_cycle<=${SESS.eloMaxCycle||999999} GROUP BY operational_cycle ORDER BY operational_cycle DESC LIMIT 8`;\n  const lvlSQL=`SELECT t.DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs,\n     ROUND(SUM(e.revenue_nat_ss_amt),0) rec_nat, ROUND(SUM(e.revenue_avon_ss_amt),0) rec_avon\n    FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n    WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR IN (${secList}) AND t.DS_NVL_PLAN_CRESC IS NOT NULL AND t.DT_HISTORICO=${SNAP} GROUP BY t.DS_NVL_PLAN_CRESC ORDER BY cbs DESC`;\n  const lvlMuniSQL=`SELECT t.DS_MUNICIPIO municipio, t.CD_SETOR setor, t.DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs\n    FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n    WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR IN (${secList}) AND t.DS_NVL_PLAN_CRESC IS NOT NULL AND t.DT_HISTORICO=${SNAP} GROUP BY t.DS_MUNICIPIO, t.CD_SETOR, t.DS_NVL_PLAN_CRESC`;\n  const [mR,ufR,tR,lR,lmR]=await Promise.all([q(mSQL),q(ufSQL),q(trendSQL),q(lvlSQL),q(lvlMuniSQL)]);\n\n  const mRows=rows(mR);\n  // Fase 18 (pedido do usuário — setor 3322 \"fechou com sucesso\" mas saiu com base 0 CBs, quebra 0\n  // CBs, 0 CBs realocadas, todo entrante \"(+0)\": T simplesmente não tinha NENHUMA linha pra este\n  // setor neste DT_HISTORICO exato, mesmo o setor sendo real — existe na base CSV de dominantes\n  // (DOM.bySetor) e tem dado real no ELO pro ciclo fechado (confirmado pelo usuário via query direta\n  // no commercial_consultant_index). Sem nenhuma linha de T, mv.matrix nunca recebe o(s) município(s)\n  // reais deste setor; o fallback de zeroCbNames mais abaixo (pensado pra um município GENUINAMENTE\n  // sem CB dentro de um setor que TEM outras linhas) tratava o setor inteiro como se realmente não\n  // tivesse nenhuma CB, e o fechamento rodava até o fim com todo número silenciosamente zerado.\n  // Pedido do usuário (segunda rodada): não travar — carregar o que der pra carregar e dizer\n  // claramente o que não pôde ser extraído. T é a ÚNICA fonte de granularidade de\n  // município/grupo/líder (o ELO só tem sector_code); então o único caso recuperável sem ambiguidade\n  // de rateio é um setor com EXATAMENTE 1 município na base — aí 100% do agregado do ELO (que TEM\n  // cbs/ativas/receita/pontos/pedidos por setor, ver cSQL logo abaixo, mesma fonte) só pode ser\n  // daquele único município, sem precisar saber a divisão real por líder/grupo (fica marcada como\n  // desconhecida). Com 0 ou 2+ municípios não há como decidir o rateio, e aí sim isto continua\n  // abortando alto — inventar uma divisão seria pior que travar.\n  if(!mRows.length){\n    let eloAgg=null;\n    try{\n      const er=rows(await q(`SELECT COUNT(DISTINCT CASE WHEN business_status_code=3 THEN person_code END) cbs, SUM(active_bc_ind) ativas, ROUND(SUM(revenue_sd_ss_amt),0) receita, ROUND(SUM(revenue_nat_ss_amt),0) rec_nat, ROUND(SUM(revenue_avon_ss_amt),0) rec_avon, ROUND(SUM(points_ss_amt),0) pontos, ROUND(SUM(orders_cnt),0) pedidos FROM ${ELO} WHERE country_code=1 AND sector_code=${code} AND operational_cycle=${SESS.eloMaxCycle}`));\n      eloAgg=er.length?er[0]:null;\n    }catch(e){ /* diagnostic only — a failure here must not block the hard error below */ }\n    const eloCbs=eloAgg?(+eloAgg.cbs||0):0;\n    const domBaseNow=level==='setor'?await ensureDominantes():null;\n    const ownMunisNow=(level==='setor'&&domBaseNow&&domBaseNow[code])?[...domBaseNow[code]]:[];\n    if(eloCbs && ownMunisNow.length===1){\n      const onlyMuni=ownMunisNow[0];\n      mv.matrixLeaderUnknown=true;\n      mv.zeroCbsWarning=`${LEVELS[level].label} ${code}: T não trouxe nenhuma linha de município/líder neste snapshot congelado (${SNAP}) — usando o total agregado do ELO (${fmt(eloCbs)} CBs, ciclo ${SESS.eloMaxCycle}) atribuído ao único município da base (${mName(onlyMuni)}), SEM detalhamento real de líder/grupo — o líder aparece como \"desconhecido\" neste resultado.`;\n      mRows.push({municipio:mName(onlyMuni), setor:code, grupo:'__T_MISSING__', lider:'(líder desconhecido — T incompleto)',\n        cbs:eloCbs, receita:+eloAgg.receita||0, rec_nat:+eloAgg.rec_nat||0, rec_avon:+eloAgg.rec_avon||0, pontos:+eloAgg.pontos||0, pedidos:+eloAgg.pedidos||0, ativas:+eloAgg.ativas||0});\n    } else {\n      const eloHint=eloCbs?` (o ELO tem ${fmt(eloCbs)} CBs pra este setor no ciclo ${SESS.eloMaxCycle}, mas a base de dominantes lista ${ownMunisNow.length} município(s) pra ele — só dá pra recuperar automaticamente o caso de exatamente 1, onde não há ambiguidade de rateio por município)`:'';\n      throw new Error(`${LEVELS[level].label} ${code}: 0 linhas no CRM (T) neste snapshot congelado (${SNAP}) — a extração diária está incompleta pra este ${lc(LEVELS[level].label)} específico, então não há como montar a matriz de município/líder real dele${eloHint}. Aguarde uma nova sincronização do snapshot antes de tentar de novo.`);\n    }\n  }\n  const ownMatrix=mRows.filter(r=>+r.cbs>0);\n  mv.pureOwnMetrics=blankM();\n  ownMatrix.forEach(r=>{ mv.pureOwnMetrics.cbs+=+r.cbs; mv.pureOwnMetrics.receita+=+r.receita||0; mv.pureOwnMetrics.rec_nat+=+r.rec_nat||0; mv.pureOwnMetrics.rec_avon+=+r.rec_avon||0; mv.pureOwnMetrics.pontos+=+r.pontos||0; mv.pureOwnMetrics.pedidos+=+r.pedidos||0; mv.pureOwnMetrics.ativas+=+r.ativas||0; });\n  // Fase 17 (pedido do usuário — setor 3836 via ?apply= saiu com \"0 CBs realocadas\" sem nada\n  // sinalizando a causa): quando a unidade TEM presença real no CRM (linhas de município/grupo\n  // vieram) mas a junção com o ELO no ciclo escolhido não bateu com NENHUMA delas, o fechamento\n  // saía com 0 CBs indistinguível de \"esta unidade realmente não tem CB nenhum\". Não corrige a\n  // causa (pode ser ciclo ELO desatualizado pra esta unidade especificamente, ou snapshot que\n  // mudou de dia entre duas tentativas) — só torna VISÍVEL, no mesmo estilo aviso que\n  // missingMunis/excludedCbs já usam em renderKpis.\n  // mv.zeroCbsWarning pode já ter sido setado acima (Fase 18 — fallback pro agregado do ELO quando\n  // T não trouxe NENHUMA linha): preserva essa mensagem em vez de sobrescrever com null só porque\n  // o synthetic row injetado ali faz ownMatrix.length parar de ser 0 — as duas mensagens cobrem\n  // situações diferentes e nunca coexistem de verdade (uma exige mRows vazio, a outra exige\n  // mRows não-vazio antes do fallback), mas a ordem de execução aqui embaixo apagava a de cima.\n  mv.zeroCbsWarning=mv.zeroCbsWarning||((mRows.length>0 && !ownMatrix.length)\n    ? `${LEVELS[level].label} ${code} tem ${fmt(mRows.length)} município/grupo no CRM mas 0 CBs no ciclo ELO ${SESS.eloMaxCycle} — confira se este é o ciclo certo antes de confiar neste resultado.`\n    : null);\n\n  mv.levelByMuni={};\n  if(level==='setor') Object.entries(world.muniLevel||{}).forEach(([m,lv])=>{ if(world.muniOverride[m]===code) mv.levelByMuni[m]={...lv}; });\n\n  mv.trend=rows(tR).map(r=>({...r,cbs:+r.cbs,ativas:+r.ativas,receita:+r.receita,pontos:+r.pontos})).filter(r=>r.cbs>10).reverse();\n  mv.levels=rows(lR);\n\n  let absorbedRows=[],inheritedOrphanRows=[];\n  if(level==='setor'){\n    Object.entries(world.muniOverride).forEach(([m,destSec])=>{ if(destSec===code && world.muniRow[m]) absorbedRows.push(...world.muniRow[m]); });\n    inheritedOrphanRows=(world.sectorOrphanRows&&world.sectorOrphanRows[code])||[];\n  }\n  mv.matrix=ownMatrix.concat(absorbedRows,inheritedOrphanRows);\n\n  const ufList=rows(ufR).map(r=>r.uf).filter(Boolean);\n  const domUF=ufList[0]||'PR';\n  mv.domUF=domUF;\n  domBase=domBase||await ensureDominantes();\n  // Uma GV ou mercado pode espalhar por mais UFs do que \"a UF dominante + suas vizinhas\" cobre —\n  // toda UF que aparece na base de dominantes de QUALQUER setor-membro entra na janela de shapes.\n  const memberUFs=new Set([domUF]);\n  memberSectors.forEach(s=>{ (domBase[String(s)]||new Set()).forEach(entry=>{ const u=mUf(entry); if(u) memberUFs.add(u); }); });\n  const ufSet=new Set(memberUFs);\n  memberUFs.forEach(u=>(UF_BORDERS[u]||[]).forEach(b=>ufSet.add(b)));\n  extraDestUFs.forEach(u=>ufSet.add(u));\n  mv.ufList=[...ufSet];\n  const geos=await Promise.all([...ufSet].map(u=>geoUF(u).catch(()=>null)));\n  const allFeat=geos.filter(Boolean).flatMap(g=>g.features);\n  const adj=buildAdj(allFeat);\n\n  // A UF só vem da base de dominantes (setor real da linha × município), nunca de \"esse nome só\n  // apareceu numa UF carregada\" ou de um chute pra UF dominante do fechamento inteiro. Os dois\n  // eram seguros quando só 1-3 UFs entravam na janela (fechamento de setor); GV e principalmente\n  // mercado carregam dezenas de UFs de uma vez, então \"único entre as UFs carregadas\" deixa de\n  // significar \"único no Brasil\" — era exatamente esse chute que colocava um homônimo de outra UF\n  // espalhado longe do resto do mapa. Sem entrada na base, o município fica sem UF (mesmo\n  // tratamento de \"sem shape\" que já existe) em vez de arriscar aparecer no lugar errado.\n  const ufGuessed={};\n  const ufFor=(setorOf,muni)=>{const nm=norm(muni);\n    return (DOM.ufBySetorMuni&&DOM.ufBySetorMuni[String(setorOf)+'|'+nm])||'';};\n  mv.matrix.forEach(r=>{ if(!r.uf) r.uf=ufFor(r.setor,r.municipio); });\n  rows(lmR).forEach(r=>{ const k=mkey(r.municipio,ufFor(r.setor,r.municipio)); (mv.levelByMuni[k]=mv.levelByMuni[k]||{})[r.nivel]=+r.cbs; });\n\n  const totBy={}; mv.matrix.forEach(r=>{const n=mkey(r.municipio,r.uf); totBy[n]=(totBy[n]||0)+ +r.cbs;});\n  const footprint=new Set(Object.entries(totBy).filter(([,c])=>c>0).map(([m])=>m));\n  const withShape=[...footprint].filter(n=>adj.feat[n]||world.muniGeo[n]);\n  mv.cbsByMuni={...totBy}; mv.shapeMunis=withShape.slice();\n  mv.noShapeMunis=[...footprint].filter(n=>!(adj.feat[n]||world.muniGeo[n])).map(n=>({muni:n,cbs:totBy[n]||0})).sort((a,b)=>b.cbs-a.cbs);\n  mv.noShapeCbs=mv.noShapeMunis.reduce((a,x)=>a+x.cbs,0);\n\n  // ---- RULE: quais municípios pertencem a este fechamento ----\n  // setor: exatamente a regra de hoje. GV/mercado: união da base de TODOS os setores-membro — a\n  // base já garante que um setor pertence a uma única GV/mercado, então não existe aqui o spillover\n  // fuzzy que município tem contra setor (por isso o orphan-handling abaixo é pulado nesses níveis).\n  let domOwn;\n  if(level==='setor'){\n    domOwn=domBase[code]||new Set();\n  } else {\n    domOwn=new Set();\n    memberSectors.forEach(s=>(domBase[String(s)]||new Set()).forEach(n=>domOwn.add(n)));\n  }\n  const inherited=level==='setor'?new Set(Object.keys(world.muniOverride||{}).filter(m=>world.muniOverride[m]===code)):new Set();\n  if(!domOwn.size && !inherited.size) throw new Error(`${LEVELS[level].label} ${code} não existe na base de municípios dominantes (CSV) — atualize a base antes de fechar`);\n  const belongs=n=>domOwn.has(n)||(!DOM.hasUf&&domOwn.has(mName(n)))||inherited.has(n);\n  mv.domCsvMunis=domOwn.size;\n  // KPI \"Municípios fora do setor\" (matriz ASIS/TOBE): CBs por grupo cujo município — direto da\n  // linha crua do CRM em mv.matrix, sem passar por nenhuma camada intermediária — não está listado\n  // na base de dominantes (dominantesCsvUrl) para ESTE setor. Checagem direta contra domOwn/\n  // inherited, independente de já ter (ou não) shape IBGE desenhado — deliberadamente NÃO reaproveita\n  // a marcação `_out`/geoFeat de mv.orphanByGroup (aggregateMovement), que existe pra decidir pra\n  // onde a CB vai na redistribuição (mecânica de simulação, restrita a quem tem shape), não pra\n  // reportar \"está fora da base ou não\" — um município sem shape na UF carregada também conta aqui.\n  mv.foraDoSetorByGrupo={};\n  if(level==='setor'){\n    mv.matrix.forEach(r=>{\n      const m=mkey(r.municipio,r.uf);\n      if(belongs(m)) return;\n      const g=String(r.grupo);\n      mv.foraDoSetorByGrupo[g]=(mv.foraDoSetorByGrupo[g]||0)+ +r.cbs;\n    });\n  }\n  // Município da base de dominantes sem NENHUM CB neste snapshot ainda pertence ao setor — a base é\n  // a fonte de verdade de posse, não o CRM do ciclo. Sem isto ele nunca entra em closingNames, nunca\n  // recebe destino em mv.assign (foldWorldState pula município sem mv.assign[m] ao dobrar o mundo) e\n  // continua aparecendo pra sempre como se o setor dissolvido ainda existisse (.closed-ctx) em\n  // qualquer etapa futura da timeline, mesmo depois desta etapa já ter decidido pra onde ele vai.\n  // zeroCbNames marca esses municípios pra excluir do VOTO de adjacência mais abaixo — sem CB pra\n  // realocar, um município isolado (ou geograficamente distante do miolo real do setor, um problema\n  // comum de qualidade da base CSV que ficava invisível enquanto ele tinha 0 CBs e nunca era\n  // desenhado) não pode sozinho puxar um destino novo e desconexo pro mv.dest; ele ainda é desenhado\n  // e ainda recebe um destino real, via o fallback de optimizeFor pro mv.dest já estabelecido pelos\n  // votos reais.\n  // Belongs to EVERY level now, not just setor: a GV/mercado closure needs the exact same treatment\n  // for a município whose owning setor has zero live CBs right now — the base (CSV) is still the\n  // source of truth for territory, not the current cycle's CRM snapshot. Left setor-gated, this\n  // município (and everything drawn from it — mv.geoFeat, mv.unitMunis, the whole coloured area) simply\n  // never entered the map at GV/mercado grain: a real hole exactly where real territory is.\n  const zeroCbNames=new Set();\n  domOwn.forEach(n=>{\n    if(footprint.has(n)) return;\n    const feat=adj.feat[n]||world.muniGeo[n]; if(!feat) return;\n    totBy[n]=0; mv.cbsByMuni[n]=0; footprint.add(n); withShape.push(n); zeroCbNames.add(n);\n  });\n  const dropped=level==='setor'?withShape.filter(n=>!belongs(n)):[];\n  if(withShape.length && dropped.length===withShape.length) throw new Error(`${LEVELS[level].label} ${code}: nenhum dos municípios com CBs está na base de dominantes — nada pra realocar`);\n  const droppedSet=new Set(dropped);\n  mv.excludedMunis=dropped.map(n=>({muni:n,cbs:totBy[n]||0})).sort((a,b)=>b.cbs-a.cbs);\n  mv.excludedCbs=dropped.reduce((a,n)=>a+(totBy[n]||0),0);\n\n  const closingNames=withShape.filter(n=>!droppedSet.has(n));\n  const closingSet=new Set(closingNames);\n  mv.geoFeat={}; closingNames.forEach(n=>{ mv.geoFeat[n]=(adj.feat[n]&&adj.feat[n].f)||world.muniGeo[n]; });\n\n  // ---- unidade de realocação: a geometria é sempre município; a UNIDADE que troca de dono é o\n  // próprio município (setor fecha), o setor inteiro (GV fecha) ou a GV inteira (mercado fecha).\n  const municipioSetor={}; mv.matrix.forEach(r=>{ const n=mkey(r.municipio,r.uf); if(!municipioSetor[n]) municipioSetor[n]=String(r.setor); });\n  const resolveUnit=n=>{\n    if(level==='setor') return n;\n    // A zero-CB município (injected above from the CSV) has no mv.matrix row at all, so\n    // municipioSetor never learns its setor from the live snapshot — fall back to the same CSV base\n    // everything else here already trusts (DOM.setorByMuni), instead of losing it from its unit.\n    let sOf=municipioSetor[n]||DOM.setorByMuni[n]; if(!sOf) return '';\n    // A setor that already closed EARLIER in this session (a setor-level etapa before this GV/mercado\n    // one) no longer really owns n — n's live CD_SETOR still says otherwise (the CRM snapshot never\n    // learns about a simulated closure), so without this n kept showing up as belonging to a setor\n    // that's supposed to be gone, as if that etapa never happened. world.muniOverride already carries\n    // the fully-resolved (chain-collapsed through any number of prior setor closures) current owner.\n    if(world.closedSectors.has(sOf) && world.muniOverride[n]) sOf=world.muniOverride[n];\n    if(level==='gv') return sOf;\n    // mercado: same idea one level up — this setor's GV may have already closed (a gv-level etapa\n    // earlier in this session) and sent it to a sibling GV. world.secOverride carries that redirect\n    // (built the same way foldWorldState builds muniOverride, just one level up).\n    return (world.secOverride && world.secOverride[sOf]) || DOM.gvBySetor[sOf] || '';\n  };\n  mv.unitOfMuni={}; closingNames.forEach(n=>{ mv.unitOfMuni[n]=resolveUnit(n); });\n  mv.unitMunis={}; closingNames.forEach(n=>{ const u=mv.unitOfMuni[n]; if(!u) return; (mv.unitMunis[u]=mv.unitMunis[u]||[]).push(n); });\n  const units=level==='setor'?closingNames:Object.keys(mv.unitMunis);\n\n  const drawnCsv=new Set(closingNames.map(n=>DOM.hasUf?n:mName(n)));\n  const footKeyOf=k=>DOM.hasUf?(footprint.has(k)?k:null):([...footprint].find(x=>mName(x)===k)||null);\n  mv.csvMunisTotal=domOwn.size;\n  mv.ufGuessed={...ufGuessed};\n  if(level==='setor'){\n    mv.missingMunis=[...domOwn].filter(k=>!drawnCsv.has(k)).map(k=>{\n      const fk=footKeyOf(k);\n      const hasShape=!!(adj.feat[fk||k]||world.muniGeo[fk||k]);\n      const alias=[...footprint].filter(x=>x!==k&&mName(x)===mName(k));\n      let reason;\n      if(!fk&&alias.length) reason=`UF divergente — as CBs entraram na chave ${alias.join(' / ')} e a base diz ${k}`;\n      else if(!fk) reason=hasShape?'sem CBs neste setor no snapshot — desenhado como vizinho':'sem CBs neste setor no snapshot';\n      else if(!hasShape) reason='sem shape nas UFs carregadas';\n      else reason='chave não casou com a base de dominantes';\n      return {muni:k,cbs:(fk?totBy[fk]:alias.reduce((a,x)=>a+(totBy[x]||0),0))||0,reason:reason};\n    });\n  } else {\n    mv.missingMunis=[];\n  }\n\n  // ---- HARD RULE: destino tem de compartilhar a fronteira certa acima do que está fechando ----\n  // setor → mesma GV; GV → mesmo mercado; mercado → nada acima, qualquer vizinho vale.\n  const ownParent=parentOf(level,code);\n  if(level!=='mercado' && !ownParent) throw new Error(`${LEVELS[level].label} ${code} não tem ${level==='setor'?'CD_GV':'MERCADO'} na base de municípios dominantes — sem essa fronteira não há como garantir que os destinos ficam do lado certo da hierarquia`);\n  const sameParent=destCode=>!ownParent||parentOf(level,destCode)===ownParent;\n  mv.gv=ownParent;\n  const alreadyClosed=destCode=>level==='setor'?world.closedSectors.has(destCode):(level==='gv'?(world.closedGvs||new Set()).has(destCode):(world.closedMercados||new Set()).has(destCode));\n\n  // ---- município compartilhado por vários setores (São Paulo etc.): o setor fechando é dono só de\n  // UMA fatia dele, não da cidade inteira — sem isto a camada `.muni` interativa pintava (e deixava\n  // clicar) o município INTEIRO com a cor do destino escolhido, mesmo a maior parte dele pertencendo\n  // a outros setores sem nenhuma relação com este fechamento. Movido pra DEPOIS de mv.gv/sameParent/\n  // alreadyClosed existirem (não era mais logo após mv.geoFeat) porque a fatia de cada sibling agora\n  // depende de quem é candidato válido — só dá pra saber isso com a GV do fechamento já resolvida.\n  // O pedaço que fecha mantém o tamanho de sempre (1 de N reais, N = todos os siblings de verdade);\n  // só o RESTANTE é refatiado, e agora só entre quem qualifica (mesma GV, ainda não fechado, setor\n  // real) — sem isso, cada sibling inválido continuava ocupando sozinho a largura original da sua\n  // fatia em cinza, em vez dessa largura ir pros siblings válidos de verdade (mesma regra \"só corta\n  // pelos válidos\" que a camada de vizinhos, em drawMap, já aplica). mv.sharedSiblingCodes guarda\n  // TODO sibling real visto aqui (válido ou não) — o bookkeeping de \"rejeitado por GV errada\" mais\n  // abaixo lê daqui, não mais de mv.sharedMuniSlices, porque esse array agora só carrega os já-\n  // válidos (ou uma fatia única com sector:null, quando nenhum sibling qualifica).\n  mv.sharedMuniSlices=[];\n  mv.sharedSiblingCodes=new Set();\n  // Real sector-sharing per município (independent of geometry/geoFeat) — feeds the ★ ÓTIMO \"split\"\n  // objective below: a município left split across multiple real sectors after this closure is a\n  // structural defect on its own, so the optimizer is biased toward routing a shared município back\n  // to whichever OTHER real sector already owns a piece of it, consolidating instead of scattering it\n  // to an unrelated destination.\n  mv.muniSiblings={};\n  if(level==='setor'){\n    const isValidSibling=s=>SESS.sectorsList.some(x=>x.setor===s)&&!alreadyClosed(s)&&sameParent(s);\n    closingNames.forEach(n=>{\n      const realOwners=DOM.setoresByMuni&&DOM.setoresByMuni[n];\n      // A município that's only here via `inherited` (this sector received it whole, via muniOverride,\n      // from an earlier etapa's closure) never had `code` as one of its REAL (CSV) owners — the raw\n      // base still lists whatever sector really owned it. Without this guard, that static list (minus\n      // `code`, which was never in it to begin with) reads as \"other real siblings\" and this município\n      // gets sliced/hatched as if genuinely shared, even though it belongs to `code` wholly and only.\n      if(!realOwners||!realOwners.has(code)) return;\n      const siblings=[...realOwners].filter(s=>s!==code);\n      if(siblings.length) mv.muniSiblings[n]=siblings;\n      if(!siblings.length||!mv.geoFeat[n]) return;\n      siblings.forEach(s=>mv.sharedSiblingCodes.add(s));\n      const orig=mv.geoFeat[n];\n      // Stashed before mv.geoFeat[n] below gets overwritten with just this sector's own slice —\n      // drawMap's multi-owner outline (multiBorderFeats) wants the TRUE, single, un-cut polygon for\n      // a município shared with the closing sector itself, same as it already gets for a shared\n      // município that's merely a bordering neighbour (mv.nbrFeat, never sliced to begin with).\n      mv.muniFullShape=mv.muniFullShape||{}; mv.muniFullShape[n]=orig;\n      const owners=[code,...siblings].sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      const slices=sliceFeatureEqual(orig,owners.length);\n      const mine=slices[owners.indexOf(code)];\n      if(mine&&!mine.__empty) mv.geoFeat[n]=mine;\n      const remParts=slices.filter((sl,i)=>owners[i]!==code&&sl&&!sl.__empty).flatMap(sl=>sl.geometry.coordinates);\n      if(!remParts.length) return;\n      const remainder={type:'Feature',properties:orig.properties,__uf:orig.__uf,geometry:{type:'MultiPolygon',coordinates:remParts}};\n      const valid=siblings.filter(isValidSibling).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      sliceFeatureEqual(remainder,valid.length||1).forEach((sl,i)=>{\n        if(sl.__empty) return;\n        mv.sharedMuniSlices.push({feature:sl,sector:valid.length?valid[i]:null,muni:n});\n      });\n    });\n  }\n\n  mv.allowed={}; mv.allowedRaw={};\n  units.forEach(u=>{ mv.allowed[u]={}; mv.allowedRaw[u]={}; });\n  mv.rejectedCodes=new Set(); mv.rejectedInfo={};\n\n  const extVotes={}; const nbrNames=new Set(); let domSecOf={};\n  if(level==='setor'){\n    // setor: destino descoberto por ADJACÊNCIA GEOGRÁFICA de município — uma query ao vivo por\n    // vizinho, mas o perímetro de UM setor é pequeno o bastante pra valer a pena.\n    closingNames.forEach(n=>{\n      if(!adj.feat[n]) return;\n      // Previously skipped here: a município with 0 live CBs under the closing sector's own code\n      // could never vote for a neighbouring destination, even when geographically real and\n      // genuinely adjacent (e.g. its CBs are currently tagged under a sibling sector, or simply a\n      // live/reference-data mismatch) — so it could never reach an otherwise-valid, visibly\n      // bordering destination, either as a mv.dest candidate or as its own contiguity seed. It is\n      // still drawn/assigned via the fallback either way, so letting it vote costs nothing and\n      // only ever adds a real, geographically-grounded option.\n      adj.nbrs(n).forEach(({o,w})=>{ if(!closingSet.has(o) && !droppedSet.has(o)){ nbrNames.add(o); (extVotes[n]=extVotes[n]||[]).push({o,w}); } });\n    });\n    // Setor dominante de cada vizinho vem da base ESTÁTICA (DOM.setorByMuni), não de uma consulta ao\n    // vivo por contagem de CBs: a consulta antiga só devolvia linha pro município com CB ativo NESTE\n    // snapshot — um vizinho geograficamente real mas sem CB agora simplesmente não aparecia, então\n    // nunca virava rejeitado nem contexto (sumia do mapa em vez de aparecer cinza). A base de\n    // dominantes cobre todo município, com ou sem CB no momento, então nenhum vizinho fica de fora.\n    nbrNames.forEach(n=>{ domSecOf[n]=DOM.setorByMuni[n]; });\n    nbrNames.forEach(n=>{ if(world.muniOverride && world.muniOverride[n]) domSecOf[n]=world.muniOverride[n]; });\n\n    mv.nbrCurrentSector={};\n    mv.gvRejected=[];\n    const gvHidden=new Set();\n    nbrNames.forEach(n=>{\n      const destCode=destUnitOfSector(level,domSecOf[n],world);\n      if(destCode && !sameParent(destCode)){\n        gvHidden.add(n);\n        mv.nbrCurrentSector[n]=destCode;\n        mv.rejectedCodes.add(destCode);\n        if(!mv.rejectedInfo[destCode]) mv.rejectedInfo[destCode]={code:destCode,parent:parentOf('setor',destCode)};\n        if(mv.gvRejected.indexOf(destCode)<0) mv.gvRejected.push(destCode);\n        return;\n      }\n      if(domSecOf[n]) mv.nbrCurrentSector[n]=destCode;\n    });\n    // Rejected neighbours KEEP their nbrNames/nbrCurrentSector entry — only their votes are stripped\n    // below, never their geometry — so the map can gray them in with a hover explaining the mismatch\n    // instead of just erasing them from the world.\n    Object.keys(extVotes).forEach(n=>{ extVotes[n]=extVotes[n].filter(v=>!gvHidden.has(v.o)); });\n\n    // Shared-município siblings get the SAME hard GV rule as any other destination candidate — a\n    // São Paulo sibling owned by a different-GV setor is rejected context, never a silently-available\n    // destination. Reads mv.sharedSiblingCodes (every real sibling seen, valid or not), not\n    // mv.sharedMuniSlices — that array now only carries already-valid siblings (or a null-sector flat\n    // piece), so a cross-GV sibling would never surface here reading straight from it.\n    (mv.sharedSiblingCodes||new Set()).forEach(s=>{\n      if(!sameParent(s)){\n        mv.rejectedCodes.add(s);\n        if(!mv.rejectedInfo[s]) mv.rejectedInfo[s]={code:s,parent:parentOf('setor',s)};\n        if(mv.gvRejected.indexOf(s)<0) mv.gvRejected.push(s);\n      }\n    });\n\n    mv.bridgeMunis=closingNames.slice();\n    mv.ignoredBridges=[];\n\n    // Setor referenciado pela base de dominantes mas que não existe MAIS no CRM (código renumerado/\n    // retirado, erro de digitação na base) não pode virar destino de verdade — sem isto ele entra\n    // como destino \"válido\" com span sempre 0 (a query real nunca devolve linha nenhuma pra ele),\n    // indistinguível na tela de um setor genuinamente vazio. SESS.sectorsList é a MESMA checagem de\n    // existência que addMovement já usa pra recusar fechar um código que não existe no snapshot.\n    const validSectorCodes=new Set((SESS.sectorsList||[]).map(s=>s.setor));\n    const wAny={};\n    closingNames.forEach(n=>{\n      const u=n;\n      if(!mv.allowed[u]) return;\n      (extVotes[n]||[]).forEach(({o,w})=>{\n        const destCode=mv.nbrCurrentSector[o];\n        if(!destCode || destCode===code || alreadyClosed(destCode) || !validSectorCodes.has(destCode)) return;\n        mv.allowed[u][destCode]=(mv.allowed[u][destCode]||0)+w;\n        mv.allowedRaw[u][destCode]=(mv.allowedRaw[u][destCode]||0)+w;\n        wAny[destCode]=(wAny[destCode]||0)+w;\n      });\n    });\n    // A shared-território SIBLING (this closure literally owns a slice of the same município as\n    // destCode — mv.sharedSiblingCodes, populated above) can never earn a vote in the scan just\n    // above: it explicitly excludes the closing sector's OWN municípios as voters\n    // (`!closingSet.has(o)`, back where extVotes is built), and a shared município IS one of them —\n    // correctly, it must not vote for itself. But that left the closing sector's single most\n    // locally-relevant destination (literally the same município, split two ways) structurally\n    // unable to ever reach mv.dest, while muniTipHTML's isValidSiblingTarget tells the map hover\n    // it's \"válido\" on organisational grounds alone (same GV, exists, not closed — no geography\n    // required). Given a guaranteed slot here instead of merely entering a vote race it cannot win —\n    // ranked first, so it only bumps a weaker normal candidate out of the top-6 when genuinely out\n    // of room, never the other way around.\n    const validSiblings=[...(mv.sharedSiblingCodes||new Set())].filter(s=>validSectorCodes.has(s)&&!alreadyClosed(s)&&sameParent(s));\n    const votedRanked=Object.keys(wAny).sort((a,b)=>wAny[b]-wAny[a]);\n    mv.dest=[...new Set([...validSiblings,...votedRanked])].slice(0,6);\n    if(!mv.dest.length){\n      mv.dest=[...new Set(Object.values(mv.nbrCurrentSector))].filter(destCode=>destCode&&destCode!==code&&!alreadyClosed(destCode)&&sameParent(destCode)&&validSectorCodes.has(destCode)).slice(0,6);\n      units.forEach(u=>{ if(mv.allowed[u]&&!Object.keys(mv.allowed[u]).length){ mv.dest.forEach(destCode=>mv.allowed[u][destCode]=1); } });\n    }\n  } else {\n    // GV/mercado: destino é SEMPRE todo irmão válido na hierarquia — NUNCA descoberto por\n    // adjacência geográfica de município. Uma GV/mercado pode ter território espalhado por\n    // dezenas de UFs sem qualquer conexão física entre as partes (a hierarquia aqui é\n    // organizacional, não geográfica) — \"vizinho por fronteira de município\" não é só caro (a\n    // query ao vivo vira um IN-list de centenas de municípios e estoura timeout: foi exatamente\n    // isso que quebrou o fechamento de um mercado espalhado) — é a pergunta errada. A fronteira que\n    // já importa (mesma GV / mesmo mercado) é imposta pela regra de parentesco logo acima; dentro\n    // dela QUALQUER irmão é candidato, e o otimizador decide pelo equilíbrio de span e continuidade\n    // de líder, nunca por proximidade física — geografia não pesa aqui.\n    mv.nbrCurrentSector={};\n    mv.gvRejected=[];\n    mv.bridgeMunis=closingNames.slice();\n    mv.ignoredBridges=[];\n    const siblings=level==='gv'\n      ? [...(DOM.gvsByMercado[ownParent]||[])]\n      : Object.keys(DOM.sectorsByMercado||{});\n    mv.dest=siblings.filter(destCode=>destCode!==code && !alreadyClosed(destCode) && sameParent(destCode));\n    units.forEach(u=>{ mv.dest.forEach(destCode=>{ mv.allowed[u][destCode]=1; mv.allowedRaw[u][destCode]=1; }); });\n\n    // mv.realTouch: sinal de fronteira GEOGRÁFICA de verdade, separado de mv.allowed (que acima\n    // fica 1 pra TODO irmão válido, de propósito — nesse nível não existe voto por adjacência, GV/\n    // mercado é organizacional). contigBadFor usa “semente = mv.allowed[m][s] verdadeiro” pra achar\n    // por onde cada destino genuinamente encosta — com mv.allowed sempre 1 aqui, TODO setor “encosta”\n    // em TODO destino trivialmente, e a checagem de conectividade vira no-op: nada em fechamento de\n    // GV/mercado jamais acusava quebra (bug relatado: setor do GV fechando ficou uma ilha isolada\n    // longe do resto do destino escolhido, sem nenhum aviso). mv.realTouch[u][destCode] só fica\n    // verdadeiro quando algum município de u encosta de verdade num município que JÁ pertence hoje\n    // ao destino — exatamente o que mv.allowed já significa no nível setor — e contigBadFor, logo\n    // abaixo, passa a preferir esse sinal quando ele existe.\n    mv.realTouch={}; units.forEach(u=>{ mv.realTouch[u]={}; });\n    closingNames.forEach(n=>{\n      if(!adj.feat[n]) return;\n      const u=mv.unitOfMuni[n]; if(!u||!mv.realTouch[u]) return;\n      adj.nbrs(n).forEach(({o})=>{\n        if(closingSet.has(o)) return;\n        const sOf=DOM.setorByMuni[o]; if(!sOf) return;\n        const destCode=destUnitOfSector(level,sOf,world);\n        if(!destCode||!mv.dest.includes(destCode)) return;\n        mv.realTouch[u][destCode]=(mv.realTouch[u][destCode]||0)+1;\n      });\n    });\n\n    // Geographically-touching GVs that fail the hierarchy rule (different mercado) — grayed on the\n    // map with a hover explaining why, using ONLY the static dominantes CSV (DOM.setorByMuni /\n    // DOM.gvBySetor / DOM.mercadoByGv, already in memory) — no live query, so the timeout that broke\n    // geographic discovery for a spread-out mercado closing (comment above) never re-enters here.\n    // nGv MUST resolve through destUnitOfSector (world-aware), not a raw DOM.gvBySetor lookup: a\n    // previous GV closure THIS SESSION may already have cascaded sOf to a new GV via secOverride,\n    // and DOM.gvBySetor only ever knows the static/original one. Without this, a neighbour município\n    // whose setor was reassigned earlier in the session rendered under the GV that closure CLOSED\n    // (still \"rejected\"/wrong colour) instead of its real current GV — and since nbrNames already\n    // held it from here, the correct territoryOf() pass for the real GV never got a chance to fix it.\n    if(level==='gv'){\n      closingNames.forEach(n=>{\n        if(!adj.feat[n]) return;\n        adj.nbrs(n).forEach(({o})=>{\n          if(closingSet.has(o) || nbrNames.has(o)) return;\n          const sOf=DOM.setorByMuni[o]; if(!sOf) return;\n          const nGv=destUnitOfSector(level,sOf,world); if(!nGv || nGv===code || sameParent(nGv)) return;\n          nbrNames.add(o);\n          mv.nbrCurrentSector[o]=nGv;\n          mv.rejectedCodes.add(nGv);\n          if(!mv.rejectedInfo[nGv]) mv.rejectedInfo[nGv]={code:nGv,parent:DOM.mercadoByGv[nGv]||''};\n          if(mv.gvRejected.indexOf(nGv)<0) mv.gvRejected.push(nGv);\n        });\n      });\n    }\n  }\n  mv.dest=mv.dest.filter(sameParent);\n\n  // ---- O QUE É DESENHADO: território COMPLETO de cada destino (não só a borda), sempre lido da\n  // mesma base de dominantes já usada pro lado que fecha.\n  const territoryOf=destCode=>{\n    const secs=memberSectorsOf(level,destCode,world);\n    const out=new Set();\n    secs.forEach(s=>(domBase[String(s)]||new Set()).forEach(n=>out.add(n)));\n    return out;\n  };\n  mv.muniOwners={};\n  mv.dest.forEach(destCode=>{\n    territoryOf(destCode).forEach(n=>{\n      // A município that's part of the CLOSING sector itself is already drawn (and clicked) via\n      // mv.geoFeat/unitAssign, further down — it isn't up-for-grabs context. Without this guard, a\n      // multi-sector city (São Paulo etc.) that also happens to be one of the closing sector's OWN\n      // municípios got its single \"current assignment\" fill painted right over the sliced context\n      // layer underneath — same IBGE polygon, and the closing layer draws on top.\n      if(closingSet.has(n)) return;\n      (mv.muniOwners[n]=mv.muniOwners[n]||[]).push(destCode);\n      if(nbrNames.has(n)) return;\n      nbrNames.add(n);\n      mv.nbrCurrentSector[n]=(level==='setor'&&world.muniOverride&&world.muniOverride[n])?world.muniOverride[n]:destCode;\n    });\n  });\n  // Draw every município we actually resolved a current sector for — not just the top-voted\n  // mv.dest candidates + rejectedCodes. A real same-GV neighbour sector that lost the vote race\n  // (more than 6 distinct neighbours, or simply outvoted) still gets classified further down as\n  // 'plain' context; filtering it out HERE instead left it entirely undrawn — a blank gap on the\n  // map exactly where a real, valid neighbouring sector is, instead of the grey context it should be.\n  // A neighbour whose current sector never resolved (no CRM row for it in this snapshot, a name\n  // mismatch, whatever) used to be dropped here entirely — invisible, no way to tell it apart from\n  // \"there's genuinely nothing there\". It still keeps its real shape; the render step below tags it\n  // 'unresolved' and shows the actual município name on hover instead, which is what you want when\n  // debugging why something didn't resolve.\n  // Um setor/GV rejeitado só entrava no mapa pelos municípios que sobreviveram à varredura de\n  // adjacência (os poucos que realmente encostam no que está fechando) — o resto do território dele,\n  // segundo a base de dominantes, nunca aparecia. Mesma expansão que mv.dest já recebe logo acima:\n  // território COMPLETO do setor/GV rejeitado, não só a fatia que faz fronteira.\n  mv.rejectedCodes.forEach(destCode=>{\n    territoryOf(destCode).forEach(n=>{\n      if(closingSet.has(n)) return;\n      if(nbrNames.has(n)) return;\n      nbrNames.add(n);\n      mv.nbrCurrentSector[n]=(level==='setor'&&world.muniOverride&&world.muniOverride[n])?world.muniOverride[n]:destCode;\n    });\n  });\n  // Setor fechando: um vizinho da MESMA GV que perdeu a votação (mais de 6 candidatos, ou simplesmente\n  // menos votado) nunca vira mv.dest nem mv.rejectedCodes — fica só como o único município que de fato\n  // encosta no fechamento, sem a expansão de território, e o resto dele sobra como buraco no mapa\n  // (nem colorido, nem cinza, nem contexto — simplesmente ausente). mv.allowedRaw carrega TODO\n  // candidato válido já descoberto, não só o top-6, então dá pra expandir os que sobraram também.\n  const plainCandidates=new Set();\n  units.forEach(u=>Object.keys(mv.allowedRaw[u]||{}).forEach(dc=>plainCandidates.add(dc)));\n  // Persisted so drawMap can test \"is this real sector a valid destination candidate anywhere in\n  // this closure\" without the top-6 mv.dest cap — adjacent + same GV/mercado + not already closed is\n  // enough to count as valid for the multi-sector-município striping/hover, even if it lost the vote.\n  mv.validCandidates=plainCandidates;\n  plainCandidates.forEach(destCode=>{\n    if(mv.dest.includes(destCode)) return;\n    territoryOf(destCode).forEach(n=>{\n      if(closingSet.has(n)) return;\n      if(nbrNames.has(n)) return;\n      nbrNames.add(n);\n      mv.nbrCurrentSector[n]=(level==='setor'&&world.muniOverride&&world.muniOverride[n])?world.muniOverride[n]:destCode;\n    });\n  });\n  mv.nbrFeat=[...nbrNames].map(n=>adj.feat[n]&&adj.feat[n].f).filter(Boolean);\n  units.forEach(u=>{ Object.keys(mv.allowed[u]||{}).forEach(destCode=>{ if(!mv.dest.includes(destCode)) delete mv.allowed[u][destCode]; }); });\n  mv.color=assignDestColors(mv.dest);\n\n  const accC={}; Object.entries(mv.nbrCurrentSector).forEach(([nb,destCode])=>{ if(!mv.dest.includes(destCode))return; const f=(adj.feat[nb]&&adj.feat[nb].f)||world.muniGeo[nb]; if(!f)return; const c=centroidLL(f); accC[destCode]=accC[destCode]||[0,0,0]; accC[destCode][0]+=c[0]; accC[destCode][1]+=c[1]; accC[destCode][2]++; });\n  mv.secCentroid={}; mv.dest.forEach(destCode=>{ if(accC[destCode]) mv.secCentroid[destCode]=[accC[destCode][0]/accC[destCode][2],accC[destCode][1]/accC[destCode][2]]; });\n\n  if(level==='setor'){\n    mv.muniCentroid={}; closingNames.forEach(n=>{ if(mv.geoFeat[n]) mv.muniCentroid[n]=centroidLL(mv.geoFeat[n]); });\n    mv.closeAdj={}; closingNames.forEach(n=>{ mv.closeAdj[n]=adj.feat[n]?adj.nbrs(n).filter(({o})=>closingSet.has(o)).map(({o})=>o):[]; });\n  } else {\n    mv.muniCentroid={};\n    Object.entries(mv.unitMunis).forEach(([u,ms])=>{\n      const cs=ms.map(n=>mv.geoFeat[n]&&centroidLL(mv.geoFeat[n])).filter(Boolean);\n      if(cs.length) mv.muniCentroid[u]=[cs.reduce((a,c)=>a+c[0],0)/cs.length,cs.reduce((a,c)=>a+c[1],0)/cs.length];\n    });\n    mv.closeAdj={};\n    const unitSet=new Set(units);\n    Object.entries(mv.unitMunis).forEach(([u,ms])=>{\n      const touch=new Set();\n      ms.forEach(n=>{ if(!adj.feat[n]) return; adj.nbrs(n).forEach(({o})=>{ const ou=mv.unitOfMuni[o]; if(ou&&ou!==u&&unitSet.has(ou)) touch.add(ou); }); });\n      mv.closeAdj[u]=[...touch];\n    });\n  }\n  const comp={}; let ci=0; units.forEach(n=>{ if(comp[n]!=null)return; const st=[n]; comp[n]=ci; while(st.length){ const x=st.pop(); (mv.closeAdj[x]||[]).forEach(o=>{ if(comp[o]==null){ comp[o]=ci; st.push(o); } }); } ci++; });\n  const compAllowed={}; units.forEach(n=>{ const cid=comp[n]; (compAllowed[cid]=compAllowed[cid]||new Set()); Object.keys(mv.allowed[n]||{}).forEach(s=>compAllowed[cid].add(s)); });\n  mv.reachable={}; units.forEach(n=>{ mv.reachable[n]=[...(compAllowed[comp[n]]||[])].filter(s=>mv.dest.includes(s)); });\n\n  if(mv.dest.length){\n    const destSectorMap={}; mv.dest.forEach(destCode=>{ destSectorMap[destCode]=memberSectorsOf(level,destCode,world); });\n    const allDestSecs=[...new Set(Object.values(destSectorMap).flat())];\n    const rList=allDestSecs.join(',');\n    const bSQL=`SELECT t.CD_SETOR setor, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs,\n       SUM(e.active_bc_ind) ativas,\n       ROUND(SUM(e.revenue_sd_ss_amt),0) receita, ROUND(SUM(e.revenue_nat_ss_amt),0) rec_nat,\n       ROUND(SUM(e.revenue_avon_ss_amt),0) rec_avon, ROUND(SUM(e.points_ss_amt),0) pontos, ROUND(SUM(e.orders_cnt),0) pedidos\n      FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n      WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR IN (${rList}) AND t.DT_HISTORICO=${SNAP} GROUP BY t.CD_SETOR`;\n    const blSQL=`SELECT t.CD_SETOR setor, t.DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs\n      FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n      WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR IN (${rList}) AND t.DS_NVL_PLAN_CRESC IS NOT NULL AND t.DT_HISTORICO=${SNAP} GROUP BY t.CD_SETOR, t.DS_NVL_PLAN_CRESC`;\n    // CBs count comes ONLY from ELO (commercial_consultant_index), never from a T-driven join: T's\n    // daily CRM extract is missing entire cohorts of registered CBs for dozens of setores (e.g. setor\n    // 3746 — 1806 registered in ELO's closed cycle vs 7 visible through the old T-driven count), so\n    // any count gated on a matching T row silently drops most of a setor's real CBs. ELO only groups\n    // by sector_code (no município column), so this fixes the setor-level base count; the per-município\n    // matrix below still has to come from T — there is no other source with that granularity.\n    // ativas is sourced the same way — matches T-driven ativas closely on healthy setores (1636 vs\n    // 1650 for setor 3627) and, like cbs, must not depend on a T row existing.\n    // 18/09: receita/pontos/pedidos SAIRAM do T e passaram pro ELO, junto com mSQL, lvlSQL e\n    // computeTransfer - ver o comentario grande em cima do mSQL. A afirmacao que estava aqui (\"os\n    // campos do ELO nao batem, ~2-2,4x, outra definicao de metrica\") estava errada: a razao instavel\n    // vinha de comparar o acumulador do ciclo ABERTO do CRM com um ciclo FECHADO inteiro do ELO.\n    // Medido no mesmo ciclo, batem. Manter no T era o que fazia a matriz \"antes -> depois\" discordar\n    // do grafico de tendencia (que ja le do ELO) na mesma tela, sem nada sinalizando.\n    const cSQL=`SELECT sector_code setor, COUNT(DISTINCT person_code) cbs, SUM(active_bc_ind) ativas\n      FROM ${ELO} WHERE country_code=1 AND operational_cycle=${SESS.eloMaxCycle} AND business_status_code=3 AND sector_code IN (${rList}) GROUP BY sector_code`;\n    const [bR,blR,cR]=await Promise.all([q(bSQL),q(blSQL),q(cSQL)]);\n    const secMetrics={}; rows(bR).forEach(r=>{ secMetrics[String(r.setor)]=r; if(!SESS.sectorBaseCache[String(r.setor)]) SESS.sectorBaseCache[String(r.setor)]=r; });\n    rows(cR).forEach(r=>{\n      const s=String(r.setor);\n      if(!secMetrics[s]) secMetrics[s]={setor:s,cbs:0,ativas:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0};\n      secMetrics[s].cbs=+r.cbs||0;\n      secMetrics[s].ativas=+r.ativas||0;\n    });\n    const secLevel={}; rows(blR).forEach(r=>{ const s=String(r.setor); (secLevel[s]=secLevel[s]||{})[r.nivel]=+r.cbs; });\n    mv.baseMetrics={}; mv.baseLevel={};\n    mv.dest.forEach(destCode=>{\n      const secs=destSectorMap[destCode];\n      const acc=blankM(); acc.setor=destCode;\n      secs.forEach(s=>{ const r=secMetrics[String(s)]; if(!r) return; Object.keys(blankM()).forEach(k=>{ acc[k]=(+acc[k]||0)+(+r[k]||0); }); });\n      mv.baseMetrics[destCode]=acc;\n      const lv={}; secs.forEach(s=>{ const l=secLevel[String(s)]; if(!l) return; Object.entries(l).forEach(([nv,c])=>{ lv[nv]=(lv[nv]||0)+c; }); });\n      mv.baseLevel[destCode]=lv;\n    });\n  } else { mv.baseMetrics={}; mv.baseLevel={}; }\n\n  mv.dest.forEach(destCode=>{\n    if(!mv.baseMetrics[destCode]) mv.baseMetrics[destCode]={setor:destCode,cbs:0,ativas:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0};\n    const inflowSrc=level==='setor'?world.sectorInflow:(level==='gv'?world.gvInflow:world.mercadoInflow);\n    const infl=inflowSrc&&inflowSrc[destCode];\n    if(infl) Object.keys(blankM()).forEach(k=>{ mv.baseMetrics[destCode][k]=(+mv.baseMetrics[destCode][k]||0)+(infl[k]||0); });\n    if(level==='setor'){\n      const inflLv=world.sectorInflowLevel&&world.sectorInflowLevel[destCode];\n      if(inflLv){ mv.baseLevel[destCode]=mv.baseLevel[destCode]||{}; Object.entries(inflLv).forEach(([nv,c])=>{ mv.baseLevel[destCode][nv]=(mv.baseLevel[destCode][nv]||0)+c; }); }\n      // Manual município transfers (see foldWorldState) subtract from whichever sector LOST the\n      // município — the only outflow this engine has, since a closed sector never needs it (its\n      // whole base is moot once dissolved).\n      const outfl=world.sectorOutflow&&world.sectorOutflow[destCode];\n      if(outfl) Object.keys(blankM()).forEach(k=>{ mv.baseMetrics[destCode][k]=Math.max(0,(+mv.baseMetrics[destCode][k]||0)-(outfl[k]||0)); });\n    }\n  });\n\n  aggregateMovement(mv,world);\n  mv.assign=optimizeFor(mv);\n  return mv;\n}\n\n// Reconstructs a timeline entry from a FROZEN saved etapa (saveSession's trimmed schema) instead of\n// recomputing it against Databricks — the whole point of the new session format. Every business-data\n// field is copied verbatim (this IS the point: no recompute, no drift from what was on screen when\n// saved); only map geometry (geoFeat/nbrFeat/muniCentroid/secCentroid/sharedMuniSlices) is rebuilt,\n// always live, from ufList/closingKeys/nbrKeys against whatever shapes are current — per the rule that\n// geometry and Overpass/OSM data are never frozen.\nasync function hydrateFromFrozen(i,saved){\n  const isSetor=(saved.level||'setor')==='setor';\n  const mv={\n    sector:String(saved.sector), level:saved.level||'setor', policy:saved.policy||'fora', view:'tobe', selMuni:null, optBadge:saved.optBadge||'',\n    zeroCbsWarning:saved.zeroCbsWarning||null, matrixLeaderUnknown:!!saved.matrixLeaderUnknown,\n    assign:{...(saved.assign||{})}, dest:[...(saved.dest||[])],\n    munis:saved.munis||{}, leaders:saved.leaders||[],\n    baseMetrics:saved.baseMetrics||{}, baseLevel:saved.baseLevel||{},\n    trend:saved.trend||[], pureOwnMetrics:saved.pureOwnMetrics||blankM(),\n    muniOwners:saved.muniOwners||{}, rejectedCodes:new Set(saved.rejectedCodes||[]), rejectedInfo:saved.rejectedInfo||{},\n    nbrCurrentSector:saved.nbrCurrentSector||{}, validCandidates:new Set(saved.validCandidates||[]),\n    gvRejected:saved.gvRejected||[], unitOfMuni:saved.unitOfMuni||{}, unitMunis:saved.unitMunis||{},\n    closeAdj:saved.closeAdj||{}, csvMunisTotal:saved.csvMunisTotal||0, domCsvMunis:saved.domCsvMunis||0,\n    excludedMunis:saved.excludedMunis||[], excludedCbs:saved.excludedCbs||0,\n    noShapeMunis:saved.noShapeMunis||[], noShapeCbs:saved.noShapeCbs||0,\n    cbsByMuni:saved.cbsByMuni||{}, gv:saved.gv||'', domUF:saved.domUF||'', optTier:saved.optTier==null?0:saved.optTier,\n    matrix:isSetor?(saved.matrix||[]).map(r=>({...r})):[], levelByMuni:isSetor?(saved.levelByMuni||{}):{},\n    allowed:isSetor?(saved.allowed||{}):{}, muniSiblings:isSetor?(saved.muniSiblings||{}):{},\n    sharedSiblingCodes:new Set(isSetor?(saved.sharedSiblingCodes||[]):[]),\n    missingMunis:isSetor?(saved.missingMunis||[]):[],\n    orphanByGroup:isSetor?(saved.orphanByGroup||{}):{}, orphanMetricsByGroup:isSetor?(saved.orphanMetricsByGroup||{}):{},\n    orphanLevelByGroup:isSetor?(saved.orphanLevelByGroup||{}):{},\n    foraDoSetorByGrupo:isSetor?(saved.foraDoSetorByGrupo||{}):{},\n  };\n  mv.reachable=saved.reachable||{};\n  if(saved.kind==='transfer'){\n    mv.kind='transfer'; mv.xferFrom=String(saved.xferFrom); mv.xferTo=String(saved.xferTo);\n    mv.xferMuni=saved.xferMuni; mv.xferMetrics=saved.xferMetrics||blankM(); mv.xferRows=saved.xferRows||[];\n  }\n  mv.color=assignDestColors(mv.dest);\n\n  const ufList=(saved.ufList&&saved.ufList.length)?saved.ufList:[mv.domUF].filter(Boolean);\n  const geos=await Promise.all(ufList.map(u=>geoUF(u).catch(()=>null)));\n  const adj=buildAdj(geos.filter(Boolean).flatMap(g=>g.features));\n  mv.geoFeat={}; (saved.closingKeys||[]).forEach(n=>{ mv.geoFeat[n]=adj.feat[n]&&adj.feat[n].f; });\n  mv.nbrFeat=(saved.nbrKeys||[]).map(n=>adj.feat[n]&&adj.feat[n].f).filter(Boolean);\n\n  const accC={}; Object.entries(mv.nbrCurrentSector).forEach(([nb,destCode])=>{ if(!mv.dest.includes(destCode)) return; const f=adj.feat[nb]&&adj.feat[nb].f; if(!f) return; const c=centroidLL(f); accC[destCode]=accC[destCode]||[0,0,0]; accC[destCode][0]+=c[0]; accC[destCode][1]+=c[1]; accC[destCode][2]++; });\n  mv.secCentroid={}; mv.dest.forEach(destCode=>{ if(accC[destCode]) mv.secCentroid[destCode]=[accC[destCode][0]/accC[destCode][2],accC[destCode][1]/accC[destCode][2]]; });\n\n  if(isSetor){\n    mv.muniCentroid={}; Object.keys(mv.geoFeat).forEach(n=>{ if(mv.geoFeat[n]) mv.muniCentroid[n]=centroidLL(mv.geoFeat[n]); });\n  } else {\n    mv.muniCentroid={};\n    Object.entries(mv.unitMunis).forEach(([u,ms])=>{\n      const cs=ms.map(n=>mv.geoFeat[n]&&centroidLL(mv.geoFeat[n])).filter(Boolean);\n      if(cs.length) mv.muniCentroid[u]=[cs.reduce((a,c)=>a+c[0],0)/cs.length,cs.reduce((a,c)=>a+c[1],0)/cs.length];\n    });\n  }\n\n  mv.sharedMuniSlices=[];\n  if(isSetor){\n    Object.entries(mv.muniSiblings).forEach(([n,siblings])=>{\n      const orig=mv.geoFeat[n]; if(!orig||!siblings.length) return;\n      // Stashed before mv.geoFeat[n] below gets overwritten with just this sector's own slice —\n      // drawMap's multi-owner outline (multiBorderFeats) wants the TRUE, single, un-cut polygon,\n      // exactly like computeMovement already stashes on a fresh compute. Missing here meant a\n      // município shared with the closing sector always got its hatch back (sharedMuniSlices,\n      // rebuilt above) on a LOADED session, but never its border — multiBorderFeats had nothing\n      // to draw from since mv.muniFullShape simply didn't exist on a hydrated mv.\n      mv.muniFullShape=mv.muniFullShape||{}; mv.muniFullShape[n]=orig;\n      const owners=[mv.sector,...siblings].sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      const slices=sliceFeatureEqual(orig,owners.length);\n      const mine=slices[owners.indexOf(mv.sector)];\n      if(mine&&!mine.__empty) mv.geoFeat[n]=mine;\n      const remParts=slices.filter((sl,idx)=>owners[idx]!==mv.sector&&sl&&!sl.__empty).flatMap(sl=>sl.geometry.coordinates);\n      if(!remParts.length) return;\n      const remainder={type:'Feature',properties:orig.properties,__uf:orig.__uf,geometry:{type:'MultiPolygon',coordinates:remParts}};\n      const valid=siblings.filter(s=>mv.sharedSiblingCodes.has(s)).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      sliceFeatureEqual(remainder,valid.length||1).forEach((sl,idx)=>{ if(sl.__empty) return; mv.sharedMuniSlices.push({feature:sl,sector:valid.length?valid[idx]:null,muni:n}); });\n    });\n  }\n\n  SESS.timeline[i]=mv;\n}\n\n// ---------- manual município transfer (rule-violation \"exception\" fix) ----------\n// A lightweight sibling of computeMovement: moves ONE município's CBs from an open sector straight\n// to another open sector, with neither dissolving — the one case ★ ÓTIMO's own redistribution can\n// never reach, because it only ever touches municípios that belong to whichever sector is CLOSING.\n// Metrics are snapshotted here and stored verbatim on the transfer record (recipe-replay style, same\n// as everything else a session persists) — no geometry captured: the map keeps drawing this\n// município from whatever context layer already loaded it, which degrades gracefully rather than\n// needing its own fetch.\nasync function computeTransfer(from,to,muniKey){\n  await ensurePinned();\n  const SNAP=SESS.snapLiteral;\n  // Mesma troca do mSQL (18/09): receita/pontos/pedidos saem do ELO no ciclo fechado, nao mais dos\n  // acumuladores *_CICLO do CRM. Esta query alimenta xferMetrics e corrige baseMetrics de origem e\n  // destino na etapa de transferencia - se ficasse no CRM, a transferencia somaria e subtrairia\n  // numeros de um periodo diferente do resto da matriz.\n  const sql=`SELECT t.DS_MUNICIPIO municipio, t.CD_SETOR setor, t.CD_GRUPO grupo, MAX(t.DS_LEADER_NAME) lider,\n     COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs, ROUND(SUM(e.revenue_sd_ss_amt),0) receita,\n     ROUND(SUM(e.revenue_nat_ss_amt),0) rec_nat, ROUND(SUM(e.revenue_avon_ss_amt),0) rec_avon,\n     ROUND(SUM(e.points_ss_amt),0) pontos, ROUND(SUM(e.orders_cnt),0) pedidos,\n     SUM(e.active_bc_ind) ativas\n    FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n    WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR=${from} AND t.DT_HISTORICO=${SNAP}\n    GROUP BY t.DS_MUNICIPIO, t.CD_SETOR, t.CD_GRUPO`;\n  const recs=rows(await q(sql));\n  const uf=mUf(muniKey);\n  const metrics=blankM();\n  const byGroup={};\n  recs.forEach(r=>{\n    const ru=uf||DOM.ufBySetorMuni[String(from)+'|'+muniN(r.municipio)]||'';\n    if(mkey(r.municipio,ru)!==muniKey) return;\n    metrics.cbs+=+r.cbs; metrics.receita+=+r.receita||0; metrics.rec_nat+=+r.rec_nat||0; metrics.rec_avon+=+r.rec_avon||0; metrics.pontos+=+r.pontos||0; metrics.pedidos+=+r.pedidos||0; metrics.ativas+=+r.ativas||0;\n    const g=String(r.grupo);\n    const row=byGroup[g]=byGroup[g]||{grupo:g,lider:r.lider,...blankM()};\n    row.cbs+=+r.cbs; row.receita+=+r.receita||0; row.rec_nat+=+r.rec_nat||0; row.rec_avon+=+r.rec_avon||0; row.pontos+=+r.pontos||0; row.pedidos+=+r.pedidos||0; row.ativas+=+r.ativas||0;\n  });\n  return {metrics, xferRows:Object.values(byGroup)};\n}\n// A transfer-movement stage never changes mv.munis/mv.assign structurally — the reassigned município\n// belongs to one of the PARENT stage's own destination sectors (mv.dest), never to mv.munis (only the\n// dissolving sector's own children live there — see suggestMuniMoves, which sources donor municípios\n// from DOM.bySetor[donor], donor∈mv.dest). What genuinely changes is baseMetrics[from]/baseMetrics[to]\n// — the same two fields computeMovement's own tail already patches from world.sectorInflow/\n// sectorOutflow for a LATER stage. computeDerived(mv) picks the new spans up automatically at render\n// time from baseMetrics, exactly like every other render already re-derives everything from mv fresh.\nasync function computeTransferMovement(i){\n  const t=SESS.timeline[i];\n  const prev=SESS.timeline[i-1]; // i is never 0 — applyTransfer always inserts after an existing stage\n  const mv={...prev};\n  mv.kind='transfer'; mv.xferFrom=t.xferFrom; mv.xferTo=t.xferTo; mv.xferMuni=t.xferMuni;\n  // Own copy — NOT because this function writes to it, but because editSelected's map-click/segmented-\n  // button mutators write into mv.assign IN PLACE (m2.assign[m]=s), and sharing the object with prev\n  // would let an edit made while viewing THIS stage silently corrupt the parent stage's own assignment.\n  mv.assign={...prev.assign};\n  mv.baseMetrics={...prev.baseMetrics};\n  const {metrics,xferRows}=await computeTransfer(t.xferFrom,t.xferTo,t.xferMuni); // live re-query — from's numbers\n  mv.xferMetrics=metrics; mv.xferRows=xferRows;                         // may have shifted upstream\n  const patchBase=(code,sign)=>{\n    const cur=mv.baseMetrics[code]||{setor:code,cbs:0,ativas:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0};\n    const next={...cur};\n    Object.keys(blankM()).forEach(k=>{ next[k]=Math.max(0,(+next[k]||0)+sign*(metrics[k]||0)); });\n    mv.baseMetrics[code]=next;\n  };\n  patchBase(t.xferTo,+1);\n  patchBase(t.xferFrom,-1);\n  if(mv.nbrCurrentSector && (t.xferMuni in mv.nbrCurrentSector)){\n    mv.nbrCurrentSector={...mv.nbrCurrentSector, [t.xferMuni]:t.xferTo};\n  }\n  SESS.timeline[i]=mv;\n}\n// Pins a new transfer-movement stage right after the etapa it was applied against — see\n// computeTransferMovement, which does the actual clone+patch work through the normal computeAt\n// dispatch/dirty-cascade, the same path any other edit already uses.\nasync function applyTransfer(from,to,muniKey){\n  if(guardLocked()) return;\n  const parent=SESS.timeline[SESS.selected]; if(!parent) return;\n  const insertAt=SESS.selected+1;\n  SESS.timeline.splice(insertAt,0,{sector:parent.sector, level:parent.level||'setor', kind:'transfer', xferFrom:String(from), xferTo:String(to), xferMuni:muniKey});\n  SESS.dirtyFrom = SESS.dirtyFrom==null ? insertAt : Math.min(SESS.dirtyFrom, insertAt);\n  SESS.selected=insertAt;\n}\n// Retargets an ALREADY-inserted transfer stage in place (never inserts a new one — that's\n// applyTransfer's job). Dirty-marked at the stage's OWN index, not +1 like editSelected: a\n// transfer stage's baseMetrics/xferMetrics/xferRows/nbrCurrentSector are precomputed snapshots\n// (unlike mv.assign, which every render re-derives live), so the stage itself must be recomputed —\n// computeAt's existing kind==='transfer' branch already does that correctly via computeTransferMovement.\nasync function setTransferTarget(code){\n  if(guardLocked()) return;\n  const mv=SESS.timeline[SESS.selected];\n  if(!mv||mv.kind!=='transfer'||!mv.dest||!mv.dest.includes(code)||mv.xferTo===code) return;\n  mv.xferTo=code;\n  SESS.dirtyFrom = SESS.dirtyFrom==null ? SESS.selected : Math.min(SESS.dirtyFrom, SESS.selected);\n  await flushDirty();\n  renderAll();\n}\n// Plain wraparound through mv.dest, deliberately including xferFrom as a natural \"revert\" stop —\n// mirrors how the normal município click-cycle (drawMap) never excludes the current assignment\n// either. No contiguity guard: nextContigSafeDest reasons about the CLOSING sector's own dissolving\n// territory staying connected, which has no meaning for a transfer between two already-open sectors.\nasync function cycleTransferTarget(){\n  const mv=SESS.timeline[SESS.selected];\n  if(!mv||mv.kind!=='transfer'||!mv.dest||!mv.dest.length) return;\n  await setTransferTarget(mv.dest[(mv.dest.indexOf(mv.xferTo)+1)%mv.dest.length]);\n}\n// Geo adjacency for an OPEN sector's own territory — cached by UF-set key, since the same pair of\n// sectors tends to get re-checked across several fix cards in one session.\nconst OPEN_SECTOR_ADJ_CACHE={};\nasync function openSectorAdj(ufSet){\n  const key=[...ufSet].sort().join(',');\n  if(OPEN_SECTOR_ADJ_CACHE[key]) return OPEN_SECTOR_ADJ_CACHE[key];\n  const geos=await Promise.all([...ufSet].map(u=>geoUF(u).catch(()=>null)));\n  const adj=buildAdj(geos.filter(Boolean).flatMap(g=>g.features));\n  OPEN_SECTOR_ADJ_CACHE[key]=adj;\n  return adj;\n}\n// Candidate município moves for the span-rule fix card — scoped to the ONE violation the clicked\n// button represents (`code`), tested against mv's own REAL, current span map (sp), never a\n// hypothetical \"what if a different sector closes\" preview. Only ever offered as a MINIMAL plan —\n// at most 2 municípios — because reallocating an established sector's territory is a manual\n// calibration a person has to eyeball on the map; a plan that shuffles four+ municípios around to\n// plug one gap reads as arbitrary busywork, not as something anyone would actually click. If\n// nothing that small covers the whole gap while staying geometrically safe, no plan is offered at\n// all — the \"fechar\" card right next to it (closeWork) is the fallback for that case.\n// Two geometric guards, not one: a candidate must not be the only thing holding the DONOR's\n// remaining territory together (as before), AND it must not leave the RECEIVER's resulting\n// territory in disconnected pieces either — a município that borders neither the donor's rest nor\n// the receiver's existing footprint would dangle as its own island wherever it lands.\nasync function suggestMuniMoves(mv,sp,lim,code,isOver){\n  if((mv.level||'setor')!=='setor') return null;\n  const others=mv.dest.filter(d=>d!==code);\n  if(!others.length) return null;\n  const gapNeeded=isOver?(lim.max?Math.max(0,sp[code]-lim.max):0):(lim.min?Math.max(0,lim.min-sp[code]):0);\n  if(!gapNeeded) return null;\n  const donorPool=isOver?[code]:others;\n  const receiverPool=isOver?others:[code];\n\n  // Single connected-component test, shared by both guards below: a donor's REMAINING territory\n  // after losing its combo members, and a receiver's RESULTING territory after gaining them. One\n  // node is trivially connected — nothing left to disconnect it from.\n  const connected=(nodes,adj)=>{\n    const arr=[...nodes]; if(arr.length<=1) return true;\n    const set=new Set(arr);\n    const seen=new Set([arr[0]]),st=[arr[0]];\n    while(st.length){ const cur=st.pop(); adj.nbrs(cur).forEach(({o})=>{ if(set.has(o)&&!seen.has(o)){ seen.add(o); st.push(o); } }); }\n    return seen.size===arr.length;\n  };\n\n  const ownByDonor={},adjByDonor={};\n  let pool=[];\n  for(const dn of donorPool){\n    const ownMunis=DOM.bySetor[dn]||new Set();\n    if(ownMunis.size<2) continue;   // nothing left to give away without emptying the sector\n    const sql=`SELECT t.DS_MUNICIPIO municipio, t.DS_ESTADO uf, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1 WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR=${dn} AND t.DT_HISTORICO=${SESS.snapLiteral} GROUP BY t.DS_MUNICIPIO, t.DS_ESTADO ORDER BY cbs ASC`;\n    let recs; try{ recs=rows(await q(sql)); }catch(e){ continue; }\n    const cands=[];\n    recs.forEach(r=>{\n      const ru=r.uf||DOM.ufBySetorMuni[String(dn)+'|'+muniN(r.municipio)]||'';\n      const k=mkey(r.municipio,ru);\n      if(ownMunis.has(k)&&+r.cbs>0) cands.push({muni:k,cbs:+r.cbs,donor:dn});\n    });\n    if(!cands.length) continue;\n    const ufSet=new Set();\n    ownMunis.forEach(m=>{ const u=mUf(m); if(u) ufSet.add(u); });\n    mv.dest.forEach(d=>(DOM.bySetor[d]||new Set()).forEach(m=>{ const u=mUf(m); if(u) ufSet.add(u); }));\n    ufSet.forEach(u=>(UF_BORDERS[u]||[]).forEach(b=>ufSet.add(b)));\n    let adj; try{ adj=await openSectorAdj(ufSet); }catch(e){ continue; }\n    ownByDonor[dn]=ownMunis; adjByDonor[dn]=adj;\n    pool=pool.concat(cands.filter(c=>adj.feat[c.muni]));\n  }\n  if(!pool.length) return null;\n\n  // Receivers' CURRENT (pre-plan) territory — the baseline every geometry check below grows on top\n  // of, same for a 1- or a 2-município combo.\n  const recvBase={}; receiverPool.forEach(rc=>{ recvBase[rc]=DOM.bySetor[rc]||new Set(); });\n\n  // Does this combo (1 or 2 candidates, each already carrying its chosen receiver in `.rc`) hold up\n  // geometrically? Donor side: group by donor, remove the combo's members, whatever is left must\n  // stay one region. Receiver side: group by receiver, add the combo's members to its existing\n  // territory, old + new must stay one region too.\n  // Receiver-side guard is deliberately NOT a full re-verification that the WHOLE existing\n  // territory (often dozens of towns) is itself one connected blob in our loaded shapes — that's a\n  // pre-existing fact this fix doesn't own, and a real sector routinely has a stray missing shape or\n  // CSV alias gap somewhere far from anything being added here (see the UF/alias handling elsewhere\n  // in this file). It only asks the LOCAL question that actually matters: does the incoming piece\n  // touch the receiver's existing footprint — directly, or via its sibling in this same plan?\n  const attaches=(newMunis,existing,adj)=>{\n    const touches=m=>[...adj.nbrs(m)].some(({o})=>existing.has(o));\n    const rest=new Set(newMunis);\n    const linked=new Set([...rest].filter(touches));\n    if(!linked.size) return false;\n    let grew=true;\n    while(grew){\n      grew=false;\n      for(const m of rest){\n        if(linked.has(m)) continue;\n        if([...adj.nbrs(m)].some(({o})=>linked.has(o))){ linked.add(m); grew=true; }\n      }\n    }\n    return linked.size===rest.size;\n  };\n  const geomOk=combo=>{\n    const byDonor={}; combo.forEach(c=>(byDonor[c.donor]=byDonor[c.donor]||[]).push(c.muni));\n    for(const dn in byDonor){\n      const remaining=[...ownByDonor[dn]].filter(m=>!byDonor[dn].includes(m));\n      if(!remaining.length||!connected(remaining,adjByDonor[dn])) return false;\n    }\n    const byRecv={}; combo.forEach(c=>(byRecv[c.rc]=byRecv[c.rc]||[]).push(c.muni));\n    for(const rc in byRecv){\n      const adj=adjByDonor[combo.find(c=>c.rc===rc).donor];\n      if(!attaches(byRecv[rc],recvBase[rc],adj)) return false;\n    }\n    return true;\n  };\n  const roomOk=combo=>{\n    const used={}; combo.forEach(c=>{ used[c.rc]=(used[c.rc]||0)+c.cbs; });\n    return Object.entries(used).every(([rc,cbs])=>cbs<=(lim.max?lim.max:Infinity)-sp[rc]);\n  };\n  const planFrom=combo=>{\n    const spNow={...sp};\n    combo.forEach(c=>{ spNow[c.donor]-=c.cbs; spNow[c.rc]+=c.cbs; });\n    return {moves:combo.map(c=>({from:c.donor,to:c.rc,muni:c.muni,cbs:c.cbs})), resolved:true, spNow};\n  };\n\n  // 1 município: every (candidate × receiver) pairing that alone covers the whole gap. Smallest\n  // cbs wins — the least overshoot, the least disruptive single move.\n  let best1=null;\n  for(const c of pool){\n    if(c.cbs<gapNeeded) continue;\n    for(const rc of receiverPool){\n      if(rc===c.donor) continue;\n      const combo=[{...c,rc}];\n      if(!roomOk(combo)) continue;\n      if(!geomOk(combo)){ continue; }\n      if(!best1||c.cbs<best1[0].cbs) best1=combo;\n    }\n  }\n  if(best1) return planFrom(best1);\n\n  // 2 municípios: cheapest combined-cbs pair (any donor/receiver split) that together cover the\n  // gap — the plan someone would actually pick over closing a whole sector.\n  let best2=null,best2Cost=Infinity;\n  for(let i=0;i<pool.length;i++){\n    for(let j=i+1;j<pool.length;j++){\n      const c1=pool[i],c2=pool[j];\n      if(c1.muni===c2.muni) continue;\n      const total=c1.cbs+c2.cbs;\n      if(total<gapNeeded||total>=best2Cost) continue;\n      for(const rc1 of receiverPool){\n        if(rc1===c1.donor) continue;\n        for(const rc2 of receiverPool){\n          if(rc2===c2.donor) continue;\n          const combo=[{...c1,rc:rc1},{...c2,rc:rc2}];\n          if(!roomOk(combo)) continue;\n          if(!geomOk(combo)){ continue; }\n          best2=combo; best2Cost=total;\n        }\n      }\n    }\n  }\n  if(best2) return planFrom(best2);\n\n  return {moves:null, resolved:false};\n}\n\n// ---------- cascade engine ----------\nasync function computeAt(i){\n  await ensurePinned();\n  const cur=SESS.timeline[i];\n  if(cur && cur.kind==='transfer'){ await computeTransferMovement(i); return; }\n  const world=foldWorldState(SESS.timeline,i);\n  const sec=SESS.timeline[i].sector;\n  const level=SESS.timeline[i].level||'setor';\n  const mv=await computeMovement(sec,level,world);\n  SESS.timeline[i]=mv;\n}\nasync function recomputeFrom(i){\n  for(let k=i;k<SESS.timeline.length;k++){\n    loader(true,`recalculando etapa ${k+1} de ${SESS.timeline.length}…`);\n    try{ await computeAt(k); }\n    catch(e){\n      // A stage that failed to recompute still holds its PREVIOUS result, so the timeline would\n      // silently mix pre- and post-change etapas. Flag this stage and everything after it as stale\n      // (dashed chip + ⟳) so the state is visible and flushDirty retries from here.\n      SESS.dirtyFrom = SESS.dirtyFrom==null ? k : Math.min(SESS.dirtyFrom,k);\n      throw e;\n    }\n  }\n}\nasync function flushDirty(){\n  if(SESS.dirtyFrom==null) return;\n  const from=SESS.dirtyFrom; SESS.dirtyFrom=null;\n  loader(true,'recalculando etapas seguintes…');\n  try{ await recomputeFrom(from); }\n  catch(e){ $('#ds').textContent='erro ao recalcular cadeia: '+e.message; $('#ds').classList.add('err'); }\n  finally{ loader(false); }\n}\nasync function addMovement(level,sec){\n  if(guardLocked()) return;\n  level=level||'setor';\n  sec=(sec==null?'':String(sec)).trim();\n  if(!sec) return;\n  if(!movementAvailable(level)){ $('#ds').textContent=`Fechar ${LEVELS[level].label} ainda não está disponível.`; $('#ds').classList.add('err'); return; }\n  if(!hierarchyAllows(level)){ const minLabel=LEVELS[Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===sessionMinRank())].label; $('#ds').textContent=`esta sessão já fechou um nível mais alto — só \"${minLabel}\" (ou acima) fica disponível.`; $('#ds').classList.add('err'); return; }\n  const closedNow=new Set(SESS.timeline.filter(mv=>mv.kind!=='transfer' && (mv.level||'setor')===level).map(mv=>mv.sector));\n  if(closedNow.has(sec)){ $('#ds').textContent=LEVELS[level].label+' '+sec+' já fechado(a) nesta timeline.'; $('#ds').classList.add('err'); return; }\n  // Setor existence used to be pre-checked against SESS.sectorsList (built from T LEFT JOIN ELO,\n  // grouped by t.CD_SETOR at the frozen DT_HISTORICO) before ever reaching computeMovement. T's daily\n  // CRM extract can simply be missing rows for a real, active setor on a given frozen date even when\n  // the CSV base (DOM.bySetor — the SAME source the \"SETOR A FECHAR\" picker itself reads) and ELO both\n  // have it, which rejected a genuine setor (e.g. 3322) with a wrong \"não existe no snapshot congelado\"\n  // error. GV/mercado never had this extra gate and rely solely on computeMovement's own domOwn check\n  // below (CSV-sourced, the authoritative \"which setor exists\" answer) — setor now does the same.\n  await flushDirty();\n  loader(true,`fechando ${lc(LEVELS[level].label)} `+sec+' — puxando dado do snapshot congelado…');\n  $('#liveState').classList.remove('err');\n  try{\n    SESS.timeline.push({sector:String(sec),level:level});\n    await computeAt(SESS.timeline.length-1);\n    PULL_SET=new WeakSet();\n    SESS.selected=SESS.timeline.length-1;\n    $('#ds').classList.remove('err'); $('#ds').textContent='';\n  }catch(e){\n    SESS.timeline.pop();\n    $('#ds').textContent=`erro ao fechar ${lc(LEVELS[level].label)} `+sec+': '+e.message; $('#ds').classList.add('err');\n  } finally { loader(false); renderAll(); }\n}\nfunction removeFrom(i){\n  if(guardLocked()) return;\n  SESS.timeline.length=i;\n  PULL_SET=new WeakSet();\n  SESS.selected=Math.min(SESS.selected,Math.max(0,SESS.timeline.length-1));\n  if(SESS.dirtyFrom!=null && SESS.dirtyFrom>=SESS.timeline.length) SESS.dirtyFrom=null;\n  renderAll();\n}\nasync function editSelected(mutator){\n  if(guardLocked()) return;\n  const mv=SESS.timeline[SESS.selected]; if(!mv) return;\n  mutator(mv);\n  FIX_CACHE.get(mv)?.clear();\n  if(SESS.selected<SESS.timeline.length-1){\n    SESS.dirtyFrom = SESS.dirtyFrom==null ? SESS.selected+1 : Math.min(SESS.dirtyFrom, SESS.selected+1);\n  }\n  renderAll();\n}\n\n// ---------- sectors list / picker ----------\n// CICLO (19/09): esta query reimplementava INLINE o penultimo ciclo global do ELO — a mesma regra\n// que queryEloCycle() abandonou, porque um ciclo pode ter algumas linhas processadas sem estar\n// fechado. Dava 202616, que tem 72 pessoas no país inteiro: o JOIN não casava e o picker mostrava\n// 0 CBs em TODO setor (medido: 617 setores zerados; com a regra certa, 1.592.074 CBs e só 2 zerados).\n// Corrigir queryEloCycle() não consertava esta — eram duas cópias da mesma regra velha.\n// Por que a expressão vai EMBUTIDA em vez de usar SESS.eloMaxCycle: loadSectors roda no init, dentro\n// do Promise.all, ANTES de o operador escolher o ciclo no card de confirmação — interpolar\n// SESS.eloMaxCycle aqui produziria `operational_cycle=null`, que nunca casa em SQL, e devolveria o\n// mesmo zero de antes por outro caminho. Embutida, ela concorda com queryEloCycle() por construção:\n// é a MESMA regra (maior ciclo do ELO anterior ao CD_CICLO_ATUAL da maioria das CBs no snapshot).\n// O snapshot também passou a ser o mesmo COALESCE do resolveSnapshotPin, no lugar do MAX ao vivo.\nasync function loadSectors(){\n  const SNAP_SUBQ=`COALESCE((SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO<(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)),(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1))`;\n  const CICLO_FECHADO_SUBQ=`(SELECT MAX(c.operational_cycle) FROM ${ELO} c WHERE c.country_code=1 AND c.operational_cycle<(SELECT m.CD_CICLO_ATUAL FROM ${T} m WHERE m.CD_PAIS=1 AND m.CD_SETOR>0 AND m.CD_GRUPO>0 AND m.DT_HISTORICO=${SNAP_SUBQ} GROUP BY m.CD_CICLO_ATUAL ORDER BY COUNT(*) DESC LIMIT 1))`;\n  const sql=`SELECT t.CD_SETOR setor, MAX(t.DS_ESTADO) uf, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs, COUNT(DISTINCT t.DS_MUNICIPIO) munis\n    FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${CICLO_FECHADO_SUBQ} AND e.country_code=1\n    WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR>0 AND t.DT_HISTORICO=${SNAP_SUBQ} GROUP BY t.CD_SETOR ORDER BY t.CD_SETOR`;\n  SESS.sectorsList=rows(await q(sql)).map(r=>({setor:String(r.setor),uf:r.uf,cbs:+r.cbs,munis:+r.munis}));\n}\nfunction renderSectorPicker(){\n  const ready=SESS.stage==='main';\n  const ob=$('#openMove'); if(ob) ob.disabled=!ready;\n}\n\n// ---------- movement modal (setor / GV / mercado) ----------\n// Hierarquia mercado > GV > setor: a sessão trava no nível da primeira movimentação. Misturar\n// níveis quebraria a leitura de \"antes/depois\" da cadeia — fechar uma GV já redistribui setores\n// inteiros pra outras GVs; fechar um setor isolado depois não bate mais com o mundo que sobrou.\n// de/a/no: contrações de \"de\"/\"a\"/\"em\" com o artigo de cada nível — GV é feminina (a Gerência de\n// Vendas), setor e mercado são masculinos. Errar o gênero aqui é o tipo de coisa que grita \"isto foi\n// feito pra setor e só remendado pros outros níveis\", exatamente o que este objeto existe pra evitar.\nconst LEVELS={\n  setor:{label:'Setor',codeLbl:'Setor a fechar',placeholder:'código do setor',childLabel:'Município',destLabel:'Setor',de:'do',a:'ao',no:'no',parentLabel:'GV',parentPhrase:'da mesma GV',parentWord:'gerência'},\n  gv:{label:'GV',codeLbl:'GV a fechar',placeholder:'código da GV',childLabel:'Setor',destLabel:'GV',de:'da',a:'à',no:'na',parentLabel:'Mercado',parentPhrase:'do mesmo mercado',parentWord:'mercado'},\n  mercado:{label:'Mercado',codeLbl:'Mercado a fechar',placeholder:'nome do mercado',childLabel:'GV',destLabel:'Mercado',de:'do',a:'ao',no:'no',parentLabel:null,parentPhrase:null,parentWord:null},\n};\n// GV é sigla — .toLowerCase() a transformaria em \"gv\", errado em qualquer posição da frase. Palavras\n// normais (Setor/Mercado/Município) minúsculam à vontade no meio de uma frase; GV nunca.\nconst LOWER={Setor:'setor',Mercado:'mercado',Município:'município',GV:'GV'};\nconst lc=w=>LOWER[w]||w.toLowerCase();\n// Hierarquia é uma catraca, não um cadeado: mercado(0) > GV(1) > setor(2). Fechar um nível mais\n// alto (número menor) trava PRA SEMPRE nesta sessão qualquer nível mais baixo (número maior) —\n// mas o próprio nível, ou um ainda mais alto, continua liberado. É assim que \"posso fechar setores\n// até fechar uma GV; posso fechar GVs até fechar um mercado\" funciona: nunca destrava de volta.\nconst LEVEL_RANK={mercado:0,gv:1,setor:2};\nfunction sessionMinRank(){\n  let r=LEVEL_RANK.setor;\n  SESS.timeline.forEach(mv=>{ const rk=LEVEL_RANK[mv.level||'setor']; if(rk<r) r=rk; });\n  return r;\n}\nfunction hierarchyAllows(lvl){ return LEVEL_RANK[lvl]<=sessionMinRank(); }\nfunction movementAvailable(lvl){ return lvl==='setor'||lvl==='gv'||lvl==='mercado'; }\n// ---------- hierarquia: setor ⊂ GV ⊂ mercado — tudo derivado da base de dominantes (DOM) ----------\n// memberSectors: o escopo de CD_SETOR que a query no CRM precisa cobrir pra fechar este código, em\n// qualquer nível — um setor é ele mesmo; uma GV é todo setor que a base lhe atribui; um mercado é\n// todo setor de toda GV que a base atribui a ele.\n// membros ATUAIS de um GV/mercado, um nível abaixo (setores de uma GV; GVs de um mercado) — nunca\n// só a foto estática do CSV: uma etapa anterior NESTA MESMA sessão pode já ter movido um filho pra\n// dentro ou pra fora deste código, e um fechamento seguinte tem de enxergar o mundo como ele está\n// agora, não como o CSV descreveu antes de qualquer movimentação.\nfunction currentMembersOf(level,code,world){\n  code=String(code);\n  // \"gv\": setores da GV (DOM.sectorsByGv). \"mercado\": GVs do mercado (DOM.gvsByMercado) — NUNCA\n  // sectorsByMercado, que pula a camada de GV inteira e faz memberSectorsOf tratar códigos de\n  // setor como se fossem códigos de GV (não batem em DOM.sectorsByGv, e o fechamento morre com\n  // \"não existe na base\" mesmo quando o mercado está lá).\n  const staticMembers=level==='gv'?(DOM.sectorsByGv[code]||new Set()):(DOM.gvsByMercado[code]||new Set());\n  const overrideMap=level==='gv'?(world&&world.secOverride):(world&&world.gvOverride);\n  const out=new Set(staticMembers);\n  if(overrideMap){\n    Object.entries(overrideMap).forEach(([child,dest])=>{\n      if(dest===code) out.add(child);\n      else if(staticMembers.has(child)) out.delete(child);\n    });\n  }\n  return [...out];\n}\n// setores que a query no CRM precisa cobrir pra fechar este código, em qualquer nível — setor é\n// ele mesmo; GV é seus setores ATUAIS; mercado é todo setor de toda GV ATUALMENTE sua (setor não\n// troca de GV sozinho numa sessão de mercado — só GV inteira troca de mercado).\nfunction memberSectorsOf(level,code,world){\n  code=String(code);\n  if(level==='setor') return [code];\n  if(level==='gv') return currentMembersOf('gv',code,world);\n  const gvs=currentMembersOf('mercado',code,world);\n  const out=new Set();\n  gvs.forEach(gv=>(DOM.sectorsByGv[gv]||new Set()).forEach(s=>out.add(s)));\n  return [...out];\n}\n// fronteira que um destino tem de respeitar: GV do setor que fecha, mercado da GV que fecha —\n// mercado não tem nada acima, então não existe regra pra ele. Estática de propósito: um setor não\n// troca de GV nem uma GV troca de mercado só por existirem etapas anteriores nesta sessão — quem\n// muda de dono é o NÍVEL ABAIXO do que está fechando, nunca o próprio código sendo avaliado aqui.\nfunction parentOf(level,code){\n  code=String(code);\n  if(level==='setor') return DOM.gvBySetor[code]||'';\n  if(level==='gv') return DOM.mercadoByGv[code]||'';\n  return '';\n}\n// dado o setor que uma município vizinha atualmente pertence (fato ao vivo do CRM, nunca muda de\n// verdade), a que CANDIDATO A DESTINO isso corresponde neste nível — considerando que uma etapa\n// anterior desta sessão pode já ter movido esse setor (GV fechando) ou essa GV (mercado fechando)\n// pra outro dono, o que a foto estática do CSV sozinha não enxergaria.\nfunction destUnitOfSector(level,setorCode,world){\n  // setorCode is undefined when the live neighbour query found no row for that município (e.g. zero\n  // CBs in this snapshot) — String(undefined) would silently turn that into the literal text\n  // \"undefined\" and the caller would treat it as a real (bogus) sector: shown as a fake rejected\n  // sector on the map, tooltip and all. Bail out to '' — no resolvable sector — instead.\n  if(setorCode==null||setorCode==='') return '';\n  setorCode=String(setorCode);\n  if(level==='setor') return setorCode;\n  if(level==='gv'){ const ov=world&&world.secOverride&&world.secOverride[setorCode]; return ov||DOM.gvBySetor[setorCode]||''; }\n  const gv=DOM.gvBySetor[setorCode]||'';\n  const ov=world&&world.gvOverride&&world.gvOverride[gv];\n  return ov||DOM.mercadoBySetor[setorCode]||'';\n}\nfunction currentMoveLevel(){ const r=root.querySelector('input[name=\"moveLevel\"]:checked'); return (r&&r.value)||'setor'; }\nfunction openMove(){\n  const minRank=sessionMinRank();\n  const lastLevel=SESS.timeline.length?(SESS.timeline[SESS.timeline.length-1].level||'setor'):'setor';\n  root.querySelectorAll('input[name=\"moveLevel\"]').forEach(r=>{\n    const lvl=r.value;\n    r.disabled=!movementAvailable(lvl)||!hierarchyAllows(lvl);\n    r.checked=hierarchyAllows(lastLevel)?lvl===lastLevel:lvl===Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===minRank);\n  });\n  const note=$('#moveLockNote');\n  const minLabel=LEVELS[Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===minRank)].label;\n  if(note) note.textContent=minRank<LEVEL_RANK.setor\n    ? `Esta sessão já fechou um nível mais alto na hierarquia — só \"${minLabel}\" (ou acima) continua disponível daqui pra frente.`\n    : 'Escolha o nível a fechar. Você pode fechar setores à vontade até fechar uma GV; aí só GV (ou mercado) daqui pra frente. Fechar um mercado trava a sessão em mercado.';\n  $('#moveCode').value='';\n  const err=$('#moveErr'); if(err){ err.textContent=''; err.classList.add('hidden'); }\n  const st=$('#moveStatus'); if(st) st.textContent='';\n  $('#moveBg').classList.remove('hidden');\n  renderMoveForm();\n  const c=$('#moveCancel'); if(c) c.focus();\n}\nfunction closeMove(){ const b=$('#moveBg'); if(b) b.classList.add('hidden');\n  // #moveCode's dropdown panel lives in #macPortal, appended straight to document.body — a\n  // separate DOM subtree from this modal, so hiding #moveBg alone never touches it. It normally\n  // closes on a document-level click (ensureMacGlobalClose), but submitMove() via Enter never\n  // fires one, so the panel was left floating on screen after the modal itself had already closed.\n  closeAllMacSelects();\n}\nfunction renderMoveForm(){\n  root.querySelectorAll('#moveBg .opt-row').forEach(r=>r.classList.toggle('on',r.dataset.lvl===currentMoveLevel()));\n  const lvl=LEVELS[currentMoveLevel()];\n  $('#moveCodeLbl').textContent=lvl.codeLbl;\n  $('#moveCode').placeholder=lvl.placeholder;\n  loadMoveOptions();\n}\nasync function loadMoveOptions(){\n  const lvl=currentMoveLevel();\n  const dl=$('#moveCodeList'); if(!dl) return;\n  const closedSet=new Set(SESS.timeline.filter(mv=>mv.kind!=='transfer' && (mv.level||'setor')===lvl).map(mv=>mv.sector));\n  dl.innerHTML='';\n  // Marks the window during which the datalist is empty because it's still loading, not because\n  // there are genuinely no options — initMacCombo's panel reads this to show \"carregando…\" instead\n  // of misreporting \"nenhum resultado\" while ensureDominantes() (the CSV fetch, can be slow cold) is\n  // still in flight.\n  dl.dataset.loading='1';\n  const inp=$('#moveCode'); if(inp&&inp._macRefresh) inp._macRefresh();\n  try{\n    try{ await ensureDominantes(); }catch(e){ return; }\n    if(lvl==='setor'){\n      // Setor picker reads STRICTLY from the dominantes CSV (DOM.bySetor) now, never from a live CRM\n      // query — código/UF/município count are all derivable from the same base already loaded for the\n      // hierarchy rules, so this list shouldn't cost a Databricks round trip on top of it. CB count is\n      // dropped along with it: it only ever came from that same CRM query.\n      const codes=Object.keys(DOM.bySetor||{}).filter(s=>!closedSet.has(s)).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      dl.innerHTML=codes.map(s=>{\n        const ms=DOM.bySetor[s]||new Set();\n        const ufCount={};\n        ms.forEach(m=>{ const u=mUf(m); if(u) ufCount[u]=(ufCount[u]||0)+1; });\n        const uf=Object.keys(ufCount).sort((a,b)=>ufCount[b]-ufCount[a])[0];\n        return `<option value=\"${s}\">${s}${uf?' · '+uf:''} · ${ms.size} munic.</option>`;\n      }).join('');\n    } else if(lvl==='gv'){\n      const gvs=Object.keys(DOM.sectorsByGv||{}).filter(g=>!closedSet.has(g)).sort();\n      dl.innerHTML=gvs.map(g=>`<option value=\"${g}\">${g}${DOM.gvLabel[g]?' · '+esc(DOM.gvLabel[g]):''} · ${DOM.sectorsByGv[g].size} setor(es)</option>`).join('');\n    } else if(lvl==='mercado'){\n      const mks=Object.keys(DOM.sectorsByMercado||{}).filter(m=>!closedSet.has(m)).sort();\n      dl.innerHTML=mks.map(m=>`<option value=\"${m}\">${m} · ${(DOM.gvsByMercado[m]||new Set()).size} GV(s) · ${DOM.sectorsByMercado[m].size} setor(es)</option>`).join('');\n    }\n  } finally {\n    dl.dataset.loading='0';\n    if(inp&&inp._macRefresh) inp._macRefresh();\n  }\n}\nfunction moveRandPick(){\n  const opts=[...($('#moveCodeList').options||[])].map(o=>o.value);\n  if(!opts.length) return;\n  $('#moveCode').value=opts[Math.floor(Math.random()*opts.length)];\n}\nasync function submitMove(){\n  const lvl=currentMoveLevel();\n  const code=($('#moveCode').value||'').trim();\n  const err=$('#moveErr');\n  const fail=msg=>{ if(err){ err.textContent=msg; err.classList.remove('hidden'); } };\n  if(err){ err.textContent=''; err.classList.add('hidden'); }\n  if(!movementAvailable(lvl)) return fail(`Fechar ${LEVELS[lvl].label} ainda não está disponível.`);\n  if(!hierarchyAllows(lvl)){ const minLabel=LEVELS[Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===sessionMinRank())].label; return fail(`Esta sessão já fechou um nível mais alto — só \"${minLabel}\" (ou acima) fica disponível.`); }\n  if(!code) return fail('Informe um código.');\n  closeMove();\n  await addMovement(lvl,code);\n}\nfunction renderStage(){\n  const isMain=SESS.stage==='main';\n  const cc=$('#confirmCard'), mw=$('#mainWrap');\n  const hdr=$('#topHeader'), rl=$('#timelineRail');\n  if(hdr) hdr.classList.toggle('hidden', !isMain);\n  if(rl) rl.classList.toggle('hidden', !isMain);\n  if(cc) cc.classList.toggle('hidden', isMain);\n  if(mw) mw.classList.toggle('hidden', !isMain || !SESS.timeline.length);\n  const cb=$('#confirmBody');\n  if(cb && !isMain) cb.innerHTML = SESS.snapshotRaw\n    ? (SESS.snapshotPinnedDate\n        ? `📌 Snapshot histórico congelado · fixado em <b>${fmtSyncedAt(SESS.snapshotRaw)}</b> · <b>${SESS.sectorsList.length}</b> setor(es) disponível(eis) pra fechar. Nenhuma consulta de detalhe roda até você confirmar e escolher o primeiro setor.`\n        : `Snapshot congelado · atualizado em <b>${fmtSyncedAt(SESS.snapshotSyncedAt)}</b> · <b>${SESS.sectorsList.length}</b> setor(es) disponível(eis) pra fechar. Nenhuma consulta de detalhe roda até você confirmar e escolher o primeiro setor.`)\n    : 'escolha um ciclo operacional abaixo para congelar o snapshot desta sessão.';\n  const ecs=$('#eloCycleStatus');\n  if(ecs) ecs.textContent = SESS.eloMaxCycle ? `ciclo escolhido: ${SESS.eloMaxCycle}` : 'nenhum ciclo escolhido ainda — obrigatório antes de confirmar.';\n  const csBtn=$('#confirmSnap');\n  if(csBtn) csBtn.disabled = !SESS.eloMaxCycle;\n}\n\n// ---------- session persistence ----------\n// SESSIONS_DS (`geo-timeline-sessions`) and SHARED_DS (`timeline-shared-sessions`) are now TWO\n// SEPARATE tables — they used to be the same DataSet wearing two row shapes, but that let a\n// write-once shared snapshot show up next to editable working sessions in the same store:\n//   · working session  — carries `movements`; lives in SESSIONS_DS; saveSession updates it in place\n//   · shared snapshot  — carries `sharedAt` + `stages`; lives in SHARED_DS; doShare CREATES it and\n//     nothing ever updates it\n// listSessions still excludes any row carrying `sharedAt` as a defensive guard (belt-and-braces\n// now that the tables are split, load-bearing back when they were one).\n// Both lookups (`d2.data('sessionsDataset')||d2.data(SESSIONS_DS)`) stay because a policy-gated\n// DataSet's Process resolves late; same race the reader component polls for. SESSIONS_DS lives at\n// module scope so no call site can hit it in the TDZ.\nasync function listSessions(){\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS)); if(!ds) return [];\n  await ds.refresh();\n  const recs=ds.records()||[];\n  return [...recs].filter(r=>!r.sharedAt).sort((a,b)=>new Date(b.updatedAt||0)-new Date(a.updatedAt||0));\n}\nasync function refreshSessionPicker(){\n  const list=await listSessions().catch(()=>[]);\n  SESS.sessionsList=list;\n  const opts=list.map(s=>`<option value=\"${s._id}\">${esc(s.label||'sem nome')} · ${fmt((s.movements||[]).length)} etapa(s)</option>`).join('')+`<option value=\"\">+ Nova sessão</option>`;\n  const sel=$('#sessionPick'); if(sel){ sel.innerHTML=opts; sel.value=SESS.sessionId||''; initMacSelect(sel); }\n  const csel=$('#confirmSessionPick'); if(csel){ csel.innerHTML=opts; csel.value=SESS.sessionId||''; initMacSelect(csel); }\n}\nasync function saveSession(){\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS));\n  if(!ds){ $('#sessStatus').textContent='dataset de sessões indisponível'; return; }\n  if(!SESS.timeline.length){ $('#sessStatus').textContent='nada pra salvar ainda'; return; }\n  await flushDirty();\n  const label=SESS.sessionLabel||('Sessão '+new Date().toLocaleString('pt-BR'));\n  const nowIso=new Date().toISOString();\n  const payload={\n    label,\n    settings:{...DEFAULT_SETTINGS,...(SESS.settings||{})},\n    snapshotRaw:SESS.snapshotRaw, snapshotSyncedAt:SESS.snapshotSyncedAt, snapshotPinnedDate:SESS.snapshotPinnedDate, snapLiteral:SESS.snapLiteral, eloMaxCycle:SESS.eloMaxCycle,\n    // A session now freezes the RENDERED business data per etapa (trimmed to what the render layer\n    // actually reads), not just the recipe — loading no longer re-queries Databricks. Map geometry\n    // (geoFeat/nbrFeat/muniCentroid/secCentroid/sharedMuniSlices) and anything from Overpass/OSM are\n    // deliberately NEVER frozen — hydrateFromFrozen() rebuilds those live from ufList/closingKeys/\n    // nbrKeys against whatever shapes are current. GV/mercado etapas skip the setor-only fields\n    // (matrix/levelByMuni/allowed/muniSiblings/sharedSiblingCodes/orphan*) since renderMatrix* never\n    // shows them at those levels — this is also what keeps a large GV/mercado closure's payload\n    // bounded, after an earlier full-state freeze blew the write-size limit (tens of MB for one).\n    movements:SESS.timeline.map(mv=>{\n      const isSetor=(mv.level||'setor')==='setor';\n      return {\n        sector:mv.sector, level:mv.level||'setor', assign:mv.assign, policy:mv.policy, optBadge:mv.optBadge,\n        zeroCbsWarning:mv.zeroCbsWarning||null, matrixLeaderUnknown:!!mv.matrixLeaderUnknown,\n        dest:mv.dest, munis:mv.munis, leaders:mv.leaders, baseMetrics:mv.baseMetrics, baseLevel:mv.baseLevel,\n        trend:mv.trend, pureOwnMetrics:mv.pureOwnMetrics, muniOwners:mv.muniOwners,\n        rejectedCodes:[...(mv.rejectedCodes||[])], rejectedInfo:mv.rejectedInfo, nbrCurrentSector:mv.nbrCurrentSector,\n        validCandidates:[...(mv.validCandidates||[])], gvRejected:mv.gvRejected, unitOfMuni:mv.unitOfMuni, unitMunis:mv.unitMunis,\n        closeAdj:mv.closeAdj, csvMunisTotal:mv.csvMunisTotal, domCsvMunis:mv.domCsvMunis,\n        excludedMunis:mv.excludedMunis, excludedCbs:mv.excludedCbs, noShapeMunis:mv.noShapeMunis, noShapeCbs:mv.noShapeCbs,\n        cbsByMuni:mv.cbsByMuni, gv:mv.gv, domUF:mv.domUF, ufList:mv.ufList, closingKeys:Object.keys(mv.geoFeat||{}),\n        nbrKeys:(mv.nbrFeat||[]).map(f=>mkey(f.properties.name||f.properties.NOME||f.properties.nome,f.__uf)),\n        optTier:mv.optTier, reachable:mv.reachable||{},\n        ...(mv.kind==='transfer'?{kind:'transfer',xferFrom:mv.xferFrom,xferTo:mv.xferTo,xferMuni:mv.xferMuni,xferMetrics:mv.xferMetrics,xferRows:mv.xferRows}:{}),\n        ...(isSetor?{\n          matrix:(mv.matrix||[]).map(r=>({municipio:r.municipio,uf:r.uf,setor:r.setor,grupo:r.grupo,cbs:r.cbs})),\n          levelByMuni:mv.levelByMuni, allowed:mv.allowed, muniSiblings:mv.muniSiblings,\n          sharedSiblingCodes:[...(mv.sharedSiblingCodes||[])], missingMunis:mv.missingMunis,\n          orphanByGroup:mv.orphanByGroup, orphanMetricsByGroup:mv.orphanMetricsByGroup, orphanLevelByGroup:mv.orphanLevelByGroup,\n          foraDoSetorByGrupo:mv.foraDoSetorByGrupo,\n        }:{}),\n      };\n    }),\n    sectorBaseCache:{...SESS.sectorBaseCache},\n    updatedAt:nowIso,\n  };\n  $('#sessStatus').textContent='salvando…';\n  try{\n    if(SESS.sessionId){\n      await ds.update({_id:SESS.sessionId,...payload});\n    } else {\n      payload.createdAt=nowIso;\n      const created=await ds.create(payload);\n      SESS.sessionId=(created&&created._id)||(created&&created.data&&created.data._id)||(ds.selected&&ds.selected()&&ds.selected()._id)||null;\n    }\n    SESS.sessionLabel=label;\n    await refreshSessionPicker();\n    $('#sessStatus').textContent='salvo ✓';\n  }catch(e){ $('#sessStatus').textContent='erro ao salvar: '+e.message; }\n  setTimeout(()=>{ if($('#sessStatus')) $('#sessStatus').textContent=''; },2200);\n}\n// Duplicate = drop sessionId so saveSession takes its CREATE branch instead of its update branch.\n// The source record is never written to, the copy gets a fresh _id and its own createdAt, and the\n// copy becomes the active session (so the next Salvar edits the copy, not the original).\nasync function renameSession(){\n  const next=prompt('Novo nome da sessão:', SESS.sessionLabel||'');\n  if(next==null) return;\n  const trimmed=next.trim(); if(!trimmed) return;\n  SESS.sessionLabel=trimmed;\n  if(!SESS.sessionId) return;   // ainda não salva — o novo nome só pega valer no próximo \"Salvar sessão\"\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS)); if(!ds) return;\n  if($('#sessStatus')) $('#sessStatus').textContent='renomeando…';\n  try{\n    await ds.update({_id:SESS.sessionId, label:trimmed, updatedAt:new Date().toISOString()});\n    await refreshSessionPicker();\n    if($('#sessStatus')) $('#sessStatus').textContent='renomeado ✓';\n  }catch(e){ if($('#sessStatus')) $('#sessStatus').textContent='erro ao renomear: '+e.message; }\n  setTimeout(()=>{ const s=$('#sessStatus'); if(s&&(s.textContent==='renomeado ✓')) s.textContent=''; }, 2000);\n}\nasync function duplicateSession(){\n  if(!SESS.timeline.length){ $('#sessStatus').textContent='nada pra duplicar ainda'; return; }\n  const prevId=SESS.sessionId, prevLabel=SESS.sessionLabel;\n  SESS.sessionId=null;\n  SESS.sessionLabel=(SESS.sessionLabel||'Sessão')+' (cópia)';\n  await saveSession();\n  if(!SESS.sessionId){\n    // saveSession swallows its own errors into #sessStatus, so a still-null id after the await is\n    // the only failure signal we get. Put the original identity back — otherwise a failed copy\n    // silently leaves the session detached, and the next Salvar would fork a new record instead\n    // of updating the one the user thought they were editing.\n    SESS.sessionId=prevId; SESS.sessionLabel=prevLabel;\n    await refreshSessionPicker().catch(()=>{});\n    return;\n  }\n  if($('#sessStatus')) $('#sessStatus').textContent='duplicado ✓';\n}\nasync function loadSession(id){\n  id=id||'';\n  // Same code path the URL already drives at mount (see the /simulador/:session_id block, init) —\n  // now made bidirectional: picking a session here pushes that same URL shape, so the address bar,\n  // back/forward, and a copied link all agree with whatever loadSession/newSession just did. Base\n  // is derived by stripping any existing trailing /simulador(/id) rather than assumed empty, so this\n  // also degrades harmlessly (inert URL, no server round-trip — pushState never reloads) inside the\n  // builder preview, where the real base is /preview/<ref> instead of the app domain's root.\n  try{\n    const base=window.location.pathname.replace(/\\/simulador(\\/[^/?#]*)?\\/?$/,'');\n    history.pushState({}, '', base+'/simulador'+(id?('/'+encodeURIComponent(id)):''));\n  }catch(e){}\n  if(!id) return newSession();\n  const list=(SESS.sessionsList&&SESS.sessionsList.length)?SESS.sessionsList:await listSessions().catch(()=>[]);\n  const rec=list.find(s=>String(s._id)===String(id));\n  if(!rec) return newSession();\n  loader(true,'carregando sessão…');\n  SESS.sessionId=rec._id; SESS.sessionLabel=rec.label;\n  SESS.snapshotRaw=rec.snapshotRaw; SESS.snapshotSyncedAt=rec.snapshotSyncedAt||null; SESS.snapLiteral=rec.snapLiteral; SESS.eloMaxCycle=rec.eloMaxCycle;\n  SESS.snapshotPinnedDate=rec.snapshotPinnedDate||null;\n  SESS.settings={...DEFAULT_SETTINGS,...(rec.settings||{}),span:migrateSpanSettings(rec.settings)};\n  SESS.timeline=(rec.movements||[]).map(m=>({sector:String(m.sector),level:m.level||'setor'}));\n  SESS.sectorBaseCache={...(rec.sectorBaseCache||{})};\n  SESS.selected=0; SESS.stage='main'; SESS.dirtyFrom=null;\n  try{\n    // A saved session now freezes the RENDERED business data per etapa (hydrateFromFrozen), not just\n    // the recipe — loading no longer re-queries Databricks. Whether the session ends up LOCKED depends\n    // on comparing its frozen snapshot against the current live one: identical means nothing is stale,\n    // so it loads as a normal editable session; different means the frozen data is behind, so it's\n    // locked until refreshSnapshot() recomputes against the live snapshot. A failed check defaults to\n    // locked (safe: gate mutations rather than silently assume the frozen data is still current).\n    try{\n      const liveP=await resolveSnapshotPin();\n      SESS.locked = !liveP.snapshotRaw || String(liveP.snapshotRaw)!==String(rec.snapshotRaw);\n    }catch(e){ SESS.locked=true; }\n    // suggestMuniMoves/computeMovement assume DOM.bySetor is already populated — true in the live\n    // editing flow (loadMoveOptions calls ensureDominantes before any movement can exist), but a\n    // loaded session can have etapas from the moment it lands, without ever visiting that flow. Kicked\n    // off here (parallel with hydration/geometry fetches) so it's ready by the time the UI is\n    // interactive; a failure is surfaced but doesn't block the frozen data from displaying.\n    const domP=ensureDominantes().catch(e=>e);\n    for(let i=0;i<SESS.timeline.length;i++){\n      const saved=(rec.movements||[])[i]||{};\n      loader(true,`carregando etapa ${i+1}/${SESS.timeline.length} — setor ${SESS.timeline[i].sector}…`);\n      await hydrateFromFrozen(i,saved);\n    }\n    await loadSectors();\n    const domRes=await domP;\n    if(domRes instanceof Error){ $('#ds').textContent='sessão carregada, mas a base de municípios dominantes falhou: '+domRes.message+' — sugestões de correção e \"Adicionar movimentação\" ficam indisponíveis até recarregar.'; $('#ds').classList.add('err'); }\n  }catch(e){ $('#ds').textContent='erro ao carregar sessão: '+e.message; $('#ds').classList.add('err'); }\n  finally{ loader(false); }\n  // Sessão trocou de identidade (SESS.sessionId mudou lá em cima) — o <select id=\"sessionPick\">/\n  // #confirmSessionPick e o rótulo visível do macsel só refletem isso through refreshSessionPicker\n  // (é ela que faz sel.value=SESS.sessionId||'' + initMacSelect), nunca automaticamente.\n  await refreshSessionPicker().catch(()=>{});\n  renderAll();\n}\nfunction newSession(){\n  SESS.sessionId=null; SESS.sessionLabel=null; SESS.timeline=[]; SESS.selected=0; SESS.sectorBaseCache={};\n  SESS.settings={...DEFAULT_SETTINGS};\n  SESS.snapshotRaw=null; SESS.snapshotSyncedAt=null; SESS.snapshotPinnedDate=null; SESS.snapLiteral=null; SESS.eloMaxCycle=null; SESS.stage='loading'; SESS.dirtyFrom=null; SESS.locked=false;\n  (async()=>{\n    loader(true,'carregando setores…');\n    try{ await loadSectors(); SESS.stage='confirm'; }\n    catch(e){ setError('erro init: '+e.message); }\n    finally{ loader(false); await refreshSessionPicker().catch(()=>{}); renderAll(); }\n  })();\n}\n\n// ---------- snapshot refresh ----------\n// Deliberately breaks the frozen-snapshot guarantee, on demand: re-resolves DT_HISTORICO and\n// replays the whole timeline against the new one. Skips the replay when the snapshot has not\n// actually moved, so a stray click on a long timeline costs one cheap probe instead of ~8 queries\n// per etapa.\nasync function refreshSnapshot(){\n  if((SESS.stage!=='main' && SESS.stage!=='confirm') || SESS.snapBusy) return;\n  SESS.snapBusy=true;\n  const prevRaw=SESS.snapshotRaw;\n  const pill=$('#liveState'); if(pill){ pill.classList.add('busy'); pill.textContent='◌ atualizando snapshot…'; }\n  const sst0=$('#snapStatus'); if(sst0) sst0.textContent='buscando o snapshot mais recente…';\n  loader(true,'buscando o snapshot mais recente…');\n  let ok=false;\n  try{\n    const p=await resolveSnapshotPin();\n    if(!p.snapshotRaw) throw new Error('não foi possível resolver DT_HISTORICO');\n    const same=String(p.snapshotRaw)===String(prevRaw);\n    SESS.snapshotRaw=p.snapshotRaw; SESS.snapshotSyncedAt=p.syncedAt; SESS.snapLiteral=p.snapLiteral;\n    SESS.snapshotPinnedDate=null;   // clicking back to \"live\" always un-pins any historical date\n    SESS.locked=false;   // updating the snapshot always unlocks — this session is live again now\n    await loadSectors();   // the sector list follows the live max, so it can have moved too\n    let msg;\n    if(same){\n      msg=`snapshot já era o mais recente · atualizado em ${fmtSyncedAt(p.syncedAt)} — nada recalculado`;\n    } else if(SESS.timeline.length){\n      SESS.dirtyFrom=null;\n      SESS.sectorBaseCache={};   // baselines come from the snapshot, so every one of them changed\n      loader(true,'snapshot novo — recalculando a timeline…');\n      await recomputeFrom(0);\n      msg=`snapshot atualizado em ${fmtSyncedAt(p.syncedAt)} · ${fmt(SESS.timeline.length)} etapa(s) recalculada(s)`;\n    } else {\n      msg=`snapshot atualizado em ${fmtSyncedAt(p.syncedAt)}`;\n    }\n    const d=$('#ds'); if(d){ d.textContent=msg; d.classList.remove('err'); }\n    const sst=$('#snapStatus'); if(sst) sst.textContent=msg;\n    ok=true;\n  }catch(e){\n    const at=(SESS.dirtyFrom==null?null:SESS.dirtyFrom+1);\n    const errMsg='erro ao atualizar o snapshot'+(at?` na etapa ${at} — etapa ${at} em diante ficou desatualizada (⟳)`:'')+': '+e.message;\n    const d=$('#ds'); if(d){ d.textContent=errMsg; d.classList.add('err'); }\n    const sst=$('#snapStatus'); if(sst) sst.textContent=errMsg;\n  }finally{\n    SESS.snapBusy=false;\n    if(pill) pill.classList.remove('busy');\n    loader(false); renderAll();\n    if(ok) setTimeout(()=>{ const d=$('#ds'); if(d&&!d.classList.contains('err')) d.textContent=''; },7000);\n  }\n}\n\n\n// ---------- session options ----------\nfunction readOptForm(){\n  const scopeOf=lvl=>({\n    targetSpan:numOrNull($('#optTargetSpan_'+lvl).value),\n    maxSpan:numOrNull($('#optMaxSpan_'+lvl).value),\n    minSpan:numOrNull($('#optMinSpan_'+lvl).value),\n  });\n  // Mercado span has no UI control anymore — carry the session's current value through untouched\n  // instead of reading DOM inputs that no longer exist.\n  const fc=root.querySelector('input[name=\"forceContig\"]:checked');\n  return {span:{setor:scopeOf('setor'), gv:scopeOf('gv'), mercado:mergedSpanAll().mercado},\n    forceContiguity: fc?fc.value==='1':!!(SESS.settings&&SESS.settings.forceContiguity)};\n}\nfunction spanScopeError(sc,label){\n  if(sc.minSpan&&sc.maxSpan&&sc.minSpan>sc.maxSpan) return `${label} — span mínimo (${fmt(sc.minSpan)}) não pode ser maior que o máximo (${fmt(sc.maxSpan)}).`;\n  if(sc.targetSpan&&sc.maxSpan&&sc.targetSpan>sc.maxSpan) return `${label} — span alvo (${fmt(sc.targetSpan)}) não pode passar do máximo (${fmt(sc.maxSpan)}).`;\n  if(sc.targetSpan&&sc.minSpan&&sc.targetSpan<sc.minSpan) return `${label} — span alvo (${fmt(sc.targetSpan)}) não pode ficar abaixo do mínimo (${fmt(sc.minSpan)}).`;\n  return '';\n}\nfunction spanFormError(f){\n  return spanScopeError(f.span.setor,'Setor')||spanScopeError(f.span.gv,'GV')||spanScopeError(f.span.mercado,'Mercado');\n}\nfunction renderOptPreview(){\n  const el=$('#optPreview'); if(!el) return;\n  const f=readOptForm();\n  const err=spanFormError(f), ee=$('#optSpanErr');\n  if(ee){ ee.textContent=err; ee.classList.toggle('hidden',!err); }\n  const ap=$('#optApply'); if(ap) ap.disabled=!!err;\n  const mv=SESS.timeline[SESS.selected];\n  if(!mv||!mv.munis){ el.innerHTML='Nenhuma etapa calculada ainda — as regras passam a valer no próximo fechamento.'; return; }\n  const lvl=mv.level||'setor', sc=f.span[lvl];\n  let h=`<b style=\"color:var(--ink)\">Prévia — etapa ${SESS.selected+1}, ${lc(LEVELS[lvl].label)} ${mv.sector}</b><br>`;\n  if(sc.targetSpan||sc.maxSpan||sc.minSpan){\n    const sp=spanMapFor(mv,mv.assign||{}), v=spanViolationsOf(sp,{target:sc.targetSpan,max:sc.maxSpan,min:sc.minSpan});\n    h+=`Span dos destinos como está hoje: ${mv.dest.map(s=>s+' <b>'+fmt(sp[s])+'</b>').join(' · ')||'–'}`;\n    if(v.over.length||v.under.length) h+=`<br><span style=\"color:var(--closing)\">Fora dos limites: ${[...v.over.map(s=>s+' ▲ acima do máx'),...v.under.map(s=>s+' ▼ abaixo do mín')].join(', ')}</span> — o modelo ÓTIMO tenta corrigir no recálculo.`;\n    else h+=` <span style=\"color:var(--ok)\">— já dentro dos limites</span>`;\n  } else {\n    h+=`Sem limite de span pra ${lc(LEVELS[lvl].label)} — ${lc(LEVELS[lvl].destLabel)} destino pode ficar de qualquer tamanho.`;\n  }\n  if(SESS.timeline.length) h+=`<br><span style=\"margin-top:4px;display:inline-block\">Aplicar <b>recalcula as ${fmt(SESS.timeline.length)} etapa(s)</b> contra o snapshot congelado — os destinos ajustados à mão serão refeitos pelo modelo ÓTIMO.</span>`;\n  el.innerHTML=h;\n}\nfunction openOptions(){\n  const sp=mergedSpanAll();\n  ['setor','gv'].forEach(lvl=>{\n    $('#optTargetSpan_'+lvl).value=sp[lvl].targetSpan||'';\n    $('#optMaxSpan_'+lvl).value=sp[lvl].maxSpan||'';\n    $('#optMinSpan_'+lvl).value=sp[lvl].minSpan||'';\n  });\n  const fcVal=(SESS.settings&&SESS.settings.forceContiguity)?'1':'0';\n  const fcEl=root.querySelector('input[name=\"forceContig\"][value=\"'+fcVal+'\"]');\n  if(fcEl) fcEl.checked=true;\n  $('#optApply').disabled=false;\n  $('#optSessName').textContent=SESS.sessionLabel||'sessão não salva';\n  $('#optStatus').textContent='';\n  $('#optionsBg').classList.remove('hidden');\n  renderOptPreview();\n  const c=$('#optCancel'); if(c) c.focus();\n}\nfunction closeOptions(){ const b=$('#optionsBg'); if(b) b.classList.add('hidden'); }\nfunction openLockedSheet(){ const b=$('#lockedBg'); if(b) b.classList.remove('hidden'); const s=$('#lockedStatus'); if(s) s.textContent=''; const c=$('#lockedCancel'); if(c) c.focus(); }\nfunction closeLockedSheet(){ const b=$('#lockedBg'); if(b) b.classList.add('hidden'); }\n\nfunction guardLocked(){ if(!SESS.locked) return false; openLockedSheet(); return true; }\nfunction pillClick(){ if(SESS.locked){ openLockedSheet(); return; } refreshSnapshot(); }\nasync function applyOptions(){\n  if(guardLocked()) return;\n  const f=readOptForm();\n  if(spanFormError(f)) return;\n  const prevSp=mergedSpanAll();\n  const changed=['setor','gv','mercado'].some(lvl=>\n    numOrNull(prevSp[lvl].targetSpan)!==f.span[lvl].targetSpan\n    || numOrNull(prevSp[lvl].maxSpan)!==f.span[lvl].maxSpan\n    || numOrNull(prevSp[lvl].minSpan)!==f.span[lvl].minSpan\n  );\n  SESS.settings={...SESS.settings,span:f.span,forceContiguity:f.forceContiguity};\n  closeOptions();\n  if(!changed || !SESS.timeline.length){ renderAll(); return; }\n  SESS.dirtyFrom=null;\n  SESS.sectorBaseCache={};   // the recompute re-derives every baseline; keeping the old ones would\n                             // leave \"Impacto acumulado\" comparing against pre-change numbers\n  loader(true,'aplicando novos limites de span — recalculando timeline…');\n  try{\n    await recomputeFrom(0);\n    $('#ds').textContent=''; $('#ds').classList.remove('err');\n  }catch(e){\n    const at=(SESS.dirtyFrom==null?0:SESS.dirtyFrom)+1;\n    $('#ds').textContent=`erro ao aplicar o limite na etapa ${at}: ${e.message} — etapa ${at} em diante ficou desatualizada (⟳)`;\n    $('#ds').classList.add('err');\n  }finally{ loader(false); renderAll(); }\n}\n\nconst SHARED_DS='timeline-shared-sessionsDataSet';\nconst AUTH_REF='timelineSimAuthFinal';   // single source of truth for the auth strategy reference\nlet SHARE={busy:false,url:null,last:null};\n// Serialise a PRESENTATION model, not the raw session, so the reader stays purely presentational\n// and never re-derives spans/desloc/orphans — no second copy of this engine to keep in sync.\n// NOTE: METRICS_DEF[2] is the money marker; read it as !!md[2] (a literal dollar sign in this\n// position trips the upsert's parse check).\nfunction buildShareStages(){\n  return SESS.timeline.map((mv,i)=>{\n    const c=computeDerived(mv);\n    const incBy={};\n    mv.dest.forEach(s=>{ incBy[s]=Object.fromEntries(METRICS_DEF.map(md=>[md[0],0])); });\n    Object.entries(mv.munis).forEach(([m,o])=>{\n      if(m==='__OUTROS__') return;\n      const s=mv.assign[m]; if(!s||!incBy[s]) return;\n      METRICS_DEF.forEach(md=>{ incBy[s][md[0]]+=o[md[0]]||0; });\n    });\n    const saiTot={};\n    METRICS_DEF.forEach(md=>{\n      const k=md[0];\n      saiTot[k]=Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,p)=>a+((p[1][k])||0),0);\n    });\n    // orphans follow their leader's home: they enter that sector AND leave the closing one\n    c.leaders.forEach(L=>{\n      const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n      if(!om||!L.home) return;\n      METRICS_DEF.forEach(md=>{ const k=md[0];\n        if(incBy[L.home]) incBy[L.home][k]+=om[k]||0;\n        saiTot[k]+=om[k]||0; });\n    });\n    const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n    const sv=mv.dest.map(s=>c.span[s]);\n    const baseOf=(s,k)=>{ const b=mv.baseMetrics[s]||{}; return +(b[k]||0); };\n    return {\n      i:i, sector:mv.sector, level:mv.level||'setor', kind:mv.kind||null, xferFrom:mv.xferFrom||null, xferTo:mv.xferTo||null, xferMuni:mv.xferMuni||null, xferRows:mv.xferRows||null,\n      unitOfMuni:mv.unitOfMuni||{}, policy:mv.policy, domUF:mv.domUF,\n      optTier:(mv.optTier==null?0:mv.optTier),\n      dest:mv.dest.slice(), color:Object.assign({},mv.color), assign:Object.assign({},mv.assign),\n      kpis:{ cbsRealocadas:closingCbsOf(mv), desloc:c.desloc,\n             spread:(sv.length?Math.max.apply(null,sv)-Math.min.apply(null,sv):0),\n             municipios:ms.length, lideres:mv.leaders.length },\n      destinos:mv.dest.map(s=>({ s:s, base:baseOf(s,'cbs'), inc:c.inc[s]||0, orph:c.orph[s]||0, span:c.span[s]||0, gained:c.gained[s]||0 })),\n      municipios:ms.map(m=>(mv.level&&mv.level!=='setor')?{ nome:m, uf:'', cbs:mv.munis[m].cbs, destino:mv.assign[m]||null }:{ nome:mName(m), uf:mUf(m), cbs:mv.munis[m].cbs, destino:mv.assign[m]||null }),\n      excluidos:(mv.excludedMunis||[]).map(x=>({ muni:x.muni, cbs:x.cbs })),\n      orphanTotal:c.orphanTotal||0, orphanStranded:c.orphanStranded||0, noShapeCbs:mv.noShapeCbs||0,\n      lideres:c.leaders.map(L=>({ lider:(L.lider||'').split(' ').slice(0,2).join(' '), grupo:L.grupo,\n        total:L.total, home:L.home, desloc:L.desloc, orphan:L.orphan||0 })),\n      metricas:METRICS_DEF.map(md=>{ const k=md[0];\n        return { key:k, label:md[1], money:!!md[2],\n          rows:mv.dest.map(s=>({ s:s, antes:baseOf(s,k), depois:baseOf(s,k)+((incBy[s]&&incBy[s][k])||0) })),\n          sai:saiTot[k]||0 }; }),\n      geoFeat:mv.geoFeat, nbrFeat:mv.nbrFeat, excludedFeat:[],\n      // Everything below is for the shared read-only viewer (CNL_4z20SJcX0NMp2I2vz) to reproduce\n      // this live map's shared-município mosaic (hatch, clean outline, per-context colouring) —\n      // without it, a município like São Paulo can only ever render as this sector's own slice\n      // plus flat grey \"context\", because none of the resolution work (who else really owns it,\n      // what colour that owner gets, whether it's a rejected/novalid sibling) survived into the\n      // saved record. Every value here is already fully RESOLVED at this point — the read view\n      // only ever renders them, it never re-derives a business rule (no live DOM/world access\n      // there anyway). rejectedCodes is a Set, spread to an array — JSON has no Set.\n      sharedMuniSlices:(mv.sharedMuniSlices||[]).map(sl=>({feature:sl.feature,sector:sl.sector,muni:sl.muni})),\n      muniFullShape:mv.muniFullShape||{},\n      nbrCurrentSector:mv.nbrCurrentSector||{},\n      rejectedCodes:[...(mv.rejectedCodes||[])],\n      muniOwners:mv.muniOwners||{}\n    };\n  });\n}\nfunction buildShareSummary(stages){\n  const world=foldWorldState(SESS.timeline,SESS.timeline.length);\n  const receivers=[];\n  SESS.timeline.forEach((mv,i)=>{\n    if(mv.kind==='transfer') return;\n    const own=mv.pureOwnMetrics||{};\n    receivers.push({ s:mv.sector, situacao:'fechado (etapa '+(i+1)+')', antes:+(own.cbs||0), depois:0 });\n  });\n  const seen={};\n  Object.values(world.muniOverride).forEach(s=>{\n    if(seen[s]) return; seen[s]=1;\n    const base=SESS.sectorBaseCache[s]||{}, infl=world.sectorInflow[s]||{};\n    receivers.push({ s:s, situacao:'recebeu território', antes:+(base.cbs||0), depois:(+(base.cbs||0))+(infl.cbs||0) });\n  });\n  return { stages:stages.length, sectors:SESS.timeline.map(mv=>mv.sector),\n    totalCbs:stages.reduce((a,s)=>a+(s.kpis.cbsRealocadas||0),0),\n    totalDesloc:stages.reduce((a,s)=>a+(s.kpis.desloc||0),0), receivers:receivers };\n}\nfunction renderShareBody(){\n  const el=$('#shareBody'); if(!el) return;\n  const go=$('#shareGo'), cp=$('#shareCopy'), op=$('#shareOpen'), cc=$('#shareCancel');\n  if(SHARE.url){\n    el.innerHTML=`<div class=\"opt-note\" style=\"margin-top:0\"><b style=\"color:var(--ink)\">Sessão compartilhada ✓</b><br>\n      Link de leitura desta simulação congelada. Quem abrir precisa ter login no simulador.\n      <div class=\"shareurl\">${esc(SHARE.url)}</div></div>`;\n    if(go) go.classList.add('hidden');\n    if(cp) cp.classList.remove('hidden');\n    if(op) op.classList.remove('hidden');\n    if(cc) cc.textContent='Fechar';\n    return;\n  }\n  if(go) go.classList.remove('hidden');\n  if(cp) cp.classList.add('hidden');\n  if(op) op.classList.add('hidden');\n  if(cc) cc.textContent='Cancelar';\n  const desloc=SESS.timeline.reduce((a,mv)=>a+(mv.munis?computeDerived(mv).desloc:0),0);\n  el.innerHTML=`<div class=\"opt-lbl\">Confirmar compartilhamento</div>\n    <div class=\"opt-sub\">Publica uma <b>cópia congelada</b> desta sessão, exatamente no estado atual.</div>\n    <div class=\"opt-note\" style=\"margin-top:0\">\n      · <b>${fmt(SESS.timeline.length)}</b> etapa(s) — ${esc(SESS.timeline.map(mv=>mv.sector).join(' → '))}<br>\n      · snapshot atualizado em <b>${esc(fmtSyncedAt(SESS.snapshotSyncedAt))}</b><br>\n      · base: <b>municípios dominantes (CSV)</b> · destino: <b>qualquer município</b> · span: <b>${esc(spanShortAll())}</b><br>\n      · <b>${fmt(desloc)}</b> CBs deslocadas somando a cadeia\n    </div>\n    <div class=\"opt-note\">A cópia <b>não</b> acompanha edições posteriores desta sessão — pra atualizar, compartilhe de novo e use o link novo. O link exige login no simulador; não é público.</div>\n    ${SHARE.last?`<div class=\"opt-note\"><b style=\"color:var(--ink)\">Último link gerado nesta sessão</b> — aponta pro estado de <b>antes</b> das edições feitas depois dele.<div class=\"shareurl\">${esc(SHARE.last)}</div></div>`:''}`;\n}\nasync function openShare(){\n  if(!SESS.timeline.length){ $('#ds').textContent='nada pra compartilhar — feche pelo menos um setor primeiro.'; $('#ds').classList.add('err'); return; }\n  await flushDirty();\n  SHARE.url=null;\n  const s=$('#shareStatus'); if(s){ s.textContent=''; s.classList.remove('err'); }\n  renderShareBody();\n  $('#shareBg').classList.remove('hidden');\n  const b=$('#shareCancel'); if(b) b.focus();\n}\nfunction closeShare(){ const b=$('#shareBg'); if(b) b.classList.add('hidden'); }\nasync function doShare(){\n  if(SHARE.busy) return;\n  const go=$('#shareGo'), stx=$('#shareStatus');\n  SHARE.busy=true;\n  if(go){ go.disabled=true; go.textContent='Compartilhando…'; }\n  if(stx){ stx.textContent=''; stx.classList.remove('err'); }\n  try{\n    // Resolved as a GLOBAL PROCESS, not a declared param — which only works because the block\n    // instance on the `main` artboard binds `sharedDataset: @timeline-shared-sessionsDataSet`.\n    // That binding is load-bearing: a Process is only define()d once something on the rendering\n    // tree references it, so without it this lookup returns null and sharing dies on the guard\n    // below. Do NOT remove the instance param because this file never reads d2.params.sharedDataset.\n    const ds=d2.data(SHARED_DS);\n    if(!ds) throw new Error('dataset de sessões compartilhadas indisponível');\n    await flushDirty();\n    const stages=buildShareStages();\n    const summary=buildShareSummary(stages);\n    let who='';\n    try{ const a=d2.auth(AUTH_REF); who=(a&&a.user&&(a.user.email||a.user.nome))||''; }catch(e){}\n    const created=await ds.create({\n      label:SESS.sessionLabel||('Sessão '+new Date().toLocaleString('pt-BR')),\n      snapshotRaw:SESS.snapshotRaw, snapshotSyncedAt:SESS.snapshotSyncedAt,\n      settings:{...DEFAULT_SETTINGS,...(SESS.settings||{})},\n      stages:stages, summary:summary, sharedBy:who, sharedAt:new Date().toISOString()\n    });\n    const id=(created&&created._id)||(created&&created.data&&created.data._id)||(ds.selected&&ds.selected()&&ds.selected()._id)||null;\n    if(!id) throw new Error('registro gravado, mas sem _id na resposta — não foi possível montar o link');\n    SHARE.url=window.location.origin+'/sessions/'+id;\n    SHARE.last=SHARE.url;\n    renderShareBody();\n  }catch(e){\n    if(stx){ stx.textContent='erro ao compartilhar: '+e.message; stx.classList.add('err'); }\n  }finally{\n    SHARE.busy=false;\n    if(go){ go.disabled=false; go.textContent='Compartilhar'; }\n  }\n}\n\n// ---------- rendering ----------\nfunction renderTimeline(){\n  const el=$('#movementChips'); if(!el) return;\n  if(!SESS.timeline.length){ el.innerHTML='<span class=\"empty\">Nenhuma etapa ainda — use \"Adicionar movimentação\" à direita.</span>'; return; }\n  el.innerHTML=SESS.timeline.map((mv,i)=>{\n    const stale=SESS.dirtyFrom!=null && i>=SESS.dirtyFrom;\n    const on=i===SESS.selected?'on':'';\n    const isXfer=mv.kind==='transfer';\n    const label=isXfer\n      ? `<span class=\"tarw\" aria-hidden=\"true\">↳</span><span class=\"tsec\">${esc(mName(mv.xferMuni))}: ${esc(mv.xferFrom)} → ${esc(mv.xferTo)}</span>`\n      : `<span class=\"tsec\">${LEVELS[mv.level||'setor'].label} ${mv.sector}</span>`;\n    return `<div class=\"tchip ${isXfer?'tchip-transfer':''} ${on} ${stale?'stale':''}\" data-i=\"${i}\">\n      <span class=\"tnum\">${i+1}</span>${label}\n      ${stale?'<span class=\"tstale\" title=\"recalcula ao selecionar outra etapa\">⟳</span>':''}\n      <span class=\"trm\" data-rm=\"${i}\" title=\"remover esta etapa e as seguintes\">✕</span>\n    </div>`;\n  }).join('');\n  el.querySelectorAll('.tchip').forEach(ch=>{\n    ch.addEventListener('click',async (e)=>{\n      if(e.target.dataset.rm!=null && e.target.dataset.rm!==''){ removeFrom(+e.target.dataset.rm); return; }\n      const i=+ch.dataset.i;\n      if(i!==SESS.selected){ await flushDirty(); }\n      PULL_SET=new WeakSet();\n      SESS.selected=i; renderAll();\n    });\n  });\n}\nfunction renderRuleNote(){\n  const el=$('#ruleNote'); if(!el) return;\n  const mv=SESS.timeline[SESS.selected];\n  const lvl=LEVELS[(mv&&mv.level)||'setor'];\n  let h='';\n  if(!mv || (mv.level||'setor')==='setor'){\n    h+=`<b style=\"color:var(--ink)\">Base de municípios dominantes:</b> só os municípios que a base (CSV) atribui ao setor entram no mapa e na alocação`;\n    if(mv&&mv.domCsvMunis) h+=` · <b>${fmt(mv.domCsvMunis)}</b> na base do setor ${mv.sector}`;\n    if(mv&&mv.excludedCbs) h+=` · <b style=\"color:var(--closing)\">${fmt(mv.excludedMunis.length)} município(s) / ${fmt(mv.excludedCbs)} CBs</b> fora da base nesta etapa — não aparecem no mapa e as CBs seguem suas líderes (quebra de vínculo)`;\n  } else {\n    // GV/mercado não têm spillover (todo setor pertence a exatamente uma GV/mercado na base), então\n    // não existe \"excluído\" pra reportar aqui — só o tamanho da base e em quantas unidades ela se agrupa.\n    h+=`<b style=\"color:var(--ink)\">Base de ${lc(lvl.childLabel)}s dominantes:</b> ${mv.domCsvMunis!=null?`<b>${fmt(mv.domCsvMunis)}</b> município(s) `:''}na base ${lvl.de} ${lc(lvl.label)} ${mv.sector}, agrupados em <b>${fmt(Object.keys(mv.unitMunis||{}).length)}</b> ${lc(lvl.childLabel)}(s)`;\n  }\n  if(DOM.error) h+=` · <b style=\"color:var(--closing)\">⚠ base indisponível: ${esc(DOM.error)}</b>`;\n  el.innerHTML=h;\n}\nconst FIX_CACHE = new WeakMap();\nasync function computeFixSpan(mv, code, lvl, sp, lim, isOver){\n  const closeWork=(async()=>{\n    try{\n      const world=foldWorldState(SESS.timeline,SESS.timeline.length);\n      const pv=await computeMovement(String(code),lvl,world);\n      pv.assign=optimizeFor(pv);\n      const pvLim=spanLimits(pv);\n      const pvSp=spanMapFor(pv,pv.assign||{});\n      const pvV=spanLimitsOn(pvLim)?spanViolationsOf(pvSp,pvLim):{over:[],under:[]};\n      return {ok:true, dest:pv.dest||[], spMap:pvSp, over:pvV.over, under:pvV.under, optTier:pv.optTier};\n    }catch(e){\n      return {ok:false, errorMsg:e.message, rejected:[]};\n    }\n  })();\n  const moveWork=(async()=>{\n    try{\n      const plan=await suggestMuniMoves(mv,sp,lim,code,isOver);\n      return {ok:true, plan};\n    }catch(e){\n      return {ok:false, errorMsg:e.message, plan:null};\n    }\n  })();\n  const [close,move]=await Promise.all([closeWork,moveWork]);\n  return {code, isOver, close, move};\n}\nfunction renderFixResult(resultEl, entry, ctx){\n  const {mv,code,lvl,sp,lim}=ctx;\n  const close=entry.close;\n  const closeOk=!!(close&&close.ok&&!close.over.length&&!close.under.length);\n  const move=entry.move;\n  const plan=(move&&move.ok)?move.plan:null;\n  const moveOk=!!(plan&&plan.moves&&plan.resolved);\n\n  if(!closeOk&&!moveOk){\n    resultEl.innerHTML='<div class=\"hint\">nenhuma simulação resolve esta violação por completo.</div>';\n    return;\n  }\n  resultEl.innerHTML='';\n\n  if(closeOk){\n    const closeSlot=document.createElement('div'); resultEl.appendChild(closeSlot);\n    const chips=(close.dest||[]).map(d=>`<span class=\"chip\">${d} ${fmt(close.spMap[d])}</span>`).join('');\n    let pvTier='';\n    if(close.optTier===1) pvTier='nenhuma alocação conectada respeitaria o mínimo — o modelo relaxou essa exigência nesta simulação.';\n    else if(close.optTier===2) pvTier='nenhuma alocação conectada respeitaria os limites — o modelo relaxou essa exigência nesta simulação.';\n    else if(close.optTier===-1) pvTier='simulação resolvida por heurística — os limites não são garantidos.';\n    closeSlot.className='fixcard';\n    closeSlot.innerHTML=`<div>fechar ${LEVELS[lvl].label} ${code} redistribui a carga (base + já acumulado) entre:</div>\n      <div class=\"chips\">${chips||'—'}</div>\n      <div class=\"verdict ok\">✓ fecha limpo — nenhum receptor estoura o limite</div>\n      ${pvTier?`<div class=\"hint\">${pvTier}</div>`:''}\n      <button class=\"act applyfix\">✓ Aplicar — fechar ${code} como próxima etapa</button>`;\n    closeSlot.querySelector('.applyfix').onclick=()=>{ addMovement(lvl,code); };\n  }\n\n  if(moveOk){\n    const moveSlot=document.createElement('div'); resultEl.appendChild(moveSlot);\n    const rows=plan.moves.map(m=>`<div>${esc(mDisp(m.muni))} (${fmt(m.cbs)} CBs): <b style=\"color:${mv.color[m.from]}\">${m.from}</b> → <b style=\"color:${mv.color[m.to]}\">${m.to}</b></div>`).join('');\n    const touched=[...new Set(plan.moves.flatMap(m=>[m.from,m.to]))];\n    const afterChips=touched.map(s=>`<span class=\"chip\">${s} ${fmt(sp[s])} → ${fmt(plan.spNow[s])}</span>`).join('');\n    moveSlot.className='fixcard';\n    moveSlot.innerHTML=`<div>mover ${fmt(plan.moves.length)} município(s), sem fechar nenhum setor:</div>\n      <div>${rows}</div>\n      <div class=\"chips\">${afterChips}</div>\n      <div class=\"verdict ok\">✓ resolve esta violação por completo</div>\n      <button class=\"act applymove\">✓ Aplicar — mover ${plan.moves.length>1?'estes municípios':'só este município'}</button>`;\n    moveSlot.querySelector('.applymove').onclick=async()=>{\n      const mb=moveSlot.querySelector('.applymove');\n      mb.disabled=true; mb.textContent='movendo…';\n      try{\n        for(const m of plan.moves){ await applyTransfer(m.from,m.to,m.muni); }\n        mb.textContent='recalculando etapa…';\n        await flushDirty();\n        renderAll();\n      }catch(e){ mb.disabled=false; mb.textContent='erro: '+e.message; }\n    };\n    const applyBtn=moveSlot.querySelector('.applymove');\n    const setPlanHl=on=>{\n      const svg=$('#map'); if(!svg) return;\n      const want=new Set(plan.moves.map(m=>m.muni));\n      svg.querySelectorAll('[data-muni]').forEach(el=>{ if(want.has(el.getAttribute('data-muni'))) el.classList.toggle('munihl',on); });\n    };\n    applyBtn.addEventListener('mouseenter',()=>setPlanHl(true));\n    applyBtn.addEventListener('mouseleave',()=>setPlanHl(false));\n  }\n}\nfunction renderSpanRule(){\n  const el=$('#spanRule'); if(!el) return;\n  const mv=SESS.timeline[SESS.selected], lim=spanLimits(mv);\n  if(!mv||!mv.munis||mv.view==='asis'||!spanLimitsOn(lim)){ el.innerHTML=''; return; }\n  const sp=spanMapFor(mv,mv.assign||{}), v=spanViolationsOf(sp,lim);\n  const bits=[];\n  if(lim.target) bits.push(`alvo ${fmt(lim.target)}`);\n  if(lim.max) bits.push(`máx ${fmt(lim.max)}`);\n  if(lim.min) bits.push(`mín ${fmt(lim.min)}`);\n  const limTxt=`Pré-requisito de span: ${bits.join(' · ')} CBs`;\n  let tierTxt='';\n  if(mv.optTier===1) tierTxt=' — nenhuma alocação conectada respeita o mínimo; o modelo relaxou essa exigência.';\n  else if(mv.optTier===2) tierTxt=' — nenhuma alocação conectada respeita os limites; o modelo relaxou essa exigência.';\n  else if(mv.optTier===-1) tierTxt=' — etapa resolvida por heurística; os limites não são garantidos.';\n  if(v.over.length||v.under.length){\n    const lvl=mv.level||'setor';\n    const fixCodes=[...v.over,...v.under];\n    const rows=fixCodes.map(s=>{\n      const dir=v.over.includes(s)?'acima do máximo':'abaixo do mínimo';\n      return `<div class=\"rule-issue-row\" data-code=\"${s}\">\n        <div class=\"rule-issue-hd\">\n          <button class=\"rule-issue fixspan\" data-code=\"${s}\" title=\"${limTxt}${tierTxt} — clique pra simular fechar ${s} e ver, de verdade, pra onde a carga dele iria\">⚠ ${s} ${dir}: ${fmt(sp[s])}</button>\n          <button class=\"fixdismiss hidden\" data-code=\"${s}\" title=\"Recolher as sugestões desta violação\">✕</button>\n        </div>\n        <div class=\"fixresult\"></div>\n      </div>`;\n    }).join('');\n    el.innerHTML=`<div class=\"rule-item\">\n      <div class=\"rule-cap\">Span</div>\n      <div class=\"rule-issues\">${rows}</div>\n    </div>`;\n    el.querySelectorAll('.rule-issue-row').forEach(row=>{\n      const code=row.dataset.code;\n      const btn=row.querySelector('.fixspan');\n      const resultEl=row.querySelector('.fixresult');\n      const dismissBtn=row.querySelector('.fixdismiss');\n      let cache=FIX_CACHE.get(mv);\n      const cachedEntry=cache&&cache.get(code);\n      if(cachedEntry){\n        renderFixResult(resultEl,cachedEntry,{mv,code,lvl,sp,lim});\n        resultEl.classList.remove('hidden');\n        dismissBtn.classList.remove('hidden');\n      }\n      dismissBtn.onclick=()=>{ FIX_CACHE.get(mv)?.delete(code); resultEl.classList.add('hidden'); dismissBtn.classList.add('hidden'); };\n      btn.onclick=async ()=>{\n        const origText=btn.textContent;\n        resultEl.classList.remove('hidden');\n        if(!hierarchyAllows(lvl)){\n          resultEl.innerHTML=`<div class=\"fixcard\"><span style=\"color:var(--closing)\">esta sessão já fechou um nível mais alto — a sugestão não está mais disponível.</span></div>`;\n          btn.disabled=true; btn.textContent='indisponível'; return;\n        }\n        const closedNow=new Set(SESS.timeline.filter(m=>m.kind!=='transfer' && (m.level||'setor')===lvl).map(m=>m.sector));\n        if(closedNow.has(String(code))){\n          resultEl.innerHTML=`<div class=\"fixcard\"><span style=\"color:var(--closing)\">${LEVELS[lvl].label} ${code} já foi fechado(a) em outra etapa desta timeline — a sugestão não se aplica mais.</span></div>`;\n          btn.disabled=true; btn.textContent='indisponível'; return;\n        }\n        const isOver=v.over.includes(code);\n        let cache2=FIX_CACHE.get(mv); if(!cache2){ cache2=new Map(); FIX_CACHE.set(mv,cache2); }\n        let entry=cache2.get(code);\n        if(!entry){\n          dismissBtn.classList.add('hidden');\n          btn.disabled=true; btn.textContent='simulando movimentações…';\n          entry=await computeFixSpan(mv,code,lvl,sp,lim,isOver);\n          cache2.set(code,entry);\n          btn.disabled=false; btn.textContent=origText;\n        }\n        renderFixResult(resultEl,entry,{mv,code,lvl,sp,lim});\n        resultEl.classList.remove('hidden');\n        dismissBtn.classList.remove('hidden');\n      };\n    });\n  } else {\n    el.innerHTML=`<div class=\"rule-item\">\n      <div class=\"rule-cap\">Span</div>\n      <div class=\"rule-ok\" title=\"${limTxt}\">✓ todos os setores receptores dentro dos limites</div>\n    </div>`;\n  }\n}\nfunction renderContig(){const mv=SESS.timeline[SESS.selected];const el=$('#contig');if(!el||!mv)return;\n  if(mv.view==='asis'){el.innerHTML='';return;}\n  const lvl=LEVELS[mv.level||'setor'];\n  const bad=contigBadFor(mv,mv.assign);\n  if(bad.length){\n    el.innerHTML=`<div class=\"rule-item\">\n      <div class=\"rule-cap\">Conectividade</div>\n      <div class=\"rule-issues\"><button class=\"rule-issue\" id=\"fixContig\" title=\"${lc(lvl.childLabel)}(s) desconectado(s) do território ${lvl.de} ${lc(lvl.label)} — clique pra rodar ★ ÓTIMO e reconectar\">⚠ Território desconectado em ${bad.join(', ')}</button></div>\n    </div>`;\n    $('#fixContig').onclick=()=>editSelected(m=>{ m.assign=optimizeFor(m); });\n  } else {\n    el.innerHTML=`<div class=\"rule-item\">\n      <div class=\"rule-cap\">Conectividade</div>\n      <div class=\"rule-ok\">✓ cada ${lc(lvl.label)} forma uma região conectada</div>\n    </div>`;\n  }\n}\nfunction closingCbsOf(mv){ return Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,[,o])=>a+o.cbs,0); }\nfunction sectorTotalCbs(mv){ return Object.values(mv.munis).reduce((a,o)=>a+o.cbs,0); }\nfunction sectorTotalAtivas(mv){ return Object.values(mv.munis).reduce((a,o)=>a+(o.ativas||0),0); }\nfunction renderKpis(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  const childPlural=lvl.childLabel==='Setor'?'Setores':(lvl.childLabel==='GV'?'GVs':'Municípios');\n  if(mv.view==='asis'){const mc=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').length;\n    // \"CBs fora do setor\" (ASIS): soma de mv.foraDoSetorByGrupo — CBs (linha crua do CRM em mv.matrix)\n    // cujo município não está na base de municípios dominantes (dominantesCsvUrl) para ESTE setor. Só\n    // existe no nível setor (GV/mercado não têm spillover — ver computeMovement/renderRuleNote).\n    const foraDoSetorCbs=Object.values(mv.foraDoSetorByGrupo||{}).reduce((a,v)=>a+v,0);\n    $('#kpis').innerHTML=[\n      {n:fmt(sectorTotalCbs(mv)),l:`CBs ${lvl.no} ${lc(lvl.label)} ${mv.sector} (antes desta etapa)`,t:`Total de CBs ${lvl.no} ${lc(lvl.label)} hoje — já considerando o que etapas anteriores da timeline mandaram pra cá.`},\n      {n:fmt(sectorTotalAtivas(mv)),l:`CBs ativos ${lvl.no} ${lc(lvl.label)} ${mv.sector} (antes desta etapa)`,t:`Total de CBs ativos (business_status_code=3) ${lvl.no} ${lc(lvl.label)} hoje — já considerando o que etapas anteriores da timeline mandaram pra cá.`},\n      {n:mv.csvMunisTotal?`${fmt(mc)}/${fmt(mv.csvMunisTotal)}`:fmt(mc),l:`${childPlural} ${lvl.de} ${lc(lvl.label)}`,t:`${lvl.childLabel}(s) desenhados nesta etapa${mv.csvMunisTotal?` de ${fmt(mv.csvMunisTotal)} que a base de dominantes atribui ${lvl.a} ${lc(lvl.label)}`:''}.${(mv.missingMunis||[]).length?' Fora do mapa: '+mv.missingMunis.map(x=>x.muni+' ('+x.reason+')').join(' · '):''}${Object.keys(mv.ufGuessed||{}).length?' UF inferida da UF dominante do setor (nome repetido em outra UF carregada): '+Object.entries(mv.ufGuessed).map(([nm,u])=>nm+'→'+u).join(', '):''}`},\n      {n:fmt(mv.leaders.length),l:'Líderes (grupos)',t:`Líderes presentes ${lvl.no} ${lc(lvl.label)} nesta etapa.`},\n      {n:fmt(mv.dest.length),l:`${lvl.destLabel}s adjacentes`,t:`${lvl.destLabel}s vizinh${lvl.destLabel==='GV'?'as':'os'} (ainda abertas)${lvl.parentPhrase?` ${lvl.parentPhrase}`:''}${mv.gv?` (${mv.gv})`:''} que poderiam receber os ${lc(lvl.childLabel)}s.${(mv.gvRejected||[]).length&&lvl.parentWord?' Fora do mapa, por serem de outr'+(lvl.parentWord==='gerência'?'a':'o')+' '+lvl.parentWord+': '+mv.gvRejected.join(', ')+'.':''}`},\n      (mv.level||'setor')==='setor'?{n:fmt(foraDoSetorCbs),l:`CBs fora ${lvl.de} ${lc(lvl.label)}`,cls:foraDoSetorCbs?'warn':'ok',t:`CBs (dado do Databricks, antes desta etapa) em município(s) que a base de municípios dominantes (dominantesCsvUrl) não atribui a este ${lc(lvl.label)} — ficam fora do mapa e da alocação geográfica e seguem a líder do seu grupo.`}:null,\n      mv.zeroCbsWarning?{n:'⚠',l:'ELO sem CBs neste ciclo',cls:'warn',t:mv.zeroCbsWarning}:null,\n    ].filter(Boolean).map(k=>`<div class=\"kpi ${k.cls||''}\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n    return;}\n  const sv=mv.dest.map(s=>c.span[s]);const spread=sv.length?Math.max(...sv)-Math.min(...sv):0;\n  // Only flags a destination that actually received CBs (c.inc[s]>0) but ended up with no leader\n  // calling it home — a destination that received nothing tripped this trivially (gained is 0\n  // there by definition, never a real \"missing leader\" case) and was just noise on the KPI.\n  const zero=mv.dest.filter(s=>c.gained[s]===0&&c.inc[s]>0);const totCbs=closingCbsOf(mv);\n  $('#kpis').innerHTML=[\n    // \"CBs fora do X\" só faz sentido pra fechamento de setor — a mecânica de vínculo (CBs que\n    // seguem a líder pra fora do território) é uma coisa de setor/orphan, não de GV/mercado.\n    (mv.level||'setor')==='setor'?{n:fmt(c.desloc),l:mv.policy==='fora'?`CBs fora ${lvl.de} ${lc(lvl.destLabel)}`:'CBs c/ quebra de relação',cls:'warn',t:`CBs cuja líder acabou noutr${lvl.a==='à'?'a':'o'} ${lc(lvl.destLabel)} nesta etapa.`}:null,\n    {n:fmt(spread),l:'Desequilíbrio (máx−mín CBs)',cls:spread>800?'warn':'ok',t:`Diferença de tamanho entre ${lvl.destLabel==='GV'?'a maior e a menor':'o maior e o menor'} ${lc(lvl.destLabel)} depois desta etapa.`},\n    {n:fmt(totCbs),l:'CBs realocadas',cls:'',t:`Total de CBs ${lvl.de} ${lc(lvl.label)} ${mv.sector} sendo redistribuídas nesta etapa.`},\n    (mv.level||'setor')==='setor'?{n:zero.length?zero.join(', '):'nenhum',l:`${lvl.destLabel} sem líder herdada`,cls:zero.length?'warn':'ok',t:`${lvl.destLabel} que recebe ${lc(lvl.childLabel)}(s) mas nenhuma líder nesta etapa.`}:null,\n  ].filter(Boolean).concat(mv.excludedCbs?[{n:fmt(mv.excludedCbs),l:`CBs fora do setor · ${fmt(mv.excludedMunis.length)} município(s) fora da base`,cls:'warn',t:'CBs em municípios que a base de dominantes não atribui a este setor: ficam fora do mapa e da alocação geográfica e seguem a líder do seu grupo.'}]:[])\n   .concat((mv.missingMunis||[]).length?[{n:fmt(mv.missingMunis.length),l:`Município(s) da base fora do mapa · ${fmt(mv.csvMunisTotal)} na base`,cls:'warn',t:`A base de dominantes atribui ${fmt(mv.csvMunisTotal)} município(s) ao setor ${mv.sector} e ${fmt(mv.csvMunisTotal-mv.missingMunis.length)} chegaram ao mapa. Fora: ${mv.missingMunis.map(x=>x.muni+' — '+x.reason+(x.cbs?' ('+fmt(x.cbs)+' CBs)':'')).join(' · ')}`}]:[])\n   .concat(mv.zeroCbsWarning?[{n:'⚠',l:'ELO sem CBs neste ciclo',cls:'warn',t:mv.zeroCbsWarning}]:[])\n   .map(k=>`<div class=\"kpi ${k.cls}\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n}\nfunction renderSpans(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  const spansHd=$('#spansHd'); if(spansHd) spansHd.textContent=`Span por ${lc(lvl.label)} (CBs) — base + entrantes`;\n  if(mv.view==='asis'){const items=[{s:mv.sector,val:sectorTotalCbs(mv),color:CLOSE_COLOR}].concat(mv.dest.map(s=>({s,val:+(mv.baseMetrics[s]?.cbs||0),color:mv.color[s]})));\n    const max=Math.max(...items.map(x=>x.val),1);\n    $('#spans').innerHTML=items.map(x=>`<div class=\"bar-row\"><div class=\"tag\" style=\"color:${x.color}\">${x.s}</div><div class=\"bar-track\">\n      <div class=\"bar-inc\" style=\"width:${x.val/max*100}%;background:${x.color}\"></div></div>\n      <div class=\"bar-val\">${fmt(x.val)}</div></div>`).join('')\n      +`<div class=\"note\">Estrutura antes desta etapa — ${lvl.a} ${lc(lvl.label)} <b>${mv.sector}</b> ainda existe. Alterne pra <b>TOBE</b> pra ver a redistribuição.</div>`;\n    return;}\n  const lim=spanLimits(mv),on=spanLimitsOn(lim);\n  const rawMax=Math.max(...mv.dest.map(s=>c.span[s]),lim.target||0,lim.max||0,1);\n  const max=on?rawMax*1.04:rawMax;   // headroom so a marker sitting at the scale top stays visible\n  const marks='';\n  // A manual município transfer (mv.kind==='transfer') patches mv.baseMetrics[to]/[from] directly\n  // (computeTransferMovement) instead of going through mv.munis/mv.assign — so c.inc[s] (this\n  // stage's own geographic redistribution) never sees it, and the transfer's gain/loss silently\n  // disappeared into \"base\" as if it had already existed before this very stage (always \"+0\" here,\n  // even though this exact stage is the transfer). xferDeltaOf surfaces it as THIS stage's own\n  // +/- instead, exactly like c.inc already does for a normal closing movement.\n  const xferDeltaOf=s=>{ if(mv.kind!=='transfer') return null; if(s===mv.xferTo) return +mv.xferMetrics.cbs; if(s===mv.xferFrom) return -mv.xferMetrics.cbs; return 0; };\n  $('#spans').innerHTML=mv.dest.map(s=>{\n    const xd=xferDeltaOf(s);\n    const delta=xd==null?c.inc[s]:xd;\n    // Only ever highlight a genuine GAIN as the filled \"entrante\" segment — a transfer's LOSS still\n    // shows correctly in the total and in its own (-X) label, but stretching the pale \"base\" segment\n    // past the sector's real current size to depict a shrink would draw a bigger bar for a smaller\n    // number, more confusing than simply not drawing a segment for it at all.\n    const shownInc=xd==null?c.inc[s]:Math.max(xd,0);\n    const base=c.span[s]-shownInc;\n    const over=lim.max&&c.span[s]>lim.max,under=lim.min&&c.span[s]<lim.min;\n    return `<div class=\"bar-row\"><div class=\"tag\" style=\"color:${mv.color[s]}\">${s}</div><div class=\"bar-track\">\n      <div class=\"bar-base\" style=\"width:${base/max*100}%;background:${mv.color[s]}\"></div>\n      <div class=\"bar-inc${xd!=null&&xd>0?' xfer':''}\" style=\"width:${shownInc/max*100}%;background:${mv.color[s]}\"></div>${marks}</div>\n      <div class=\"bar-val\" style=\"${over?'color:var(--closing);font-weight:700':(under?'color:#ff9500;font-weight:700':'')}\">${fmt(c.span[s])} <span style=\"opacity:.6\">(${delta>=0?'+':''}${fmt(delta)})</span>${over?' ▲':''}${under?' ▼':''}</div></div>`;}).join('')\n    +`<div class=\"note\">Claro = base (já incluindo etapas anteriores) · cheio = CBs entrantes ${lvl.de} ${mv.sector} nesta etapa.${mv.kind==='transfer'?' Nesta etapa, a faixa <b>listrada</b> é a transferência manual de município (perda no setor de origem aparece só no rótulo, sem faixa).':''}${c.orphanTotal?` Inclui <b>${fmt(c.orphanTotal-c.orphanStranded)}</b> CB(s) de municípios fora da base de dominantes que seguiram suas líderes.`:''}</div>`;\n}\n// Two very different reasons land rows in the __OUTROS__ bucket — no IBGE shape, or outside the\n// sector's dominance base. Same bucket computationally, but they must not read as the same thing.\nfunction outsideNote(mv){\n  const bits=[];\n  if(mv.noShapeCbs) bits.push(`<b>${fmt(mv.noShapeCbs)}</b> CBs em ${fmt(mv.noShapeMunis.length)} município(s) sem shape na UF`);\n  if(mv.excludedCbs) bits.push(`<b>${fmt(mv.excludedCbs)}</b> CBs em ${fmt(mv.excludedMunis.length)} município(s) <b>fora da base de dominantes</b> deste setor — <b style=\"color:var(--closing)\">quebra de vínculo</b>`);\n  if(!bits.length) return '';\n  let h=`<div class=\"note\">Fora do mapa e da alocação geográfica: ${bits.join(' · ')}.`;\n  if(mv.excludedCbs){\n    const c=computeDerived(mv);\n    h+=` As <b>${fmt(mv.excludedCbs)}</b> CBs em quebra de vínculo <b>seguem a líder do seu grupo</b>`;\n    const byS=mv.dest.filter(s=>c.orph[s]).map(s=>`<b style=\"color:${mv.color[s]}\">${s}</b> (${fmt(c.orph[s])})`);\n    h+=byS.length?`: ${byS.join(' · ')}.`:'.';\n    if(c.orphanStranded) h+=` <b style=\"color:var(--closing)\">${fmt(c.orphanStranded)} CB(s) sem destino</b> — a líder não ficou com nenhum município realocado, então não há setor pra seguir.`;\n  }\n  if(mv.noShapeCbs) h+=` As CBs sem shape na UF continuam fora de qualquer setor destino.`;\n  if(mv.excludedMunis&&mv.excludedMunis.length) h+=`<br><span style=\"opacity:.85\">${mv.excludedMunis.slice(0,8).map(x=>x.muni+' ('+fmt(x.cbs)+')').join(', ')}${mv.excludedMunis.length>8?' +'+(mv.excludedMunis.length-8):''}</span>`;\n  return h+`</div>`;\n}\nlet PULL_SET=new WeakSet();\nlet PULL_CTX=null;\nlet PULL_LOADING=new WeakSet();\nfunction pullModeOn(mv){ return !!mv && PULL_SET.has(mv); }\nasync function loadNbrCbs(mv){\n  const secs=[...new Set(Object.values(mv.nbrCurrentSector||{}).filter(Boolean))];\n  const out={};\n  if(secs.length){\n    const sql=`SELECT t.DS_MUNICIPIO municipio, t.DS_ESTADO uf, t.CD_SETOR setor, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs\n      FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n      WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR IN (${secs.join(',')}) AND t.DT_HISTORICO=${SESS.snapLiteral}\n      GROUP BY t.DS_MUNICIPIO, t.DS_ESTADO, t.CD_SETOR`;\n    const recs=rows(await q(sql));\n    recs.forEach(r=>{\n      const ru=r.uf||DOM.ufBySetorMuni[String(r.setor)+'|'+muniN(r.municipio)]||'';\n      const k=mkey(r.municipio,ru);\n      out[k]=(out[k]||0)+ +r.cbs;\n    });\n  }\n  mv.nbrCbsByMuni=out;\n}\nasync function togglePullMode(){\n  const mv=SESS.timeline[SESS.selected]; if(!mv||(mv.level||'setor')!=='setor'||PULL_LOADING.has(mv)) return;\n  if(PULL_SET.has(mv)){ PULL_SET.delete(mv); drawMap(); renderMuni(); return; }\n  PULL_SET.add(mv);\n  if(!mv.nbrCbsByMuni){\n    PULL_LOADING.add(mv); renderMuni();\n    try{ await loadNbrCbs(mv); }catch(e){}\n    finally{ PULL_LOADING.delete(mv); }\n  }\n  drawMap();\n  renderMuni();\n}\nfunction closePullConfirm(){ const b=$('#pullBg'); if(b) b.classList.add('hidden'); PULL_CTX=null; }\nfunction openPullConfirm(fromSector,muniKey){\n  const mv=SESS.timeline[SESS.selected]; if(!mv) return;\n  PULL_CTX={from:String(fromSector),muni:muniKey};\n  const info=$('#pullMuniInfo'); if(info) info.innerHTML=`<b>${mDisp(muniKey)}</b> — hoje ${lc(LEVELS[mv.level||'setor'].destLabel)} <b>${esc(String(fromSector))}</b>.`;\n  const seg=$('#pullDestSeg');\n  if(seg){\n    seg.innerHTML=mv.dest.map((s,i)=>`<button data-s=\"${s}\" class=\"${i===0?'on':''}\" style=\"${i===0?'background:'+mv.color[s]:''}\">${s}</button>`).join('');\n    seg.querySelectorAll('button').forEach(b=>b.onclick=()=>{\n      seg.querySelectorAll('button').forEach(x=>{x.classList.remove('on');x.style.background='';});\n      b.classList.add('on'); b.style.background=mv.color[b.dataset.s]||'';\n    });\n  }\n  const st=$('#pullStatus'); if(st) st.textContent='';\n  const bg=$('#pullBg'); if(bg) bg.classList.remove('hidden');\n  const cx=$('#pullClose'); if(cx) cx.onclick=closePullConfirm;\n  const cc=$('#pullCancel'); if(cc) cc.onclick=closePullConfirm;\n  const go=$('#pullGo'); if(go){ go.disabled=false; go.textContent='Aplicar'; go.onclick=confirmPull; }\n  if(cc) cc.focus();\n}\nasync function confirmPull(){\n  if(!PULL_CTX) return;\n  const onBtn=$('#pullDestSeg')&&$('#pullDestSeg').querySelector('button.on');\n  const to=onBtn&&onBtn.dataset.s; if(!to) return;\n  const go=$('#pullGo'); if(go){ go.disabled=true; go.textContent='Aplicando…'; }\n  try{\n    await applyTransfer(PULL_CTX.from,to,PULL_CTX.muni);\n    closePullConfirm();\n    await flushDirty();\n    renderAll();\n  }catch(e){\n    const st=$('#pullStatus'); if(st) st.textContent='erro: '+e.message;\n    if(go){ go.disabled=false; go.textContent='Aplicar'; }\n  }\n}\nfunction renderMuni(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const lvl=LEVELS[mv.level||'setor'];\n  const plural=lvl.childLabel==='Setor'?'Setores':(lvl.childLabel==='GV'?'GVs':'Municípios');\n  const fem=lvl.childLabel==='GV';\n  const t=$('#muniListTitle'); if(t) t.textContent=`${plural} → ${lc(lvl.destLabel)} destino`;\n  const nt=$('#muniListNote'); if(nt) nt.innerHTML=`Clique no <b>código ${lvl.a} ${lc(lvl.destLabel)}</b> pra mandar ${lvl.a} ${lc(lvl.childLabel)} <b>inteir${fem?'a':'o'}</b> pra lá (regra de negócio). A cor acompanha o mapa.`;\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  if(mv.view==='asis'){$('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"muni-item-hd\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(mv.munis[m].cbs)} CBs</div></div><span class=\"badge\" style=\"background:${CLOSE_COLOR};color:#fff;border-color:${CLOSE_COLOR}\">${lc(lvl.label)} ${mv.sector}</span></div>`).join('')\n      +`<div class=\"note\">Hoje tod${fem?'as':'os'} pertencem ${lvl.a} ${lc(lvl.label)} <b>${mv.sector}</b>. No <b>TOBE</b> cada um vira botões de destino.</div>`+outsideNote(mv);return;}\n  const xferBlock=(mv.kind==='transfer')?(\n    `<div class=\"note\" style=\"margin-top:10px;font-weight:600;color:var(--ink)\">Municípios do setor ${mv.xferFrom}</div>\n    <div class=\"muni-item\"><div class=\"muni-item-hd\"><div class=\"nm\">${mDisp(mv.xferMuni)}</div><div class=\"ct\">${fmt(mv.xferMetrics.cbs)} CBs</div></div>\n      <div class=\"seg seg-xfer\">${mv.dest.map(s=>`<button data-s=\"${s}\" class=\"${mv.xferTo===s?'on':''}\" style=\"${mv.xferTo===s?'background:'+mv.color[s]:''}\">${s}</button>`).join('')}</div>\n    </div>`\n  ):'';\n  $('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"muni-item-hd\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(mv.munis[m].cbs)} CBs</div></div>\n    <div class=\"seg\" data-m=\"${m}\">${mv.dest.map(s=>`<button data-s=\"${s}\" class=\"${mv.assign[m]===s?'on':''}\" style=\"${mv.assign[m]===s?'background:'+mv.color[s]:''}\">${s}</button>`).join('')}</div></div>`).join('')\n    +xferBlock+outsideNote(mv);\n  $('#muniList').querySelectorAll('.seg:not(.seg-xfer) button').forEach(b=>b.onclick=()=>{const m=b.parentElement.dataset.m,s=b.dataset.s;editSelected(m2=>{m2.assign[m]=s;});});\n  $('#muniList').querySelectorAll('.seg-xfer button').forEach(b=>b.onclick=()=>setTransferTarget(b.dataset.s));\n  const tb=$('#togglePull'); if(tb){ const isSetor=(mv.level||'setor')==='setor'; const loading=PULL_LOADING.has(mv);\n    tb.classList.toggle('hidden',!isSetor); tb.classList.toggle('on',isSetor&&pullModeOn(mv));\n    tb.disabled=loading; tb.innerHTML=loading?'<span class=\"spinning\">◌</span>':'✎'; tb.onclick=togglePullMode; }\n}\nfunction renderLeaders(){const mv=SESS.timeline[SESS.selected]; if(!mv){ renderXferLeaders(null); return; } const c=computeDerived(mv);\n  const showOrph=c.orphanTotal>0;\n  const lvl=LEVELS[mv.level||'setor'];\n  const titleEl=$('#leadersTitle'), noteEl=$('#leadersNote');\n  if((mv.level||'setor')!=='setor'){\n    // GV/mercado: quem troca de dono é o setor/GV inteiro (mv.assign), nunca a líder isolada — o\n    // grão que importa aqui é MUNICÍPIO × UNIDADE (a mesma matriz de renderMatrix/renderMatrixTobe),\n    // não líder × unidade: um município com CBs em mais de uma unidade (compartilhado entre setores\n    // da mesma GV) já é o caso que esta tabela existe pra revelar, e não depende de ASIS/TOBE — é\n    // posse ATUAL, a mesma nos dois modos.\n    if(titleEl) titleEl.textContent=`Municípios por ${lc(lvl.childLabel)} (nesta etapa)`;\n    if(noteEl) noteEl.innerHTML=`Cada linha é um município; os blocos são o(s) ${lc(lvl.childLabel)}(s) onde ele tem CBs, largura proporcional ao total do bloco — mais de um bloco já é compartilhamento entre ${lc(lvl.childLabel)}s.`;\n    const units=Object.keys(mv.unitMunis||{}).sort((a,b)=>(mv.munis[b]?.cbs||0)-(mv.munis[a]?.cbs||0));\n    const ms=Object.keys(mv.geoFeat||{}).sort((a,b)=>(mv.cbsByMuni[b]||0)-(mv.cbsByMuni[a]||0));\n    if(!units.length||!ms.length){\n      $('#leaders').innerHTML=`<tr><td class=\"note\" style=\"padding:10px\">Nenhum ${lc(lvl.childLabel)} com município desenhado nesta etapa.</td></tr>`;\n      renderXferLeaders(mv);\n      return;\n    }\n    const unitOfRow=r=>mv.level==='gv'?String(r.setor):(DOM.gvBySetor[String(r.setor)]||'');\n    const cellBy={};\n    mv.matrix.forEach(r=>{ const m=mkey(r.municipio,r.uf); if(!mv.geoFeat[m]) return; const u=unitOfRow(r); if(!u) return;\n      cellBy[m+'|'+u]=(cellBy[m+'|'+u]||0)+ +r.cbs; });\n    const cell=(m,u)=>cellBy[m+'|'+u]||0;\n    const mx=Math.max(...ms.flatMap(m=>units.map(u=>cell(m,u))),1);\n    // Uma coluna por unidade virava uma grade enorme e quase toda vazia — a esmagadora maioria dos\n    // municípios pertence a UMA única unidade. Em vez de coluna fixa, cada linha carrega só os\n    // blocos das unidades onde aquele município REALMENTE tem CBs (segsFor), largura proporcional\n    // (flex-grow=v, base 0) — um compartilhamento entre unidades vira mais de um bloco na mesma\n    // linha, sem precisar de coluna nem seção própria pra revelar o caso.\n    const segsFor=m=>units.map(u=>({u,v:cell(m,u)})).filter(s=>s.v>0).sort((a,b)=>b.v-a.v);\n    $('#leaders').innerHTML=`<thead><tr><th>Município</th><th style=\"text-align:left\">${lvl.childLabel}(s) — CBs</th><th>Tot</th></tr></thead><tbody>`\n      +ms.map(m=>{\n        const tot=mv.cbsByMuni[m]||0; const segs=segsFor(m);\n        const segsHtml=segs.length\n          ?`<div style=\"display:flex;gap:2px;min-height:30px\">`\n            +segs.map(({u,v})=>{const pct=tot?Math.round(v/tot*100):0;\n              return `<div style=\"flex:${v} 0 0%;min-width:2px;background:rgba(229,181,63,${.15+v/mx*.5});border-radius:4px;display:flex;flex-direction:column;align-items:center;justify-content:center;overflow:hidden;padding:2px 4px;font-size:10.5px;line-height:1.25;white-space:nowrap\" title=\"${lvl.childLabel} ${u} · ${fmt(v)} CBs (${pct}%)\"><b>${u}</b><span style=\"opacity:.65\">${fmt(v)} (${pct}%)</span></div>`;\n            }).join('')\n          +`</div>`\n          :`<span style=\"color:var(--mut)\">·</span>`;\n        return `<tr><td>${m}</td><td>${segsHtml}</td><td style=\"font-weight:700\">${fmt(tot)}</td></tr>`;\n      }).join('')\n      +`<tr style=\"font-weight:700\"><td>TOTAL</td><td></td><td>${fmt(ms.reduce((a,m)=>a+(mv.cbsByMuni[m]||0),0))}</td></tr>`\n      +`</tbody>`;\n    renderXferLeaders(mv);\n    return;\n  }\n  if(titleEl) titleEl.textContent='Líderes — destino e CBs deslocadas (nesta etapa)';\n  if(noteEl) noteEl.innerHTML=`<b>Home</b> = setor que ficou com a maioria das CBs da líder. <b style=\"color:var(--closing)\">Deslocadas</b> = CBs dela que caíram noutro setor.`;\n  if(mv.view==='asis'){$('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>Grupo</th><th>CBs</th><th>${LEVELS[mv.level||'setor'].label}</th></tr></thead><tbody>`\n    +mv.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${L.grupo}</td><td>${fmt(L.total)}</td><td><span class=\"home\" style=\"background:${CLOSE_COLOR}\"></span>${mv.sector}</td></tr>`).join('')\n    +`<tr style=\"font-weight:700\"><td>TOTAL</td><td></td><td>${fmt(mv.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td></tr>`+`</tbody>`;renderXferLeaders(mv);return;}\n  $('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>CBs</th><th>Home</th>${mv.dest.map(s=>`<th>${s}</th>`).join('')}${showOrph?'<th title=\"CBs em municípios fora da base de dominantes que seguiram esta líder\">Fora do setor</th>':''}<th>Desloc.</th></tr></thead><tbody>`\n   +c.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${fmt(L.total)}</td>\n     <td>${L.home?`<span class=\"home\" style=\"background:${mv.color[L.home]}\"></span>${L.home}`:'–'}</td>\n     ${mv.dest.map(s=>`<td class=\"cell\" style=\"${s===L.home?'color:'+mv.color[s]+';font-weight:700':''}\">${L.byS[s]||'·'}</td>`).join('')}\n     ${showOrph?`<td class=\"cell\" style=\"${L.orphan&&!L.orphanHome?'color:var(--closing);font-weight:700':''}\" title=\"${L.orphan?(L.orphanHome?'seguem a líder para o setor '+L.orphanHome:'sem home — CBs sem setor pra seguir'):''}\">${L.orphan?fmt(L.orphan)+(L.orphanHome?'':' ⚠'):'·'}</td>`:''}\n     <td style=\"color:${L.desloc?'var(--closing)':'var(--mut)'};font-weight:${L.desloc?700:400}\">${L.desloc||'0'}</td></tr>`).join('')\n   +`<tr style=\"font-weight:700\"><td>TOTAL</td><td>${fmt(c.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td>${mv.dest.map(s=>`<td>${fmt(c.leaders.reduce((a,L)=>a+L.byS[s],0))}</td>`).join('')}${showOrph?`<td>${fmt(c.orphanTotal)}</td>`:''}<td style=\"color:var(--closing)\">${fmt(c.desloc)}</td></tr>`+`</tbody>`;\n  renderXferLeaders(mv);\n}\nfunction renderXferLeaders(mv){\n  const block=$('#leadersXferBlock'); if(!block) return;\n  const hasXfer = mv && mv.kind==='transfer' && mv.xferRows && mv.xferRows.length;\n  block.classList.toggle('hidden', !hasXfer);\n  if(!hasXfer) return;\n  const title=$('#leadersXferTitle');\n  if(title) title.textContent=`Transferência manual — ${mDisp(mv.xferMuni)} (${mv.xferFrom} → ${mv.xferTo})`;\n  $('#leadersXfer').innerHTML=`<thead><tr><th>Líder</th><th>CBs</th><th>Grupo</th></tr></thead><tbody>`\n    +mv.xferRows.map(r=>`<tr><td>${esc((r.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+r.grupo))}</td><td>${fmt(r.cbs)}</td><td>${r.grupo}</td></tr>`).join('')\n    +`</tbody>`;\n}\nfunction renderXferBreakdown(mv, tableId, blockId, titleId){\n  const block=$('#'+blockId); if(!block) return;\n  const hasXfer = mv && mv.kind==='transfer' && mv.xferRows && mv.xferRows.length;\n  block.classList.toggle('hidden', !hasXfer);\n  if(!hasXfer) return;\n  const title=$('#'+titleId);\n  if(title) title.textContent=`Transferência manual — ${mDisp(mv.xferMuni)} (${mv.xferFrom} → ${mv.xferTo})`;\n  const rowsData=mv.xferRows;\n  const mx=Math.max(...rowsData.map(r=>+r.cbs||0),1);\n  const short=r=>(r.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+r.grupo);\n  $('#'+tableId).innerHTML=`<thead><tr><th>Município</th>${rowsData.map(r=>`<th title=\"${esc(r.lider||'')}\">${r.grupo}<br><span style=\"font-weight:400;font-size:10px;color:var(--mut)\">${esc(short(r))}</span></th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n    +`<tr><td>${mDisp(mv.xferMuni)}</td>`\n    +rowsData.map(r=>{const v=+r.cbs||0; return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v||'·'}</td>`;}).join('')\n    +`<td style=\"font-weight:700\">${fmt(mv.xferMetrics.cbs)}</td></tr></tbody>`;\n}\nfunction renderMatrix(){const mv=SESS.timeline[SESS.selected]; if(!mv) return;\n  // GV/mercado: a unidade que troca de dono é setor/GV, não município — então esta matriz cruza\n  // MUNICÍPIO × UNIDADE (setor fechando GV, GV fechando mercado) em vez de município × grupo.\n  // mv.matrix já carrega r.setor em toda linha (a query do CRM sempre pediu isso), então dá pra\n  // derivar a unidade de cada linha sem nenhuma consulta nova — unitOfRow abaixo é a mesma tradução\n  // setor→GV que resolveUnit/aggregateMovement já usam pra este grão.\n  if((mv.level||'setor')!=='setor'){\n    const lvl=LEVELS[mv.level];\n    const units=Object.keys(mv.unitMunis||{}).sort((a,b)=>(mv.munis[b]?.cbs||0)-(mv.munis[a]?.cbs||0));\n    const ms=Object.keys(mv.geoFeat||{}).sort((a,b)=>(mv.cbsByMuni[b]||0)-(mv.cbsByMuni[a]||0));\n    if(!units.length||!ms.length){ $('#matrix').innerHTML=`<tr><td class=\"note\" style=\"padding:10px\">Nenhum ${lc(lvl.childLabel)} com município desenhado nesta etapa.</td></tr>`; renderXferBreakdown(null,'matrixXfer','matrixXferBlock','matrixXferTitle'); return; }\n    const unitOfRow=r=>mv.level==='gv'?String(r.setor):(DOM.gvBySetor[String(r.setor)]||'');\n    const cellBy={};\n    mv.matrix.forEach(r=>{ const m=mkey(r.municipio,r.uf); if(!mv.geoFeat[m]) return; const u=unitOfRow(r); if(!u) return;\n      cellBy[m+'|'+u]=(cellBy[m+'|'+u]||0)+ +r.cbs; });\n    const cell=(m,u)=>cellBy[m+'|'+u]||0;\n    const mx=Math.max(...ms.flatMap(m=>units.map(u=>cell(m,u))),1);\n    $('#matrix').innerHTML=`<thead><tr><th>Município</th>${units.map(u=>`<th>${lvl.childLabel} ${u}</th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n      +ms.map(m=>{const tot=mv.cbsByMuni[m]||0; return `<tr><td>${m}</td>`\n        +units.map(u=>{const v=cell(m,u);const pct=tot?Math.round(v/tot*100):0;return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v?`${fmt(v)} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n        +`<td style=\"font-weight:700\">${fmt(tot)}</td></tr>`;}).join('')\n      +`<tr style=\"font-weight:700\"><td>TOTAL</td>${units.map(u=>`<td>${fmt(ms.reduce((a,m)=>a+cell(m,u),0))}</td>`).join('')}<td>${fmt(ms.reduce((a,m)=>a+(mv.cbsByMuni[m]||0),0))}</td></tr>`\n      +`</tbody>`;\n    renderXferBreakdown(null,'matrixXfer','matrixXferBlock','matrixXferTitle');\n    return;\n  }\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  // `_out` is a flag stamped on the ROW, and rows are shared between etapas (absorbed + inherited\n  // orphans), so the last etapa to aggregate wins and reading it here can describe the wrong etapa.\n  // Derive the bucket from THIS mv instead. Summing rather than .find() because several out-municípios\n  // of the same group all collapse into __OUTROS__, and only the first was being counted.\n  const outOf=x=>!mv.geoFeat[mkey(x.municipio,x.uf)];\n  const cell=(m,g)=>mv.matrix.reduce((a,x)=>a+((((outOf(x)?'__OUTROS__':mkey(x.municipio,x.uf))===m)&&String(x.grupo)===g)?+x.cbs:0),0);\n  const mx=Math.max(...ms.flatMap(m=>mv.leaders.map(L=>cell(m,L.grupo))),1);\n  const short=L=>(L.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+L.grupo);\n  // No destination column/home dot here (unlike TOBE). This is the AS-IS matrix: every município in\n  // it still belongs to the closing sector, so showing where it is HEADED mixed the two states of\n  // the world in one table and made the ASIS view read like a plan. The destination lives on the\n  // map and in \"Municípios → setor destino\", which are about the to-be.\n  $('#matrix').innerHTML=`<thead><tr><th>Município | Grupo | Líder</th>${mv.leaders.map(L=>`<th title=\"${esc(L.lider||'')}\">${L.grupo}<br><span style=\"font-weight:400;font-size:10px;color:var(--mut)\">${esc(short(L))}</span></th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +ms.map(m=>{const tot=mv.munis[m].cbs; return `<tr><td>${m}</td>`\n     +mv.leaders.map(L=>{const v=cell(m,L.grupo);const pct=L.total?Math.round(v/L.total*100):0;return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v?`${v} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(tot)}</td></tr>`;}).join('')\n   +`<tr class=\"lider-foot\"><td>Municípios fora do setor</td>`\n     +mv.leaders.map(L=>{const v=(mv.foraDoSetorByGrupo&&mv.foraDoSetorByGrupo[L.grupo])||0;const pct=L.total?Math.round(v/L.total*100):0; return `<td title=\"CBs desta líder (dado do Databricks) em município(s) que a base de dominantes (dominantesCsvUrl) não atribui ao setor ${mv.sector}\">${v?`${fmt(v)} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n     +`<td></td></tr>`\n   +`</tbody>`;\n  renderXferBreakdown(mv,'matrixXfer','matrixXferBlock','matrixXferTitle');\n}\n// TOBE counterpart of the AS-IS matrix: the same município × grupo grid plus the two columns the AS-IS\n// view deliberately refuses to carry — where the município ends up, and under which líder.\n// \"Líder resultante\" is the líder holding the most CBs in that município. A município is served by several\n// grupos at once and the data names no single owner, so the dominant one is the honest answer and its\n// share is printed beside it instead of being hidden behind a single name.\nfunction renderMatrixTobe(){const mv=SESS.timeline[SESS.selected]; const el=$('#matrixTobe'); const nt=$('#tobeNote');\n  if(!el) return;\n  if(!mv||!mv.munis){ el.innerHTML=''; if(nt) nt.innerHTML=''; renderXferBreakdown(null,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle'); return; }\n  if((mv.level||'setor')!=='setor'){\n    // Mesma matriz município × unidade do ASIS (renderMatrix), mais o que o TOBE sempre acrescenta:\n    // pra onde cada linha/coluna vai. Aqui o destino é simples pelo motivo oposto do setor-level: não\n    // há \"líder dominante\" a inferir — cada unidade (coluna) já tem UM destino fixo (mv.assign[u]),\n    // então o próprio município herda esse destino de mv.unitOfMuni sem precisar de heurística nenhuma.\n    const lvl=LEVELS[mv.level];\n    const units=Object.keys(mv.unitMunis||{}).sort((a,b)=>(mv.munis[b]?.cbs||0)-(mv.munis[a]?.cbs||0));\n    const ms=Object.keys(mv.geoFeat||{}).sort((a,b)=>(mv.cbsByMuni[b]||0)-(mv.cbsByMuni[a]||0));\n    if(!units.length||!ms.length){ el.innerHTML=`<tr><td class=\"note\" style=\"padding:10px\">Nenhum ${lc(lvl.childLabel)} com município desenhado nesta etapa.</td></tr>`; if(nt) nt.innerHTML=''; renderXferBreakdown(null,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle'); return; }\n    const unitOfRow=r=>mv.level==='gv'?String(r.setor):(DOM.gvBySetor[String(r.setor)]||'');\n    const cellBy={};\n    mv.matrix.forEach(r=>{ const m=mkey(r.municipio,r.uf); if(!mv.geoFeat[m]) return; const u=unitOfRow(r); if(!u) return;\n      cellBy[m+'|'+u]=(cellBy[m+'|'+u]||0)+ +r.cbs; });\n    const cell=(m,u)=>cellBy[m+'|'+u]||0;\n    const mx=Math.max(...ms.flatMap(m=>units.map(u=>cell(m,u))),1);\n    el.innerHTML=`<thead><tr><th>Município</th>${units.map(u=>`<th>${lvl.childLabel} ${u}</th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n      +ms.map(m=>{const tot=mv.cbsByMuni[m]||0; const uOf=mv.unitOfMuni[m]; const dest=uOf?mv.assign[uOf]:null;\n        return `<tr><td>${dest?`<span class=\"home\" style=\"background:${mv.color[dest]}\"></span>`:''}${m}</td>`\n        +units.map(u=>{const v=cell(m,u);const pct=tot?Math.round(v/tot*100):0;return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v?`${fmt(v)} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n        +`<td style=\"font-weight:700\">${fmt(tot)}</td></tr>`;}).join('')\n      +`<tr class=\"lider-foot\"><td>Destino</td>`\n        +units.map(u=>{const dest=mv.assign[u]; return `<td>${dest?`<span class=\"home\" style=\"background:${mv.color[dest]}\"></span>${dest}`:'–'}</td>`;}).join('')\n        +`<td></td></tr>`\n      +`</tbody>`;\n    if(nt) nt.innerHTML=`<b>Destino</b> (rodapé) = pra onde vai cada ${lc(lvl.childLabel)} inteiro (coluna) — fixo pra todos os municípios dele, já que quem troca de dono é o ${lc(lvl.childLabel)}, não o município.`;\n    renderXferBreakdown(null,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle');\n    return;\n  }\n  const c=computeDerived(mv);\n  const homeOf={}; c.leaders.forEach(L=>{ homeOf[L.grupo]=L.home; });\n  const homeCbsOf={}; c.leaders.forEach(L=>{ homeCbsOf[L.grupo]=(L.home&&L.total)?Math.round((L.byS[L.home]||0)/L.total*100):0; });\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  // One pass over the matrix instead of a scan per cell: the AS-IS table can afford the naive version\n  // on six municípios, but this one asks for the dominant líder too, which multiplies the lookups.\n  const outOf=x=>!mv.geoFeat[mkey(x.municipio,x.uf)];\n  const cellBy={}; mv.matrix.forEach(x=>{ const k=(outOf(x)?'__OUTROS__':mkey(x.municipio,x.uf))+'|'+String(x.grupo);\n    cellBy[k]=(cellBy[k]||0)+ +x.cbs; });\n  const cell=(m,g)=>cellBy[m+'|'+g]||0;\n  const mx=Math.max(...ms.flatMap(m=>mv.leaders.map(L=>cell(m,L.grupo))),1);\n  const domLeader=m=>{ let best=null,bc=0;\n    mv.leaders.forEach(L=>{ const v=cell(m,L.grupo); if(v>bc){ bc=v; best=L; } });\n    return best?{L:best,cbs:bc}:null; };\n  const short=L=>(L.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+L.grupo);\n  // NOT named `rows` — that is the module-level Databricks response helper, and shadowing it inside a\n  // render function is a trap for whoever adds a query here later.\n  // Off-mismatch ainda calculado pra alimentar a nota abaixo da tabela — só não vira mais coluna: o\n  // destino agora é \"Setor destino\" por GRUPO no rodapé, não mais por município na grade.\n  const recs=ms.map(m=>{ const d=mv.assign[m]||null, dl=domLeader(m);\n    const home=dl?(homeOf[dl.L.grupo]||null):null;\n    return {m:m,off:!!(dl&&home&&d&&home!==d)}; });\n  el.innerHTML=`<thead><tr><th>Município | Grupo | Líder</th>${mv.leaders.map(L=>`<th title=\"${esc(L.lider||'')}\">${L.grupo}<br><span style=\"font-weight:400;font-size:10px;color:var(--mut)\">${esc(short(L))}</span></th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +ms.map(m=>`<tr><td><span class=\"home\" style=\"background:${mv.color[mv.assign[m]]||'#ccc'}\"></span>${m}</td>`\n     +mv.leaders.map(L=>{const v=cell(m,L.grupo);const pct=L.total?Math.round(v/L.total*100):0;return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v?`${v} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(mv.munis[m].cbs)}</td></tr>`).join('')\n   // Um rótulo de destino por GRUPO (coluna), não por município (linha) — cada grupo tem UM home\n   // fixo (computeDerived já calculou), então ele mora uma vez no rodapé, igual o nome já morava.\n   +`<tr class=\"lider-foot\"><td>Municípios fora do setor</td>`\n     +mv.leaders.map(L=>{const v=(mv.foraDoSetorByGrupo&&mv.foraDoSetorByGrupo[L.grupo])||0;const pct=L.total?Math.round(v/L.total*100):0; return `<td title=\"CBs desta líder (dado do Databricks) em município(s) que a base de dominantes (dominantesCsvUrl) não atribui ao setor ${mv.sector}\">${v?`${fmt(v)} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n     +`<td></td></tr>`\n   +`<tr class=\"lider-foot\"><td>Setor destino</td>`\n     +mv.leaders.map(L=>{const h=homeOf[L.grupo]; return `<td>${h?`<span class=\"home\" style=\"background:${mv.color[h]}\"></span>${h}`:'–'}</td>`;}).join('')\n     +`<td></td></tr>`\n   +`<tr class=\"lider-foot\"><td>CBs dentro do setor</td>`\n     +mv.leaders.map(L=>{const h=homeOf[L.grupo]; const v=homeCbsOf[L.grupo]||0; return `<td>${h?v+'%':'–'}</td>`;}).join('')\n     +`<td></td></tr>`     +`</tbody>`;\n  renderXferBreakdown(mv,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle');\n  if(nt){\n    const offN=recs.filter(r=>r.off).length;\n    nt.innerHTML=(mv.policy!=='fora')\n      ? `Política <b>quebrar relação</b>: as CBs deixam a líder atual e passam à estrutura do setor destino — o rodapé mostra pra onde cada grupo vai.`\n      : `<b>Setor destino</b> = o setor que recebe a maior parte das CBs de cada líder (rodapé, uma vez por grupo). `\n        +(offN?`<span style=\"color:var(--closing)\">${fmt(offN)} município(s) ⚠</span> têm a líder dominante indo para um setor diferente do dela — as CBs seguem a líder (é disso que o KPI “CBs fora do setor” é feito).`\n          :`Em todos os municípios a líder dominante termina no mesmo setor que recebe o território.`);\n  }\n}\nfunction renderPolicy(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  // O toggle fora-do-setor/quebra-de-relação é uma decisão de setor fechando — em GV/mercado a\n  // seção some inteira em vez de mostrar um controle que não corresponde a nada calculado.\n  const sec=$('#policySection'); if(sec) sec.classList.toggle('hidden',(mv.level||'setor')!=='setor');\n  if((mv.level||'setor')!=='setor') return;\n  $('#policyNote').innerHTML=mv.policy==='fora'\n    ?`<b style=\"color:var(--acc)\">Mantendo vínculo:</b> ${fmt(c.desloc)} CBs seguem com a líder mas ficam <b>fisicamente noutr${lvl.a==='à'?'a':'o'} ${lc(lvl.destLabel)}</b>. Preserva relação; suja geografia.`\n    :`<b style=\"color:var(--acc)\">Quebrando vínculo:</b> ${fmt(c.desloc)} CBs <b>re-vinculam a uma líder d${lvl.a==='à'?'a':'o'} ${lc(lvl.destLabel)} destino</b>. Geografia limpa; rompe relação.`;\n}\nfunction renderPlan(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor']; const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>a.localeCompare(b));\n  // GV/mercado: a chave \"m\" aqui é um código de setor/GV, não uma chave município/UF — mName/mUf\n  // parseariam lixo, então o CSV de plano exporta o código cru nesses níveis.\n  mv._plan=ms.map(m=>(mv.level&&mv.level!=='setor')?{municipio:m,uf:'',cbs:mv.munis[m].cbs,destino:mv.assign[m]}:{municipio:mName(m),uf:mUf(m),cbs:mv.munis[m].cbs,destino:mv.assign[m]});\n  $('#plan').innerHTML=`Etapa ${SESS.selected+1} · ${lvl.label} <b>${mv.sector}</b> → ${ms.map(m=>`${m}→<b style=\"color:${mv.color[mv.assign[m]]}\">${mv.assign[m]||'–'}</b>`).join(' · ')}<br>\n   Política: <b>${mv.policy==='fora'?`manter vínculo (CBs fora ${lvl.de} ${lc(lvl.destLabel)})`:'quebrar relação'}</b> · ${fmt(c.desloc)} CBs impactadas nesta etapa.`\n   +(c.orphanTotal?`<br>Fora da base de dominantes: ${fmt(c.orphanTotal)} CB(s) seguem suas líderes — ${mv.dest.filter(s=>c.orph[s]).map(s=>`${s} (${fmt(c.orph[s])})`).join(' · ')||'–'}${c.orphanStranded?` · <b style=\"color:var(--closing)\">${fmt(c.orphanStranded)} sem líder com destino</b>`:''}. Não entram no CSV, que é por município.`:'')\n   +(mv.kind==='transfer'?`<br><b style=\"color:var(--acc)\">Transferência manual</b>: ${mName(mv.xferMuni)} (${mUf(mv.xferMuni)}) — ${mv.xferFrom} → ${mv.xferTo} · ${fmt(mv.xferMetrics.cbs)} CBs`:'');\n}\nfunction renderReport(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  mv.metric=mv.metric||'cbs';\n  const KEYS=METRICS_DEF.map(m=>m[0]);\n  const incBy={};mv.dest.forEach(s=>incBy[s]=Object.fromEntries(KEYS.map(k=>[k,0])));\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(!s||!incBy[s])return;KEYS.forEach(k=>incBy[s][k]+=o[k]||0);});\n  // orphan CBs land on their leader's home sector, not on a geographic destination\n  c.leaders.forEach(L=>{ const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n    if(om&&L.home&&incBy[L.home]) KEYS.forEach(k=>incBy[L.home][k]+=om[k]||0); });\n  // mv.munis['__OUTROS__'] already holds EVERY row this sector has that never got drawn on the\n  // map — \"fora da base de dominantes\" (the one subset mv.orphanMetricsByGroup tracks) AND any\n  // other reason a município has no geoFeat (no shape in the loaded UFs, key mismatch — see\n  // missingMunis). Summing every mv.munis entry, __OUTROS__ included, is the exact same total\n  // sectorTotalCbs() already uses for the \"CBs no setor (antes desta etapa)\" KPI — this bar has to\n  // agree with that KPI, not undercount by whichever slice of __OUTROS__ isn't a tracked orphan\n  // group (a município with no shape at all silently vanished from this total before).\n  const closeTot={};KEYS.forEach(k=>closeTot[k]=Object.values(mv.munis).reduce((a,o)=>a+(o[k]||0),0));\n  const trend=mv.trend.slice(-6);\n  const chartHTML=mk=>{\n    const isM=['receita','rec_nat','rec_avon'].includes(mk); const fN=isM?money:fmt;\n    const data=mv.dest.map(s=>({label:s,color:mv.color[s],antes:+(mv.baseMetrics[s]?.[mk]||0),depois:+(mv.baseMetrics[s]?.[mk]||0)+(incBy[s]?.[mk]||0)}));\n    data.push({label:mv.sector+'✕',color:'#ff3b30',antes:closeTot[mk]||0,depois:0});\n    const max=Math.max(...data.flatMap(d=>[d.antes,d.depois]),1);\n    const W=720,H=290,base=238,top=42,maxH=base-top,n=data.length,gw=(W-16)/n;\n    let g='';\n    data.forEach((d,i)=>{const cx=8+gw*i+gw/2,bw=Math.min(30,gw*0.30);\n      const ah=d.antes/max*maxH,dh=d.depois/max*maxH,ax=cx-bw-2,dx=cx+2;\n      g+=`<rect x=\"${ax.toFixed(1)}\" y=\"${(base-ah).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${ah.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\" opacity=\"0.35\"/>`;\n      g+=`<rect x=\"${dx.toFixed(1)}\" y=\"${(base-dh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${dh.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\"/>`;\n      g+=`<text x=\"${(ax+bw/2).toFixed(1)}\" y=\"${(base-ah-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9\" fill=\"#9a9aa8\">${fN(d.antes)}</text>`;\n      g+=`<text x=\"${(dx+bw/2).toFixed(1)}\" y=\"${(base-dh-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9.5\" font-weight=\"700\" fill=\"${d.color}\">${fN(d.depois)}</text>`;\n      const dp=d.antes?((d.depois-d.antes)/d.antes*100):(d.depois?100:0);\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"16\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"${dp>=0?'#34c759':'#ff3b30'}\">${dp>0?'+':''}${dp.toFixed(0)}%</text>`;\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"${base+16}\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"#1c1c2b\">${d.label}</text>`;});\n    return `<svg viewBox=\"0 0 ${W} ${H}\" style=\"width:100%\"><line x1=\"8\" y1=\"${base}\" x2=\"${W-8}\" y2=\"${base}\" stroke=\"#e7e7ef\"/>${g}</svg>`;\n  };\n  let h=`<div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap\">\n      <h2 style=\"margin:0\">Impacto antes → depois desta etapa</h2>\n      <select id=\"metricSel\">${METRICS_DEF.map(m=>`<option value=\"${m[0]}\" ${m[0]===mv.metric?'selected':''}>${m[1]}</option>`).join('')}</select></div>\n    <div class=\"note\" style=\"margin:2px 0 4px\">Barra clara = <b>antes desta etapa</b> (já incluindo etapas anteriores) · barra cheia = <b>depois</b> · <b style=\"color:var(--acc)\">${mv.sector}✕</b> = ${lc(lvl.label)} fechad${lvl.a==='à'?'a':'o'} nesta etapa · % = variação</div>\n    ${chartHTML(mv.metric)}`;\n  h+=`<h2 style=\"margin-top:16px\">Antes → Depois por ${lc(lvl.destLabel)} receptor (snapshot congelado)</h2>\n    <div class=\"scroll grow\"><table><thead><tr><th>${lvl.destLabel}</th>${METRICS_DEF.map(m=>`<th>${m[1]}</th>`).join('')}</tr></thead><tbody>`;\n  mv.dest.forEach(s=>{const b=mv.baseMetrics[s]||{};const inc=incBy[s];\n    h+=`<tr><td><span class=\"home\" style=\"background:${mv.color[s]}\"></span>${s}</td>`+\n      METRICS_DEF.map(([k])=>{const bv=+b[k]||0,av=bv+(inc[k]||0);const d=bv?((av-bv)/bv*100):0;const f=METRICS_DEF.find(x=>x[0]===k)[2]==='$'?money:fmt;\n        return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\" title=\"${({cbs:'CBs',ativas:'CBs ativas',pedidos:'Pedidos',pontos:'Pontos',receita:'Receita',rec_nat:'Receita Natura',rec_avon:'Receita Avon'})[k]} ${({cbs:'recebidas',ativas:'recebidas',pedidos:'recebidos',pontos:'recebidos',receita:'recebida',rec_nat:'recebida',rec_avon:'recebida'})[k]}: +${f(inc[k]||0)}\\nAntes ${f(bv)} -> Depois ${f(av)}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;}).join('')+`</tr>`;});\n  h+=`<tr style=\"opacity:.8\"><td>${mv.sector} (sai)</td>`+METRICS_DEF.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(closeTot[k]||0)}</td>`).join('')+`</tr>`;\n  h+=`</tbody></table></div>`;\n  const incLvl={};mv.dest.forEach(s=>incLvl[s]={});\n  Object.keys(mv.munis).forEach(m=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(!s||!incLvl[s])return;const lv=mv.levelByMuni[m]||{};for(const nv in lv)incLvl[s][nv]=(incLvl[s][nv]||0)+lv[nv];});\n  c.leaders.forEach(L=>{ const ol=mv.orphanLevelByGroup&&mv.orphanLevelByGroup[L.grupo];\n    if(ol&&L.home&&incLvl[L.home]) Object.entries(ol).forEach(([nv,n])=>incLvl[L.home][nv]=(incLvl[L.home][nv]||0)+n); });\n  h+=`<h2 style=\"margin-top:18px\">Consultoras por nível — antes → depois por ${lc(lvl.destLabel)} receptor</h2>\n    <div class=\"scroll grow\"><table><thead><tr><th>${lvl.destLabel}</th>${NIVEIS.map(n=>`<th>${n}</th>`).join('')}<th>Total</th></tr></thead><tbody>`;\n  mv.dest.forEach(s=>{const base=mv.baseLevel[s]||{},inc=incLvl[s]||{};let tb=0,ta=0;\n    h+=`<tr><td><span class=\"home\" style=\"background:${mv.color[s]}\"></span>${s}</td>`+\n      NIVEIS.map(nv=>{const b=base[nv]||0,i=inc[nv]||0,a=b+i;tb+=b;ta+=a;\n        return `<td>${a?fmt(a):'·'}${i?`<div class=\"delta up\">+${fmt(i)}</div>`:''}</td>`;}).join('')+\n      `<td style=\"font-weight:700\">${fmt(ta)}${ta-tb?`<div class=\"delta up\">+${fmt(ta-tb)}</div>`:''}</td></tr>`;});\n  h+=`</tbody></table></div><div class=\"note\">Número = CBs por nível <b>depois</b> desta etapa · <span style=\"color:var(--ok)\">+N</span> = entrantes do ${mv.sector}.</div>`;\n  const spk=(key)=>{const vals=trend.map(t=>+t[key]||0);const mx=Math.max(...vals,1);\n    return `<div class=\"spark\">${vals.map(v=>`<div class=\"b\" style=\"height:${(v/mx*100).toFixed(0)}%\" title=\"${fmt(v)}\"></div>`).join('')}</div>\n      <div class=\"note\">${trend.map(t=>String(t.ciclo).slice(-2)).join(' · ')}</div>`;};\n  h+=`<h2 style=\"margin-top:18px\">Setor ${mv.sector} — últimos ${trend.length} ciclos até o snapshot congelado</h2>\n    <div class=\"note\" style=\"margin:-4px 0 8px\">Tendência histórica <b>do próprio setor</b> — não ajustada pela absorção hipotética de etapas anteriores (limitação conhecida: tendência é por setor, não decomponível por município).</div>\n    <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:10px\">\n    ${[['cbs','CBs'],['ativas','Ativas'],['receita','Receita'],['pontos','Pontos']].map(([k,l])=>`<div><div class=\"note\" style=\"margin:0 0 3px;font-weight:600;color:var(--ink)\">${l}</div>${spk(k)}</div>`).join('')}</div>`;\n  $('#tab-report').innerHTML=h;\n  const sel=$('#metricSel'); if(sel){ sel.onchange=()=>{mv.metric=sel.value; renderReport();}; initMacSelect(sel); }\n}\nfunction renderCumulative(){\n  const el=$('#tab-cumulative'); if(!el) return;\n  // Esta view soma a cadeia inteira via foldWorldState().muniOverride/sectorBaseCache — hoje só\n  // populados pelo fechamento de setor. GV/mercado têm o rastro equivalente (secOverride/gvOverride),\n  // mas essa agregação ainda não foi refeita nesse grão; melhor avisar do que mostrar tabela vazia.\n  const lockLvl=SESS.timeline.length?(SESS.timeline[0].level||'setor'):'setor';\n  if(lockLvl!=='setor'){ el.innerHTML=`<div class=\"note\">Impacto acumulado da cadeia ainda não disponível pra sessões de ${lc(LEVELS[lockLvl].label)} — use o relatório da etapa (aba anterior) etapa a etapa.</div>`; return; }\n  if(!SESS.timeline.length){ el.innerHTML='<div class=\"note\">Nenhuma etapa na timeline ainda.</div>'; return; }\n  const ready=SESS.timeline.every(mv=>mv.munis);\n  if(!ready){ el.innerHTML='<div class=\"note\">Aguardando etapas terminarem de calcular…</div>'; return; }\n  const closingMoves=SESS.timeline.filter(mv=>mv.kind!=='transfer');\n  const finalWorld=foldWorldState(SESS.timeline,SESS.timeline.length);\n  const openSectors=[...new Set(Object.values(finalWorld.muniOverride))];\n  let h=`<h2 style=\"margin:0 0 8px\">Linha do tempo — ${closingMoves.length} fechamento(s)</h2>\n    <div class=\"note\" style=\"margin:-2px 0 10px\">Setores fechados, em ordem: ${closingMoves.map(mv=>mv.sector).join(' → ')}. Cada linha abaixo soma o efeito de <b>toda a cadeia</b>, não apenas da última etapa.</div>`;\n  h+=`<div class=\"scroll grow\"><table><thead><tr><th>Setor</th><th>Situação</th>${METRICS_DEF.map(([,l])=>`<th>${l}</th>`).join('')}</tr></thead><tbody>`;\n  closingMoves.forEach(mv=>{\n    const i=SESS.timeline.indexOf(mv);\n    h+=`<tr style=\"opacity:.75\"><td>${mv.sector}</td><td>fechado (etapa ${i+1})</td>`+METRICS_DEF.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(mv.pureOwnMetrics?.[k]||0)}</td>`).join('')+`</tr>`;\n  });\n  openSectors.forEach(s=>{\n    const base=SESS.sectorBaseCache[s]||{};\n    const infl=finalWorld.sectorInflow[s]||{};\n    h+=`<tr><td>${s}</td><td>recebeu território</td>`+METRICS_DEF.map(([k,,t])=>{\n      const bv=+base[k]||0, av=bv+(infl[k]||0); const d=bv?((av-bv)/bv*100):0; const f=(t==='$'?money:fmt);\n      return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;\n    }).join('')+`</tr>`;\n  });\n  h+=`</tbody></table></div><div class=\"note\">\"Antes\" de um setor receptor é o snapshot congelado (sem nenhum fechamento aplicado); \"depois\" soma tudo que a cadeia inteira jogou pra ele.</div>`;\n  el.innerHTML=h;\n}\nfunction project(feats){let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  const each=cb=>feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(p=>p.forEach(r=>r.forEach(cb)));});\n  each(p=>{mnx=Math.min(mnx,p[0]);mxx=Math.max(mxx,p[0]);mny=Math.min(mny,p[1]);mxy=Math.max(mxy,p[1]);});\n  const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=MAP_W,H=MAP_H,pad=22;\n  const gw=(mxx-mnx)*kx,gh=(mxy-mny),sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n  const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n  return p=>[ox+(p[0]-mnx)*kx*sc, oy+(mxy-p[1])*sc];\n}\nfunction pathFor(f,pr){const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  return ps.map(poly=>poly.map(r=>'M'+r.map(p=>{const q=pr(p);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');}\nfunction cxy(f,pr){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(poly=>poly[0].forEach(p=>{const q=pr(p);x+=q[0];y+=q[1];n++;}));return[x/n,y/n];}\n// ---------- shared-município slicing ----------\n// A huge urban município (São Paulo etc.) can be the CSV's dominant município for MANY different\n// setores at once — Natura subdivides one big city into dozens of commercial sectors while IBGE's\n// shapefile only has ONE polygon for the whole city. Drawing that single polygon once per claiming\n// sector stacked identical copies on top of each other: only whichever sector was processed first\n// ever \"won\" the shape, and every other sector's label floated over territory it didn't actually own\n// (mv.muniOwners below records every claimant, not just the first). Slice the polygon into N\n// equal-width strips instead — one per claiming sector, ordered by sector code — along whichever\n// axis the shape is longer on, so each sector genuinely gets its own area instead of losing the race.\nfunction clipRingHalf(ring,axis,keepGe,val){\n  const out=[],n=ring.length; if(!n) return out;\n  const side=p=>keepGe?p[axis]>=val:p[axis]<=val;\n  for(let i=0;i<n;i++){\n    const cur=ring[i],prev=ring[(i-1+n)%n];\n    const curIn=side(cur),prevIn=side(prev);\n    if(curIn!==prevIn){\n      const t=(val-prev[axis])/(cur[axis]-prev[axis]);\n      const other=1-axis,pt=[0,0]; pt[axis]=val; pt[other]=prev[other]+t*(cur[other]-prev[other]);\n      out.push(pt);\n    }\n    if(curIn) out.push(cur);\n  }\n  return out;\n}\nfunction sliceFeatureEqual(feature,n){\n  if(n<=1) return [feature];\n  const g=feature.geometry,parts=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  const rings=[]; parts.forEach(poly=>poly.forEach(r=>{ if(r&&r.length>=4) rings.push(r); }));\n  let mnx=Infinity,mxx=-Infinity,mny=Infinity,mxy=-Infinity;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  if(!isFinite(mnx)) return Array.from({length:n},()=>({...feature,__empty:true}));\n  const wide=(mxx-mnx)>=(mxy-mny), axis=wide?0:1;\n  const lo=wide?mnx:mny, hi=wide?mxx:mxy, span=(hi-lo)||1;\n  const out=[];\n  for(let i=0;i<n;i++){\n    const a=lo+span*i/n, b=lo+span*(i+1)/n;\n    const polys=[];\n    rings.forEach(r=>{\n      let c=clipRingHalf(r,axis,true,a);\n      c=clipRingHalf(c,axis,false,b);\n      if(c.length>=3) polys.push([[...c,c[0]]]);\n    });\n    out.push(polys.length?{type:'Feature',properties:feature.properties,__uf:feature.__uf,geometry:{type:'MultiPolygon',coordinates:polys}}:{...feature,__empty:true});\n  }\n  return out;\n}\n// ---------- label placement ----------\n// Pole of inaccessibility: the interior point farthest from ANY boundary of the dissolved area. A\n// centroid is only a mean — on a concave sector, one split across a river, or a member whose vertex\n// average falls in a bay, it lands outside the very territory it is supposed to name, which is what\n// pushed the sector numbers off their own shapes. Even-odd across every ring of every member is a\n// correct union test here because municipalities tile rather than overlap.\n// The search box is clipped to the viewBox, so the result is inside the territory AND on screen.\n// Rings are decimated first — this is a label, not a measurement — which keeps a 20-município sector\n// to a few million ops, and the result is cached per stage: geometry never changes between renders,\n// only the fill does.\nfunction labelPointFor(fs,pr){\n  const rings=[];\n  (fs||[]).forEach(f=>{ if(!f||!f.geometry) return;\n    const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n    ps.forEach(poly=>poly.forEach(r=>{\n      if(!r||r.length<4) return;\n      const step=Math.max(1,Math.floor(r.length/120)),pts=[];\n      for(let i=0;i<r.length;i+=step) pts.push(pr(r[i]));\n      if(pts.length>3) rings.push(pts);\n    }));});\n  if(!rings.length) return null;\n  let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  mnx=Math.max(mnx,2); mny=Math.max(mny,2); mxx=Math.min(mxx,698); mxy=Math.min(mxy,498);\n  if(!(mxx>mnx&&mxy>mny)) return null;\n  const inside=(x,y)=>{ let c=false;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      if(((a[1]>y)!==(b[1]>y)) && (x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])) c=!c; }\n    return c; };\n  const edgeDist=(x,y)=>{ let m=Infinity;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      const dx=b[0]-a[0],dy=b[1]-a[1],L=dx*dx+dy*dy;\n      let t=L?(((x-a[0])*dx+(y-a[1])*dy)/L):0; t=t<0?0:(t>1?1:t);\n      const ex=a[0]+t*dx-x,ey=a[1]+t*dy-y,d=ex*ex+ey*ey; if(d<m)m=d; }\n    return Math.sqrt(m); };\n  const W=mxx-mnx,H=mxy-mny,N=26;\n  // Centroide (média simples dos vértices) do território inteiro — só usado pra desempatar A FAVOR\n  // do meio quando um ponto mais central tem folga quase tão boa quanto o campeão isolado. Um setor\n  // com um lóbulo bem mais gordo de um lado sempre ganhava o rótulo puxado pra esse lado, mesmo\n  // quando um ponto quase tão espaçoso e bem mais central também servia — o peso é pequeno de\n  // propósito, nunca vale mais que uma folga real melhor.\n  let ccx=0,ccy=0,cn=0;\n  rings.forEach(r=>r.forEach(p=>{ ccx+=p[0]; ccy+=p[1]; cn++; }));\n  ccx/=cn||1; ccy/=cn||1;\n  const CENTER_PULL=0.22;\n  const scoreAt=(x,y,d)=>d-CENTER_PULL*Math.hypot(x-ccx,y-ccy);\n  let best=null,bd=-1,bs=-Infinity;\n  for(let i=0;i<=N;i++) for(let j=0;j<=N;j++){\n    const x=mnx+W*i/N,y=mny+H*j/N;\n    if(!inside(x,y)) continue;\n    const d=edgeDist(x,y),s=scoreAt(x,y,d); if(s>bs){ bs=s; bd=d; best=[x,y]; }\n  }\n  if(!best) return null;\n  // Hill-climb off the coarse grid: the grid only has to land somewhere in the right lobe, the\n  // refinement finds the deep point inside it — halving the step whenever no neighbour improves.\n  let step=Math.max(W,H)/N/2;\n  for(let k=0;k<7;k++){\n    let moved=false;\n    for(const o of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]){\n      const x=best[0]+o[0]*step,y=best[1]+o[1]*step;\n      if(x<mnx||x>mxx||y<mny||y>mxy||!inside(x,y)) continue;\n      const d=edgeDist(x,y),s=scoreAt(x,y,d); if(s>bs){ bs=s; bd=d; best=[x,y]; moved=true; }\n    }\n    if(!moved) step/=2;\n  }\n  // The clearance travels with the point: it is the radius of the largest circle that fits inside the\n  // territory there, which is exactly the budget a caller needs to decide how much text will fit.\n  return {p:best,r:bd};\n}\nfunction labelPointOf(mv,key,fs,pr){\n  if(!mv._lbl) mv._lbl={};\n  if(!(key in mv._lbl)) mv._lbl[key]=labelPointFor(fs,pr);\n  return mv._lbl[key];\n}\n// ---------- map zoom/pan ----------\n// Zoom é feito manipulando o viewBox, nunca um transform CSS — assim rótulos e polígonos vivem no\n// MESMO sistema de coordenadas e escalam juntos automaticamente (texto tem font-size em unidades do\n// viewBox, então encolher o viewBox amplia tudo por igual, sem lógica extra pro tamanho da fonte).\nconst MAP_W=700; let MAP_H=500; const MAP_ZOOM_MAX=8;\nlet mapView={x:0,y:0,w:MAP_W,h:MAP_H};\n// MAP_H acompanha a proporção real do card na tela (MAP_W fica fixo) — sem isso o \"mundo\" de\n// desenho tinha sempre proporção 700x500 fixa, e o <svg> (que preserva essa proporção) ficava\n// menor que o card sempre que o card fosse bem mais alto que largo, sobrando um vão em branco\n// embaixo — a sensação de \"mapa preso num quadrado\" dentro de um espaço bem maior.\nfunction syncMapDims(){\n  const wrap=$('.map-wrap'); if(!wrap) return;\n  const cw=wrap.clientWidth, ch=wrap.clientHeight; if(!cw||!ch) return;\n  const newH=Math.round(MAP_W*(ch/cw));\n  if(Math.abs(newH-MAP_H)<2) return;\n  const fracW=mapView.w/MAP_W, cx=(mapView.x+mapView.w/2)/MAP_W, cy=(mapView.y+mapView.h/2)/MAP_H;\n  MAP_H=newH;\n  const w=Math.min(MAP_W,Math.max(MAP_W/MAP_ZOOM_MAX,MAP_W*fracW));\n  const h=Math.min(MAP_H,Math.max(MAP_H/MAP_ZOOM_MAX,MAP_H*fracW));\n  let x=cx*MAP_W-w/2, y=cy*MAP_H-h/2;\n  x=Math.min(MAP_W-w,Math.max(0,x)); y=Math.min(MAP_H-h,Math.max(0,y));\n  mapView={x,y,w,h};\n}\nfunction applyMapView(){\n  const svg=$('#map'); if(!svg) return;\n  svg.setAttribute('viewBox', mapView.x.toFixed(2)+' '+mapView.y.toFixed(2)+' '+mapView.w.toFixed(2)+' '+mapView.h.toFixed(2));\n  // Rótulo tem font-size em unidades do viewBox, então sem isso ele cresceria junto com o zoom até\n  // virar um borrão — encolhendo o font-size na mesma proporção que o viewBox encolhe cancela\n  // exatamente o zoom, e o texto fica do MESMO tamanho na tela em qualquer nível de zoom.\n  const fsScale=mapView.w/MAP_W;\n  svg.querySelectorAll('text[data-fs]').forEach(t=>{\n    const base=+t.getAttribute('data-fs')||8;\n    t.setAttribute('font-size',(base*fsScale).toFixed(2));\n  });\n  // Mesmo raciocínio pro halo do texto e pro fio que liga um rótulo deslocado ao seu município —\n  // os dois têm largura de traço fixa em unidades do viewBox, então sem reescalar iam virar uma\n  // mancha grossa (halo) ou uma barra (fio) no zoom alto em vez de ficarem do mesmo tamanho na tela.\n  svg.querySelectorAll('[data-sw]').forEach(el=>{\n    const base=+el.getAttribute('data-sw')||1;\n    el.style.strokeWidth=(base*fsScale).toFixed(3)+'px';\n  });\n  // Fio de rótulo deslocado é calculado UMA vez, no mundo inteiro de 700x500, pra evitar colisão\n  // com TODO outro rótulo — inclusive os que, no zoom atual, nem estão mais na tela. Quanto mais\n  // fundo o zoom, maior a fração da área visível que esse mesmo fio (comprimento fixo em unidades\n  // do mundo) passa a cobrir, virando um risco diagonal cortando o mapa até um rótulo que já saiu\n  // do enquadramento. Esconder o fio quando ele passa de uma fração do que está visível agora\n  // resolve isso sem precisar recalcular posição de rótulo a cada passo de zoom.\n  const viewDiag=Math.hypot(mapView.w,mapView.h);\n  svg.querySelectorAll('line.lead').forEach(ln=>{\n    const dx=(+ln.getAttribute('x2'))-(+ln.getAttribute('x1')), dy=(+ln.getAttribute('y2'))-(+ln.getAttribute('y1'));\n    ln.style.display=(Math.hypot(dx,dy)>viewDiag*0.35)?'none':'';\n  });\n}\nfunction zoomMapBy(factor){\n  const cx=mapView.x+mapView.w/2, cy=mapView.y+mapView.h/2;\n  let w=mapView.w/factor, h=mapView.h/factor;\n  w=Math.min(MAP_W, Math.max(MAP_W/MAP_ZOOM_MAX, w));\n  h=Math.min(MAP_H, Math.max(MAP_H/MAP_ZOOM_MAX, h));\n  let x=cx-w/2, y=cy-h/2;\n  x=Math.min(MAP_W-w, Math.max(0,x));\n  y=Math.min(MAP_H-h, Math.max(0,y));\n  mapView={x,y,w,h};\n  applyMapView();\n}\n// Mesma matemática do zoomMapBy, mas ancorada no cursor em vez do centro da view — o ponto do mapa\n// que está sob o mouse continua sob o mouse depois do zoom, o que faz o scroll parecer\n// contínuo/\"seamless\" em vez de recentralizar o mapa a cada girada da roda.\nfunction zoomMapAt(factor, clientX, clientY){\n  const svg=$('#map'); if(!svg) return;\n  const rect=svg.getBoundingClientRect(); if(!rect.width||!rect.height) return;\n  const fx=(clientX-rect.left)/rect.width, fy=(clientY-rect.top)/rect.height;\n  const wx=mapView.x+fx*mapView.w, wy=mapView.y+fy*mapView.h;\n  let w=mapView.w/factor, h=mapView.h/factor;\n  w=Math.min(MAP_W, Math.max(MAP_W/MAP_ZOOM_MAX, w));\n  h=Math.min(MAP_H, Math.max(MAP_H/MAP_ZOOM_MAX, h));\n  let x=wx-fx*w, y=wy-fy*h;\n  x=Math.min(MAP_W-w, Math.max(0,x));\n  y=Math.min(MAP_H-h, Math.max(0,y));\n  mapView={x,y,w,h};\n  applyMapView();\n}\n// Arrastar sempre fica DENTRO do canvas de 700x500 (clamp em [0, MAP_W-w] / [0, MAP_H-h]) — nunca\n// existe um x/y que deixa o viewBox parcialmente fora da área com conteúdo, então o mapa nunca\n// \"some\" da tela, só pode chegar até a borda do que existe pra ver.\nlet mapDrag=null, mapDidDrag=false;\nfunction mapPointerDown(e){\n  const svg=$('#map'); if(!svg) return;\n  mapDrag={x0:e.clientX,y0:e.clientY,vx0:mapView.x,vy0:mapView.y,rect:svg.getBoundingClientRect()};\n  mapDidDrag=false;\n  svg.classList.add('dragging');\n  // NÃO setPointerCapture aqui: capturar o ponteiro no <svg> reencaminha o click resultante pro\n  // próprio <svg> em vez do <path> do município embaixo do cursor — todo clique parava de chegar\n  // no onclick de reatribuição, não só depois de um arrasto de verdade. move/up escutam em `root`\n  // (nunca substituído por renderAll) em vez de no <svg>, então o arrasto continua funcionando\n  // mesmo se o ponteiro sair da área do mapa no meio do gesto.\n}\nfunction mapPointerMove(e){\n  if(!mapDrag) return;\n  const dxPix=e.clientX-mapDrag.x0, dyPix=e.clientY-mapDrag.y0;\n  if(!mapDidDrag && Math.hypot(dxPix,dyPix)>4) mapDidDrag=true;\n  if(!mapDrag.rect.width||!mapDrag.rect.height) return;\n  const sx=mapView.w/mapDrag.rect.width, sy=mapView.h/mapDrag.rect.height;\n  let x=mapDrag.vx0-dxPix*sx, y=mapDrag.vy0-dyPix*sy;\n  x=Math.min(MAP_W-mapView.w, Math.max(0,x));\n  y=Math.min(MAP_H-mapView.h, Math.max(0,y));\n  mapView={...mapView,x,y};\n  applyMapView();\n}\nfunction mapPointerUp(){\n  if(!mapDrag) return;\n  mapDrag=null;\n  const svg=$('#map'); if(svg) svg.classList.remove('dragging');\n}\n// ---------- OSM reference layers (rodovias/rios/picos) ----------\n// This map is a hand-projected SVG, not a Leaflet/Mapbox instance — there is no tile layer to drop a\n// basemap into. Vector geometry is the only shape that can ride the SAME pr() projection every\n// município shape already uses and land in the right spot. Scoped to the bbox of what mv.geoFeat +\n// mv.nbrFeat actually put on screen for THIS stage — never the whole country — because Overpass's\n// road/river graph for Brazil is enormous and the ask here is é \"what's around this closure\", not a\n// national basemap. Cached by (layer, bbox) rather than by session/stage index: two stages that\n// happen to look at the same patch of ground share one fetch, and switching sessions can never serve\n// a stale layer under a reused stage index.\n// UMA query Overpass pra Rodovias inteira (as 8 subclasses documentadas na wiki do Overpass API,\n// https://wiki.openstreetmap.org/wiki/Overpass_API, num regex só), não 8 requests separados — 8\n// chamadas de uma vez contra o mesmo servidor público é exatamente o padrão que dispara\n// rate-limit/504 \"servidor ocupado\" (foi o que quebrou \"secondary\"/\"secondary_link\"). Busca e cache\n// continuam por bbox, só que agora sob UMA chave ('rodovias'); a subclasse de cada trecho\n// (highway=motorway/trunk/primary/secondary + _link) já vem em el.tags.highway na resposta (\"out\n// geom\" inclui as tags do elemento por padrão) — cada checkbox individual só filtra o que já foi\n// baixado, sem request nova nenhuma.\nconst HIGHWAY_META={\n  motorway:{label:'Rodovia expressa',tag:'highway=motorway',color:'#777777',sw:0.5},\n  trunk:{label:'Via arterial',tag:'highway=trunk',color:'#777777',sw:0.5},\n  primary:{label:'Rodovia principal',tag:'highway=primary',color:'#777777',sw:0.5},\n  secondary:{label:'Rodovia secundária',tag:'highway=secondary',color:'#777777',sw:0.5},\n};\nconst HIGHWAY_TAG_TO_KEY={motorway:'motorway',trunk:'trunk',primary:'primary',secondary:'secondary'};\n// Menor pra maior — desenha secundária primeiro (embaixo) e motorway por último (em cima), pra uma via\n// maior nunca sumir sob uma menor só por causa da ordem arbitrária que o Overpass devolve.\nconst HIGHWAY_RANK={secondary:0,primary:1,trunk:2,motorway:3};\nconst HIGHWAY_KEYS=Object.keys(HIGHWAY_META);\nconst OSM_LAYERS=[\n  {key:'rodovias',label:'Rodovias',\n   ql:b=>`way[\"highway\"~\"^(motorway|trunk|primary|secondary)$\"](${b});`},\n  // Só rio PERMANENTE conta como barreira de verdade pra distância real entre municípios — riacho\n  // é atravessado por qualquer bueiro, e um rio \"intermitente\" (comum no semiárido nordestino) fica\n  // seco a maior parte do ano, então nenhum dos dois muda quanto tempo leva pra ir de um lugar ao\n  // outro.\n  {key:'rivers',label:'Rios',color:'#0a84ff',sw:0.5,\n   ql:b=>`way[\"waterway\"=\"river\"][\"intermittent\"!=\"yes\"](${b});`},\n  // Aeroporto/aeródromo (https://wiki.openstreetmap.org/wiki/Tag:aeroway%3Daerodrome) pode estar\n  // mapeado como nó único (campo pequeno) OU como área (way/relation, o terreno inteiro) — nwr pega\n  // os três, e \"out center\" devolve um ponto pronto pra qualquer um deles (lat/lon direto no nó,\n  // center.lat/lon calculado pro way/relation), sem geometria de contorno igual rodovia/rio porque\n  // aqui o que importa é a LOCALIZAÇÃO, não o desenho do pátio.\n  // Só aeroporto COMERCIAL: exige tag \"iata\" (código IATA de 3 letras só existe em aeródromo com voo\n  // comercial regular) — filtra fora pista de fazenda/campo de pouso privado/aeródromo militar sem\n  // codigo, que respondem à mesma query aeroway=aerodrome mas não são \"aeroporto\" no sentido do usuário.\n  {key:'airports',label:'Aeroportos',mode:'point',outMode:'center',color:'#000000',\n   ql:b=>`nwr[\"aeroway\"=\"aerodrome\"][\"iata\"](${b});`},\n];\nconst OSM_ON={motorway:false,trunk:false,primary:false,secondary:false,rivers:false,airports:false};\n// Contorno branco/colorido em volta de cada pedaço do que está fechando — ligado por padrão (era o\n// único comportamento antes deste toggle existir), desligar só tira o traço (stroke:none), nunca\n// muda preenchimento nem lógica de clique.\nlet SHOW_MUNI_OUTLINE=false;\nlet OSM_PANEL_OPEN=false; // page chrome only — deliberately not on SESS, never persisted/shared (mirrors OSM_ON/LEGEND_ON's own convention)\n// Legendas default ON — é o comportamento de sempre (rótulo em toda unidade/área de contexto);\n// os checkboxes só existem pra permitir DESLIGAR num mapa carregado, não pra ligar algo novo.\nconst LEGEND_ON={muni:false,ctx:false};\nconst CHILD_PLURAL={Município:'municípios',Setor:'setores',GV:'GVs'};\nconst OSM_CACHE={};\nconst OSM_MAX_SPAN=6; // graus — acima disso (fechamento de mercado muito grande) não busca sozinho\nfunction osmBboxOf(feats){\n  let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n    ps.forEach(poly=>poly.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; })));});\n  if(!isFinite(mnx)) return null;\n  return {south:mny,west:mnx,north:mxy,east:mxx};\n}\n// Calling overpass-api.de straight from the browser died with a bare \"Failed to fetch\" on the\n// operator's own network (proved: the exact same call succeeded from a different network) — a\n// corporate firewall/proxy blocking that third-party domain, most likely. Routing through Data2's own\n// Rush listener (osmOverpassProxy → HttpClient → overpass-api.de) moves the outbound hop to Data2's\n// backend, which never touches the operator's restricted network at all.\nasync function ensureOsmLayer(def,bbox,ck){\n  OSM_CACHE[ck]={status:'loading',data:null,error:null,attempt:0};\n  const b=bbox.south.toFixed(4)+','+bbox.west.toFixed(4)+','+bbox.north.toFixed(4)+','+bbox.east.toFixed(4);\n  const query='[out:json][timeout:25];('+def.ql(b)+');out'+(def.outMode==='center'?' center':' geom')+';';\n  const rec=OSM_CACHE[ck];\n  // The public Overpass instance fails under load often enough (504s, \"server busy\") that stopping\n  // after one try and forcing a manual re-click was more friction than the API itself — this keeps\n  // retrying with a capped backoff until it actually gets a response, instead of desmarking the\n  // checkbox(es) and giving up. stillWanted() is checked before AND after every attempt so a layer\n  // toggled off mid-retry stops hammering the server for something nobody wants anymore, rather than\n  // retrying forever in the background; drawOsmLayer's own `if(!rec)` guard is what stops this from\n  // ever running twice in parallel for the same bbox+layer, since `rec` stays present (status\n  // 'loading'/'retrying') for the whole life of this loop, never deleted on failure like before.\n  const stillWanted=()=>(def.key==='rodovias'?HIGHWAY_KEYS:[def.key]).some(k=>OSM_ON[k]);\n  for(let attempt=1;;attempt++){\n    if(!stillWanted()){ delete OSM_CACHE[ck]; return; }\n    rec.attempt=attempt; rec.status=attempt>1?'retrying':'loading';\n    try{\n      const res=await d2.rush('osmOverpassProxy').invoke({data:query});\n      const env=res&&res.data;\n      if(!env) throw new Error('proxy sem resposta');\n      if(!env.ok) throw new Error(env.error||'falha desconhecida no proxy');\n      rec.data=env.elements||[]; rec.status='ok';\n      drawMap();\n      return;\n    }catch(e){\n      rec.error=(e&&e.message)||'falha no proxy';\n      if(!stillWanted()){ delete OSM_CACHE[ck]; return; }\n      drawMap();\n      // Capped backoff (3s, 6s, 9s… up to 20s) — enough spacing to stay clear of the exact\n      // rate-limit pattern that already broke \"secondary\"/\"secondary_link\" once (see the comment\n      // above OSM_LAYERS), without making the operator wait an unreasonable amount between tries.\n      await new Promise(r=>setTimeout(r,Math.min(3000*attempt,20000)));\n    }\n  }\n}\n// ---- simplificação (RDP) + suavização (Catmull-Rom→Bézier) da geometria de via/rio do Overpass ----\n// Só pra linhas (rodovia/rio) — polígono de município/setor usa pathFor() e continua exato, sem\n// mexer, porque ali a fidelidade geométrica é o que importa (fronteira administrativa de verdade).\n// RDP_EPS em unidades do viewBox fixo (700x500 — ver comment em fitMapHeight/.map-wrap): pequeno o\n// bastante pra só remover ponto quase-colinear (zig-zag do GPS), não pra distorcer a curva real.\nconst RDP_EPS=0.6;\nfunction rdpSimplify(pts,eps){\n  if(pts.length<3) return pts;\n  const a=pts[0], b=pts[pts.length-1];\n  const dx=b[0]-a[0], dy=b[1]-a[1], len2=dx*dx+dy*dy;\n  let maxD=0,idx=0;\n  for(let i=1;i<pts.length-1;i++){\n    const p=pts[i];\n    let d;\n    if(len2===0){ d=Math.hypot(p[0]-a[0],p[1]-a[1]); }\n    else{\n      const t=((p[0]-a[0])*dx+(p[1]-a[1])*dy)/len2;\n      const px=a[0]+t*dx, py=a[1]+t*dy;\n      d=Math.hypot(p[0]-px,p[1]-py);\n    }\n    if(d>maxD){ maxD=d; idx=i; }\n  }\n  if(maxD>eps){\n    const left=rdpSimplify(pts.slice(0,idx+1),eps);\n    const right=rdpSimplify(pts.slice(idx),eps);\n    return left.slice(0,-1).concat(right);\n  }\n  return [a,b];\n}\n// Catmull-Rom → Bézier cúbica: passa exatamente pelos pontos simplificados (não aproxima, não corta\n// canto) mas troca o zig-zag reto do GPS por uma curva suave entre eles — o \"eles mentem sobre a\n// geometria\" descrito: pontos crus do OSM são só retas entre coordenadas, isto desenha uma curva de\n// verdade passando pelas mesmas coordenadas.\nfunction catmullRomPathD(pts){\n  if(pts.length<3) return 'M'+pts.map(p=>p[0].toFixed(1)+','+p[1].toFixed(1)).join('L');\n  let d=`M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;\n  for(let i=0;i<pts.length-1;i++){\n    const p0=pts[i-1]||pts[i], p1=pts[i], p2=pts[i+1], p3=pts[i+2]||p2;\n    const c1x=p1[0]+(p2[0]-p0[0])/6, c1y=p1[1]+(p2[1]-p0[1])/6;\n    const c2x=p2[0]-(p3[0]-p1[0])/6, c2y=p2[1]-(p3[1]-p1[1])/6;\n    d+=`C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;\n  }\n  return d;\n}\nfunction drawOsmLayer(svg,pr,feats,NS){\n  const st=$('#osmStatus');\n  const rodoviasOn=HIGHWAY_KEYS.some(k=>OSM_ON[k]);\n  const active=OSM_LAYERS.filter(l=>l.key==='rodovias'?rodoviasOn:OSM_ON[l.key]);\n  if(!active.length){ if(st) st.textContent=''; return; }\n  const bbox=osmBboxOf(feats); if(!bbox){ if(st) st.textContent=''; return; }\n  const tooBig=(bbox.north-bbox.south)>OSM_MAX_SPAN||(bbox.east-bbox.west)>OSM_MAX_SPAN;\n  const msgs=[];\n  active.forEach(def=>{\n    if(tooBig){ msgs.push(def.label+': área grande demais p/ carregar automaticamente'); return; }\n    const bk=bbox.south.toFixed(3)+'_'+bbox.west.toFixed(3)+'_'+bbox.north.toFixed(3)+'_'+bbox.east.toFixed(3);\n    const ck=def.key+'|'+bk;\n    let rec=OSM_CACHE[ck];\n    if(!rec){ ensureOsmLayer(def,bbox,ck); rec=OSM_CACHE[ck]; }\n    if(rec.status==='loading') msgs.push(def.label+': carregando…');\n    else if(rec.status==='retrying') msgs.push(def.label+': tentativa '+(rec.attempt||1)+'… (Overpass instável, repetindo automaticamente — última falha: '+(rec.error||'?')+')');\n    if(rec.status!=='ok') return;\n    if(def.mode==='point'){\n      // Localização, não contorno: nó usa lat/lon direto; way/relation usa o center que \"out\n      // center\" já calculou — nenhum dos dois carrega el.geometry, então essa camada nunca cai no\n      // desenho de linha abaixo.\n      const g=document.createElementNS(NS,'g');\n      rec.data.forEach(el=>{\n        const pt=el.type==='node'?{lon:el.lon,lat:el.lat}:(el.center?{lon:el.center.lon,lat:el.center.lat}:null);\n        if(!pt) return;\n        const q=pr([pt.lon,pt.lat]);\n        // Ícone de avião (glifo ✈, não um ponto azul cheio) — halo branco (paintOrder:stroke) pro símbolo\n        // ler em cima de qualquer cor de fundo do mapa, igual o halo já usado nos labels de município.\n        const ic=document.createElementNS(NS,'text');\n        ic.setAttribute('x',q[0].toFixed(1)); ic.setAttribute('y',q[1].toFixed(1));\n        ic.setAttribute('text-anchor','middle'); ic.setAttribute('dominant-baseline','central');\n        ic.setAttribute('font-size','13'); ic.setAttribute('fill',def.color);\n        ic.style.cursor='help'; ic.style.userSelect='none';\n        ic.textContent='✈';\n        const nm=(el.tags&&(el.tags.name||el.tags['name:pt']))||'Aeroporto';\n        ic.onmousemove=e=>showTip(e,`<b>${esc(nm)}</b>`);\n        ic.onmouseleave=()=>{$('#tip').style.opacity=0;};\n        g.appendChild(ic);\n      });\n      svg.appendChild(g);\n      return;\n    }\n    const g=document.createElementNS(NS,'g'); g.style.pointerEvents='none';\n    // Empilha do menos importante pro mais importante ANTES de desenhar — sem isto o Overpass devolve\n    // as vias em ordem arbitrária e uma via secundária pode acabar desenhada por cima de uma rodovia\n    // maior (o \"layer cake\" errado). Camadas sem subclasse (rios) mantêm a ordem que vieram.\n    const dataToRender=def.key==='rodovias'\n      ?[...rec.data].sort((a,b)=>(HIGHWAY_RANK[HIGHWAY_TAG_TO_KEY[a.tags&&a.tags.highway]]??-1)-(HIGHWAY_RANK[HIGHWAY_TAG_TO_KEY[b.tags&&b.tags.highway]]??-1))\n      :rec.data;\n    dataToRender.forEach(el=>{\n      if(!el.geometry||!el.geometry.length) return;\n      // Rodovias vem como UMA resposta com as 8 subclasses misturadas — cada trecho só desenha se\n      // a subclasse dele (el.tags.highway) estiver ligada agora; rios continua usando o próprio def.\n      let color,sw;\n      if(def.key==='rodovias'){\n        const hk=HIGHWAY_TAG_TO_KEY[el.tags&&el.tags.highway];\n        if(!hk||!OSM_ON[hk]) return;\n        const m=HIGHWAY_META[hk]; color=m.color; sw=m.sw;\n      } else { color=def.color; sw=def.sw; }\n      const rawPts=el.geometry.filter(Boolean).map(p=>pr([p.lon,p.lat]));\n      const d=catmullRomPathD(rdpSimplify(rawPts,RDP_EPS));\n      // Largura fixa em unidades do viewBox, sem contra-escala nenhuma (nada de data-sw) — estrada é\n      // geografia: encolhe sozinha quando o zoom mostra uma área enorme de uma vez (menos zoom =\n      // linha mais fina) e cresce normalmente ao aproximar, exatamente como o resto do mapa.\n      // Halo (contorno claro por baixo do traço real) só pra rios agora — em rodovia ficava lendo como\n      // um traço tracejado/serrilhado em vez de um contorno limpo, pedido explícito pra tirar.\n      if(def.key!=='rodovias'){\n        const halo=document.createElementNS(NS,'path'); halo.setAttribute('d',d); halo.setAttribute('fill','none');\n        halo.setAttribute('stroke','#fff'); halo.setAttribute('stroke-width',sw+1.3);\n        halo.style.strokeLinejoin='round'; halo.style.strokeLinecap='round';\n        g.appendChild(halo);\n      }\n      const p=document.createElementNS(NS,'path'); p.setAttribute('d',d); p.setAttribute('fill','none');\n      p.setAttribute('stroke',color); p.setAttribute('opacity', def.key==='rodovias' ? '1' : '0.72');\n      p.setAttribute('stroke-width',sw);\n      p.style.strokeLinejoin='round'; p.style.strokeLinecap='round';\n      g.appendChild(p);\n    });\n    svg.appendChild(g);\n  });\n  if(st) st.textContent=msgs.join(' · ');\n}\n// Mede o espaço de viewport que sobra abaixo do topo do card do mapa e usa isso como altura —\n// não um percentual fixo — porque o que existe ACIMA do mapa varia (trilho de etapas e toolbar só\n// aparecem quando a sessão já tem movimentações), então só medir de verdade para exatamente na\n// borda da tela em qualquer estado.\nfunction fitMapHeight(){\n  const wrap=$('.map-wrap'); if(!wrap) return;\n  const top=wrap.getBoundingClientRect().top;\n  if(top<=0) return;\n  // Margem generosa de propósito: sobra um pedaço visível do card seguinte (líderes/matriz) na\n  // borda inferior da tela, como sinal de que tem mais conteúdo abaixo pra rolar.\n  const margin=80;\n  wrap.style.height=Math.max(220, window.innerHeight-top-margin)+'px';\n}\nfunction drawMap(){\n  const mv=SESS.timeline[SESS.selected]; const svg=$('#map'); const ignNote=$('#mapIgnoredNote');\n  if(!mv){ svg.innerHTML=''; if(ignNote) ignNote.innerHTML=''; return; }\n  const level=mv.level||'setor';\n  const legendMuniLbl=$('#legendMuniLbl');\n  if(legendMuniLbl) legendMuniLbl.textContent = level==='setor' ? 'Nome de municípios' : `Código de ${CHILD_PLURAL[LEVELS[level].childLabel]||LEVELS[level].childLabel}`;\n  const muniOutlineLbl=$('#muniOutlineLbl');\n  if(muniOutlineLbl) muniOutlineLbl.textContent = `Contorno de ${level==='setor'?'municípios':(CHILD_PLURAL[LEVELS[level].childLabel]||LEVELS[level].childLabel)}`;\n  // GV/mercado fechando: o contorno de território agora é SEMPRE desenhado (unit-outline, mais\n  // abaixo em drawMap) — não sobra nada pra este checkbox ligar/desligar nesse nível, então ele\n  // some da toolbar em vez de virar um toggle morto que não muda mais nada na tela.\n  const muniOutlineRowEl=$('#muniOutline') && $('#muniOutline').closest('label');\n  if(muniOutlineRowEl) muniOutlineRowEl.classList.toggle('hidden', level!=='setor');\n  // setor fecha: a unidade É o município. GV/mercado fecham: cada município herda a atribuição do\n  // setor/GV a que pertence — municípios do mesmo setor sempre têm a MESMA cor e giram JUNTOS no\n  // clique, porque é o setor inteiro (não o município) que troca de dono nesses níveis.\n  const unitAssign=m=>level==='setor'?mv.assign[m]:mv.assign[(mv.unitOfMuni||{})[m]];\n  const c=computeDerived(mv);const NS='http://www.w3.org/2000/svg'; svg.innerHTML='';\n  const feats=[...mv.nbrFeat,...Object.values(mv.geoFeat)]; if(!feats.length) return;\n  // Hatch overlay flags a município whose full CSV sector list has >1 real sector, independent of\n  // how many of those sectors qualify as a valid destination target right now (isValidCtxTarget,\n  // below) — a município can collapse to one solid colour when only one owner qualifies, or to the\n  // flat __MULTI_NOVALID__ tone when none do, and either way the other real sectors it holds would\n  // otherwise be invisible. Dim by design (stroke-opacity .15): a hint to check the hover, not a\n  // second layer of colour competing with the destination fill.\n  const hatch=document.createElementNS(NS,'pattern');\n  hatch.setAttribute('id','multiSectorHatch'); hatch.setAttribute('width','7'); hatch.setAttribute('height','7');\n  hatch.setAttribute('patternUnits','userSpaceOnUse'); hatch.setAttribute('patternTransform','rotate(45)');\n  const hatchLine=document.createElementNS(NS,'line');\n  hatchLine.setAttribute('x1','0'); hatchLine.setAttribute('y1','0'); hatchLine.setAttribute('x2','0'); hatchLine.setAttribute('y2','7');\n  hatchLine.setAttribute('stroke','#1c1c1e'); hatchLine.setAttribute('stroke-width','0.6'); hatchLine.setAttribute('stroke-opacity','0.15');\n  hatch.appendChild(hatchLine);\n  const defs=document.createElementNS(NS,'defs'); defs.appendChild(hatch); svg.appendChild(defs);\n  syncMapDims();\n  const pr=project(feats);\n  const world=foldWorldState(SESS.timeline,SESS.selected);\n  // The context layer is ONE area per SECTOR, not one shape per município. The unit of this decision\n  // is the destination sector; a scatter of individually outlined municipalities carrying their own\n  // names invited exactly the wrong reading of the map. Members of a sector are filled AND stroked in\n  // the same colour, so the shared borders vanish and the group reads as a single dissolved\n  // territory — the IBGE shapes do not share vertices precisely enough to extract a clean union\n  // outline, and carrying a real polygon-clipping union for a context layer is not worth it.\n  // Labels are COLLECTED here and drawn last, after every path. Drawn inline they were buried by the\n  // next sector's fill, and placement cannot avoid a collision it has not seen yet.\n  const labels=[];\n  // Um destino \"de verdade\" pra este município compartilhado não exige ter sobrevivido ao corte de\n  // 6 do mv.dest — qualquer setor adjacente + da mesma GV/mercado + ainda não fechado nesta sessão já\n  // conta como candidato válido aqui (mv.validCandidates, a mesma malha usada como plainCandidates em\n  // computeMovement, sem o corte de top-6). Só se aplica a fechamento de setor — é aí que existe a\n  // noção de \"candidato descoberto por adjacência geográfica\"; GV/mercado mantém o comportamento\n  // anterior (todo setor real do município vira uma fatia, sem filtro de validade).\n  const isValidCtxTarget=s=>!!s&&(mv.dest.includes(s)||(mv.validCandidates&&mv.validCandidates.has(s)));\n  // Existence/closed-state at whatever grain THIS closure operates at — setor reads the live CRM\n  // sector list (as before); GV/mercado read the same dominantes-CSV hierarchy maps everything else\n  // at this level already trusts, since there is no live CRM listing of GVs/mercados to check against.\n  const levelExists=s=>level==='setor'?SESS.sectorsList.some(x=>x.setor===s)\n    :(level==='gv'?!!(DOM.sectorsByGv&&DOM.sectorsByGv[s]):!!(DOM.sectorsByMercado&&DOM.sectorsByMercado[s]));\n  const levelClosedSet=level==='setor'?world.closedSectors:(level==='gv'?world.closedGvs:world.closedMercados);\n\n  // A sibling sharing the CLOSING unit's own território (mv.sharedMuniSlices — São Paulo etc., setor\n  // level only today) is adjacent by definition, literally the same território — isValidCtxTarget's\n  // geographic-vote requirement (mv.dest/mv.validCandidates, built from OTHER municípios bordering the\n  // closing one) never applies to it, and wrongly failed every sibling that hadn't separately won that\n  // vote elsewhere. Same bar used everywhere else at this level: exists, not already closed, same\n  // parent as the unit closing — no geographic step, because none of it means anything for a\n  // co-located setor/GV.\n  const isValidSiblingTarget=s=>!!s&&levelExists(s)&&!(levelClosedSet&&levelClosedSet.has(s))&&parentOf(level,s)===mv.gv;\n  const muniTipHTML=(nm,owners)=>{\n    const lvl=LEVELS[level], fem=lvl.de==='da';\n    const destPl={Setor:'setores',GV:'GVs',Mercado:'mercados'}[lvl.destLabel]||lc(lvl.destLabel)+'s';\n    const siblingCtx=owners.includes(mv.sector);\n    const validFn=siblingCtx?isValidSiblingTarget:isValidCtxTarget;\n    const closing=owners.filter(o=>o===mv.sector);\n    const rest=owners.filter(o=>o!==mv.sector);\n    const valid=rest.filter(validFn);\n    const invalid=rest.filter(o=>!validFn(o));\n    // Grouped by WHY instead of listed one-per-código — a município that splits across a couple\n    // dozen setores (São Paulo etc.) otherwise buries the handful of real options under a wall of\n    // near-identical ✕ lines. The bucket key carries the specific detail (which OTHER gerência,\n    // say), so 3234/3236/3237 all rejected for the SAME gerência collapse into one line while a\n    // different gerência still gets its own — nothing gets merged that shouldn't be.\n    const bucketOf=o=>{\n      if(!levelExists(o)) return {key:'gone',label:'fora da base atual'};\n      if(levelClosedSet&&levelClosedSet.has(o)) return {key:'closed',label:`já fechad${fem?'as':'os'} em etapa anterior`};\n      if(lvl.parentLabel){\n        const p=parentOf(level,o);\n        if(mv.gv && p!==mv.gv) return {key:'parent:'+p,label:`é de outr${lvl.parentWord==='gerência'?'a':'o'} ${lvl.parentWord} <b>${esc(String(p||'?'))}</b>`};\n      }\n      return {key:'noborder',label:`não faz fronteira com ${lc(lvl.label)} sendo fechad${fem?'a':'o'}`};\n    };\n    const buckets=new Map();\n    invalid.forEach(o=>{\n      const b=bucketOf(o);\n      if(!buckets.has(b.key)) buckets.set(b.key,{label:b.label,codes:[]});\n      buckets.get(b.key).codes.push(o);\n    });\n    const row=(dot,label,codes)=>`<div class=\"tiprow\"><div class=\"tiprow-hd\"><span class=\"tipdot\">${dot}</span><span class=\"tiplbl\">${label}</span></div><span class=\"tipcodes\">${codes.join(', ')}</span></div>`;\n    const rowsHtml=[\n      closing.length&&row('●',`sendo fechad${fem?'a':'o'} nesta etapa`,closing),\n      valid.length&&row('✓','destino válido',valid),\n      ...[...buckets.values()].map(b=>row('✕',b.label,b.codes))\n    ].filter(Boolean).join('');\n    return `<div class=\"tipmuni\"><b>${mDisp(nm)}</b> — ${owners.length} ${destPl} neste município:${rowsHtml}</div>`;\n  };\n  const nbrBySec={};\n  // Border geometry for a genuinely multi-owner município is drawn ONCE, after every fill, from the\n  // município's FULL original shape — never from a per-slice fragment. sliceFeatureEqual band-cuts a\n  // real (often concave) shape into straight-edged pieces, and a real município can split into SEVERAL\n  // disjoint ring fragments per band; tracing every one of those individually produced a blocky,\n  // jagged mess instead of one clean outline. Keyed by município (nm) so it draws exactly once even\n  // though the município itself may contribute several fs entries across different destination keys.\n  const multiBorderFeats={};\n  mv.nbrFeat.forEach(f=>{\n    const nm=mkey(f.properties.name||f.properties.NOME||f.properties.nome||'',f.__uf);\n    // A município's real sector count is a CSV fact (DOM.setoresByMuni), never scoped to whichever\n    // destinations happen to be live candidates for THIS closure — mv.muniOwners only tracks the\n    // latter (top mv.dest picks), so a real multi-sector município whose other sectors lost the vote\n    // rendered as one flat colour instead of striped. Same source of truth mv.sharedMuniSlices\n    // already uses for the closing-sector side of this exact problem.\n    const csvOwners=DOM.setoresByMuni&&DOM.setoresByMuni[nm];\n    const owners=(csvOwners&&csvOwners.size?[...csvOwners]:(mv.muniOwners&&mv.muniOwners[nm]?[...new Set(mv.muniOwners[nm])]:[])).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n    if(owners.length>1){\n      // Real ownership is always recorded at SETOR grain (owners, above) — a GV/mercado closure has\n      // to judge \"does this município actually split HERE\" at ITS OWN grain, not the raw setor one:\n      // translate every real setor owner to its GV/mercado (destUnitOfSector — world-aware, the same\n      // resolver the single-owner fallback below already relies on for cascade/override correctness)\n      // and dedupe. Two setores owning a município that both roll up into the SAME GV are not a split\n      // at the GV level — only genuinely different level-owners earn their own stripe. At setor level\n      // this is the identity (no translation), so setor-closing behaviour is unchanged.\n      // A level-owner already closed in an earlier etapa of THIS session has been absorbed into\n      // whatever it closed into — it is not a live, separate owner of this município anymore, so it\n      // must never count toward \"does this município still split\" nor show up in the tooltip list.\n      // Filtered here (once, before the >1 check) rather than downstream: dropping a closed owner\n      // can legitimately collapse a real 2-owner município down to a plain single-owner one, which\n      // is exactly the fallthrough this function already handles below.\n      const levelOwners = (level==='setor' ? owners\n        : [...new Set(owners.map(so=>destUnitOfSector(level,so,world)).filter(Boolean))])\n            .filter(u=>!(levelClosedSet&&levelClosedSet.has(u)))\n            .sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      if(levelOwners.length>1){\n        multiBorderFeats[nm]=f;\n        // Only a VALID target earns its own stripe: a level-owner that's cross-parent, already closed,\n        // or not actually adjacent to this closure is treated as if it were not a real owner at all,\n        // and its share of the shape goes to the valid ones instead. Zero valid owners still stripes\n        // (so \"more than one owner here\" survives) but with one flat neutral tone, since there is no\n        // real destination colour to award any of them.\n        const valid=levelOwners.filter(isValidCtxTarget);\n        const sliceOwners=valid.length?valid:levelOwners;\n        // Zero valid owners: slice count is 1 (no cut at all), not levelOwners.length — there is no\n        // real destination colour to award any of the pieces, so a bbox-band cut here only carves the\n        // shape into arbitrary straight-edged fragments with nothing meaningful to show for it.\n        sliceFeatureEqual(f,valid.length?sliceOwners.length:1).forEach((slice,i)=>{\n          if(slice.__empty) return;\n          const key=valid.length?sliceOwners[i]:'__MULTI_NOVALID__';\n          (nbrBySec[key]=nbrBySec[key]||[]).push({feature:slice,muni:nm,owners:levelOwners});\n        });\n        return;\n      }\n      // Every real setor under this município rolled up into the same level-owner (or none) — no\n      // genuine split to show at this grain, so it falls through to the plain single-owner path below.\n    }\n    const s=String((mv.nbrCurrentSector&&mv.nbrCurrentSector[nm])||'');\n    (nbrBySec[s]=nbrBySec[s]||[]).push({feature:f,muni:nm});\n  });\n  // Same \"only a valid target earns its own stripe\" rule as the neighbour branch above — a sibling\n  // sector sharing this município with the one closing (São Paulo etc.) does not get its own wedge\n  // just for existing in the CSV; it needs to actually be a live destination candidate too. Grouped\n  // under the SAME '__MULTI_NOVALID__' bucket so an invalid sibling reads as flat neutral context\n  // instead of an arbitrary colour that looks like a real, selectable destination.\n  (mv.sharedMuniSlices||[]).forEach(sl=>{\n    const csvOwners=DOM.setoresByMuni&&DOM.setoresByMuni[sl.muni];\n    // Same closed-owner exclusion as the neighbour loop above — a sibling already closed earlier\n    // this session is gone, not just an invalid pick, so it drops out of the owner list entirely.\n    const owners=(csvOwners&&csvOwners.size?[...csvOwners]:[mv.sector,sl.sector])\n      .filter(o=>!(levelClosedSet&&levelClosedSet.has(o)))\n      .sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n    const key=isValidSiblingTarget(sl.sector)?sl.sector:'__MULTI_NOVALID__';\n    (nbrBySec[key]=nbrBySec[key]||[]).push({feature:sl.feature,muni:sl.muni,owners});\n  });\n  // A município shared with the CLOSING sector itself (São Paulo etc., not just a bordering\n  // neighbour) never passes through the mv.nbrFeat loop above (where multiBorderFeats is normally\n  // populated). This used to be rebuilt by concatenating mv.geoFeat's own slice with every\n  // mv.sharedMuniSlices piece into one MultiPolygon and stroking that — but sliceFeatureEqual\n  // band-cuts a município into straight fragments with no real polygon union, so stroking that\n  // naive concatenation traced every INTERNAL cut too, not just the true external edge (the stray\n  // straight gray lines cutting across a município like São Paulo). mv.muniFullShape[nm] — stashed\n  // during slicing, above, BEFORE mv.geoFeat[n] gets overwritten with just this sector's own slice —\n  // is the real, un-cut original polygon, so this draws exactly the same clean single-feature\n  // outline a bordering-neighbour shared município already gets, with none of the internal seams.\n  Object.keys(mv.muniFullShape||{}).forEach(nm=>{ if(!multiBorderFeats[nm]) multiBorderFeats[nm]=mv.muniFullShape[nm]; });\n  Object.keys(nbrBySec).forEach(s=>{\n    const fs=nbrBySec[s];\n    const isNovalid=s==='__MULTI_NOVALID__';\n    const isDest=!isNovalid&&!!s&&mv.dest.includes(s);\n    const isRejected=!isNovalid&&!!s&&mv.rejectedCodes&&mv.rejectedCodes.has(s);\n    const isUnresolved=!isNovalid&&!s;\n    // Um município com um código de setor real — mesmo não sendo candidato de destino NESTA etapa,\n    // mesmo sendo um setor já fechado numa etapa anterior — não deixa de existir nem de ter um\n    // destino de verdade; ele só não é candidato AGORA. Cor estável troca o cinza \"sem info\" por essa\n    // identidade real. Só rejected (lado errado da hierarquia), unresolved (sem shape/sem dado nenhum)\n    // e novalid (nenhum setor real deste município qualifica como destino) continuam sem cor de\n    // verdade — esses não têm \"pra onde foi\".\n    const col=isNovalid?null:(isDest?(mv.color[s]||null):(!isRejected&&!isUnresolved?colorForSector(s):null));\n    const rej=isRejected?(mv.rejectedInfo&&mv.rejectedInfo[s]):null;\n    // Sem união de polígono de verdade, a única forma de emular UM contorno em volta do setor\n    // inteiro é desenhar essa borda ANTES do preenchimento: cada aresta INTERNA (entre dois membros\n    // do mesmo setor) fica coberta pelo preenchimento opaco do vizinho por cima, e só a aresta\n    // EXTERNA (sem vizinho do mesmo setor do outro lado) continua visível — e, se dois setores\n    // \"inválidos\" diferentes se tocam, cada um desenha sua própria borda naquele limite, então a\n    // linha aparece do mesmo jeito, sem sumir.\n    if(isRejected) fs.forEach(fw=>{\n      const bp=document.createElementNS(NS,'path');\n      bp.setAttribute('d',pathFor(fw.feature,pr));\n      bp.setAttribute('class','sect-rejected-border');\n      svg.appendChild(bp);\n    });\n    fs.forEach(fw=>{\n      const f=fw.feature;\n      const p=document.createElementNS(NS,'path');\n      p.setAttribute('d',pathFor(f,pr));\n      // Stashed so discard-paint (further down) can re-slice the EXACT geometry this hitbox shows —\n      // for a município shared across several real sectors, `f` here is already just ONE owner's\n      // slice of the full shape, not the whole município; re-slicing the full shape instead of this\n      // would carve alternative-divisions bigger than the piece actually on screen, spilling the\n      // divisor lines out past this piece's real edge into a neighbouring owner's area.\n      p._geoFeature=f;\n      p.setAttribute('class','sect'+(isRejected?' rejected':(isUnresolved?' unresolved':(isNovalid?' plain':''))));\n      // Stamped so an external trigger (the span-rule fix card's hover, see moveWork) can look this\n      // piece up by município identity and toggle .munihl on it — same class the GV/mercado\n      // unit-hover group already uses, just driven from outside the map instead of from its own\n      // mouse events. Not every fw carries a muni (only pieces that came through the single- or\n      // multi-owner branches above do; a bare context feature with no CSV owner has none).\n      if(fw.muni) p.setAttribute('data-muni',fw.muni);\n      // Transfer-movement stage: highlight the município being retargeted permanently (not just on\n      // hover, unlike the fix-card's setPlanHl reuse of this same .munihl class) and let a click cycle\n      // its target sector — mv.view (not the not-yet-declared `asis` local) since this runs before that\n      // const is assigned further down this function.\n      if(mv.kind==='transfer' && fw.muni===mv.xferMuni){\n        if(mv.view!=='asis'){ p.style.cursor='pointer'; p.onclick=()=>cycleTransferTarget(); }\n      }\n      // isDest: NO stroke at all, fill only. Matching stroke to fill (even at the same opacity)\n      // still doubles up at every shared município edge — two adjacent members each paint their own\n      // translucent stroke on top of the shared line, so it composites darker than the interior even\n      // when both strokes are the exact fill colour. Only an absent stroke truly stops compositing.\n      if(col){ p.setAttribute('fill',col); }\n      if(pullModeOn(mv) && col && (!fw.owners || fw.owners.length<=1) && !isRejected && !isUnresolved && !isNovalid && fw.muni){\n        p.style.fillOpacity='1';\n        p.style.cursor='pointer';\n        p.onclick=()=>openPullConfirm(s, fw.muni);\n        const nmFull=(mUf(fw.muni)?mUf(fw.muni)+' - ':'')+mName(fw.muni);\n        const cbs=(mv.nbrCbsByMuni&&mv.nbrCbsByMuni[fw.muni])||0;\n        p.onmousemove=e=>showTip(e,`<b>${nmFull}</b><br>${fmt(cbs)} CBs`);\n        p.onmouseleave=()=>$('#tip').style.opacity=0;\n      }\n      const tipParts=[];\n      if(rej){\n        tipParts.push(level==='setor'\n          ? `${LEVELS.setor.destLabel} ${s} é da GV ${rej.parent||'?'} — a GV do setor fechando é a ${mv.gv||'?'}, então não entra como destino.`\n          : `${LEVELS.gv.destLabel} ${s} é do mercado ${rej.parent||'?'} — o mercado da GV fechando é o ${mv.gv||'?'}, então não entra como destino.`);\n      }\n      if(isUnresolved){\n        // Setor não encontrado pro município vizinho — mostra o NOME real do município no hover em vez\n        // de deixar em branco ou fingir um destino. Estado de debug de propósito: ajuda a achar rápido\n        // qual município ficou sem sector resolvido na base/consulta ao vivo.\n        const nmDisp=mDisp(mkey(f.properties.name||f.properties.NOME||f.properties.nome||'',f.__uf));\n        tipParts.push(`<b>${nmDisp}</b><br><span style=\"color:var(--mut)\">setor não encontrado — sem CBs neste snapshot ou não bateu com a base</span>`);\n      }\n      // Município com mais de um setor real: lista todos, válidos e não-válidos (com o motivo), numa\n      // seção própria do tooltip — mesmo quando este slice específico já tem um tooltip por outro\n      // motivo (rejected/unresolved), a lista fica anexada embaixo.\n      if(fw.owners&&fw.owners.length>1) tipParts.push(muniTipHTML(fw.muni,fw.owners));\n      if(tipParts.length){\n        p.onmousemove=e=>showTip(e,tipParts.join(''));\n        p.onmouseleave=()=>$('#tip').style.opacity=0;\n      }\n      svg.appendChild(p);\n      if(fw.owners&&fw.owners.length>1){\n        const hp=document.createElementNS(NS,'path');\n        hp.setAttribute('d',pathFor(f,pr));\n        hp.setAttribute('fill','url(#multiSectorHatch)');\n        hp.style.pointerEvents='none';\n        svg.appendChild(hp);\n      }\n    });\n    if(!s||isNovalid) return;\n    // One number per sector, at the pole of inaccessibility of its DISSOLVED area — the whole group\n    // of members at once, not a representative member, so the number sits in the visual middle of the\n    // territory the operator actually sees.\n    const lp=labelPointOf(mv,'s:'+s,fs.map(fw=>fw.feature),pr);\n    // Context areas always read LIGHT — .sect paints dest colours at fill-opacity .32 over white, and\n    // plain/closed-ctx are near-white greys — so a sector number never wants white ink, whatever colour\n    // the sector itself was assigned.\n    // A leader line is only worth drawing when there's real room to write into — otherwise a big\n    // multi-sector município (São Paulo etc., dozens of slivers) turns into a canvas-wide tangle of\n    // lines, one per sliver too thin to ever hold readable text. lp.r is the radius of the biggest\n    // circle that fits inside the slice; below this it isn't worth labelling at all.\n    if(LEGEND_ON.ctx && lp&&lp.p&&lp.r>=5) labels.push({anchor:lp.p,big:true,lines:[{t:s,size:11.5,weight:700}]});\n  });\n  const asis=mv.view==='asis';\n  if(level==='setor'){\n    // Setor fechando: a UNIDADE é o próprio município — cada um se comporta, colore e rotula\n    // individualmente, exatamente como sempre foi.\n    Object.entries(mv.geoFeat).forEach(([m,f])=>{const p=document.createElementNS(NS,'path');p.setAttribute('d',pathFor(f,pr));\n      const fillCol=asis?CLOSE_COLOR:(mv.color[unitAssign(m)]||'#444');\n      p.setAttribute('class','muni');p.setAttribute('fill',fillCol);\n      if(!SHOW_MUNI_OUTLINE) p.style.stroke='none';\n      if(pullModeOn(mv)) p.style.opacity='0.35';\n      if(!asis && !pullModeOn(mv)) p.onclick=()=>{editSelected(m2=>{\n        if(!m2.dest.length) return;\n        const cur=m2.assign[m];\n        m2.selMuni=m;\n        if(SESS.settings&&SESS.settings.forceContiguity){\n          const next=nextContigSafeDest(m2,m);\n          if(next===cur && m2.dest.length>1){\n            const d=$('#ds'); if(d){ d.textContent=`não dá pra mover ${mDisp(m)} — toda alternativa quebraria a continuidade de território de algum setor.`; d.classList.add('err'); }\n            return;\n          }\n          m2.assign[m]=next;\n        } else {\n          m2.assign[m]=m2.dest[(m2.dest.indexOf(cur)+1)%m2.dest.length];\n        }\n      });};\n      p.style.cursor=(asis||pullModeOn(mv))?'default':'pointer';\n      if(!pullModeOn(mv)){\n        p.onmousemove=e=>showTip(e,asis?`<b>${mDisp(m)}</b><br>${fmt(mv.cbsByMuni[m]||0)} CBs · setor ${mv.sector}`:`<b>${mDisp(m)}</b><br>${fmt(mv.cbsByMuni[m]||0)} CBs → ${unitAssign(m)||'–'}<br><span style=\"color:var(--mut)\">clique p/ trocar</span>`);\n        p.onmouseleave=()=>$('#tip').style.opacity=0;\n      }\n      svg.appendChild(p);\n      const lp=labelPointOf(mv,'m:'+m,[f],pr);\n      const at=(lp&&lp.p)||cxy(f,pr);\n      const nmFull=(mUf(m)?mUf(m)+' - ':'')+mName(m);\n      if(LEGEND_ON.muni) labels.push({anchor:at,cbs:+(mv.cbsByMuni[m]||0),\n        lines:[{t:nmFull,size:8.2,weight:700}]});\n    });\n  } else {\n    // GV/mercado fechando: a UNIDADE é o setor (ou a GV) inteiro, nunca o município isolado — vários\n    // municípios do MESMO setor têm de ler como UMA área só (stroke = fill inline, opaco, então não há\n    // o artefato de dupla-composição da camada de destino: aqui a cor é sólida, não translúcida).\n    // A fronteira que o operador precisa ver é setor-a-setor, não município-a-município, e isso já\n    // sai de graça da própria cor: setores com destinos diferentes contrastam sem precisar de traço\n    // algum. O único caso que fica sem contorno é dois setores vizinhos caindo no MESMO destino — sem\n    // union real de polígono (ver nota da camada de contexto, acima) não há como bordear só o setor\n    // sem também bordear cada município dentro dele, e a segunda opção é a bagunça pontilhada que essa\n    // troca veio corrigir. UM rótulo só por unidade, no ponto de inacessibilidade da área do grupo,\n    // nunca um rótulo por município.\n    Object.entries(mv.unitMunis||{}).forEach(([u,ms])=>{\n      const fillCol=asis?CLOSE_COLOR:(mv.color[mv.assign[u]]||'#444');\n      const feats=ms.map(m=>mv.geoFeat[m]).filter(Boolean);\n      // Sector-territory outline: ONE outline around this unit's whole dissolved territory, drawn\n      // before any fill of this unit — see the .unit-outline comment (CSS, above) for why order\n      // matters here. Always on for GV/mercado closing (not gated by SHOW_MUNI_OUTLINE, unlike the\n      // município-level toggle used at setor level) — telling one sector's territory apart from its\n      // neighbour's is the whole point of this view, not an optional extra the operator opts into.\n      ms.forEach(m=>{\n        const f=mv.geoFeat[m]; if(!f) return;\n        const bp=document.createElementNS(NS,'path');\n        bp.setAttribute('d',pathFor(f,pr));\n        bp.setAttribute('class','unit-outline');\n        svg.appendChild(bp);\n      });\n      // Todo município deste u é UMA unidade só na tela — passar o mouse sobre qualquer pedaço tem\n      // que acender o grupo inteiro, não só o pedaço que o cursor está tocando. :hover no CSS só\n      // sabe acender o próprio <path>; o grupo inteiro precisa de JS guardando os irmãos.\n      const els=[];\n      ms.forEach(m=>{\n        const f=mv.geoFeat[m]; if(!f) return;\n        const p=document.createElementNS(NS,'path');p.setAttribute('d',pathFor(f,pr));\n        // stroke SEMPRE igual ao fill (nunca 'none', mesmo com o toggle desligado) — dois\n        // municípios opacos da MESMA unidade ainda rasterizam com uma costura fina de antialiasing\n        // na borda compartilhada (mesmo fix já aplicado em .sect.plain/.sect.rejected); só um\n        // stroke da cor exata do fill fecha essa costura, o que é o que faz cada município desta\n        // unidade ler como UM território dissolvido só, em vez de uma malha de municípios\n        // individualmente contornados — exatamente o bug que motivou este comentário.\n        p.setAttribute('class','muni');p.setAttribute('fill',fillCol);p.style.stroke=fillCol;\n        els.push(p);\n        if(!asis) p.onclick=()=>{editSelected(m2=>{\n          if(!m2.dest.length) return;\n          const cur=m2.assign[u];\n          m2.selMuni=u;\n          if(SESS.settings&&SESS.settings.forceContiguity){\n            const next=nextContigSafeDest(m2,u);\n            if(next===cur && m2.dest.length>1){\n              const d=$('#ds'); if(d){ d.textContent=`não dá pra mover ${LEVELS[level].childLabel} ${u} — toda alternativa quebraria a continuidade de território de algum ${lc(LEVELS[level].destLabel)}.`; d.classList.add('err'); }\n              return;\n            }\n            m2.assign[u]=next;\n          } else {\n            m2.assign[u]=m2.dest[(m2.dest.indexOf(cur)+1)%m2.dest.length];\n          }\n        });};\n        p.style.cursor=asis?'default':'pointer';\n        p.onmouseenter=()=>{ els.forEach(e=>e.classList.add('munihl')); };\n        p.onmousemove=e=>showTip(e,asis?`<b>${LEVELS[level].childLabel} ${u}</b><br>${fmt(mv.munis[u]?.cbs||0)} CBs · ${LEVELS[level].label} ${mv.sector}`:`<b>${LEVELS[level].childLabel} ${u}</b><br>${fmt(mv.munis[u]?.cbs||0)} CBs → ${mv.assign[u]||'–'}<br><span style=\"color:var(--mut)\">clique p/ trocar</span>`);\n        p.onmouseleave=()=>{ els.forEach(e=>e.classList.remove('munihl')); $('#tip').style.opacity=0; };svg.appendChild(p);\n      });\n      const lp=labelPointOf(mv,'u:'+u,feats,pr);\n      const at=(lp&&lp.p)||(feats[0]&&cxy(feats[0],pr));\n      if(at && LEGEND_ON.muni){\n        labels.push({anchor:at,big:true,cbs:+(mv.munis[u]?.cbs||0),\n          lines:[{t:u,size:9,weight:700}]});\n      }\n    });\n  }\n  // One clean outline per genuinely multi-owner município, from its FULL original shape — see the\n  // comment where multiBorderFeats is built (above, in the population loop) for why this can't be\n  // done per-slice-fragment. Drawn LAST, after every fill (including the closing sector's own\n  // município fills above) — the shared município's own slice is opaque and painted on top of\n  // whatever came before it, so a border drawn earlier than that fill got silently erased along the\n  // stretch of perimeter running through the closing sector's own piece.\n  Object.values(multiBorderFeats).forEach(feat=>{\n    const mb=document.createElementNS(NS,'path');\n    mb.setAttribute('d',pathFor(feat,pr));\n    mb.setAttribute('class','sect-multi-border');\n    svg.appendChild(mb);\n  });\n  // Every label is drawn directly at its own labelPointOf anchor — already the point of maximum\n  // clearance inside its território — instead of hunting for open canvas space. No leader lines, no\n  // shrinking, no dropped labels: a name always sits on the shape it belongs to.\n  drawOsmLayer(svg,pr,feats,NS);\n  labels.forEach(L=>{\n    const g=document.createElementNS(NS,'g'); g.setAttribute('class','lbl'+(L.big?' big':''));\n    const hTot=L.lines.reduce((a,l)=>a+l.size*1.2,0);\n    let ty=L.anchor[1]-hTot/2+L.lines[0].size*0.86;\n    const ink='#fff', inkMut='rgba(255,255,255,.85)';\n    const halo='rgba(0,0,0,.72)';\n    const swBase=L.big?2.4:1.8;\n    L.lines.forEach(l=>{ const t=document.createElementNS(NS,'text');\n      t.setAttribute('x',L.anchor[0].toFixed(1)); t.setAttribute('y',ty.toFixed(1));\n      t.setAttribute('font-size',l.size); t.setAttribute('data-fs',l.size); t.setAttribute('font-weight',l.weight||600);\n      t.setAttribute('data-sw',swBase); t.style.strokeWidth=swBase+'px';\n      t.style.fill=l.mut?inkMut:ink; t.style.stroke=halo;\n      t.textContent=l.t; g.appendChild(t); ty+=l.size*1.2; });\n    svg.appendChild(g);\n  });\n  const rejectedLegend=(!asis && mv.gvRejected && mv.gvRejected.length)\n    ? `<span><b style=\"background:#e4e4ea;border:1px solid #9797a3\"></b>fora da hierarquia (${mv.gvRejected.length}) — passe o mouse no mapa</span>`\n    : '';\n  $('#legend').innerHTML=asis\n    ?`<span><b style=\"background:${CLOSE_COLOR}\"></b>${LEVELS[level].label} ${mv.sector} (estrutura antes desta etapa — ${level==='setor'?Object.keys(mv.geoFeat).length+' municípios':Object.keys(mv.unitMunis||{}).length+' '+LEVELS[level].childLabel.toLowerCase()+'(s)'})</span>`\n    // \"vizinhos (contexto)\"/\"setor já fechado antes\" (.sect.plain/.sect.closed-ctx) still never\n    // legend — those stay dead. \"rejected\" is the one gray key that's real again: it marks a\n    // geographically-touching setor/GV excluded for failing the hierarchy rule, appended below.\n    :mv.dest.map(s=>`<span><b style=\"background:${mv.color[s]}\"></b>${LEVELS[level].destLabel} ${s} — ${c.gained[s]} líder(es)</span>`).join('')+rejectedLegend;\n  // Municipalities outside the dominance base are deliberately NOT drawn — no shape, no hover, no\n  // legend entry. They are reported in the panel instead, so the map only ever shows territory that\n  // is actually being reallocated.\n  const aliasNote=MUNI_ALIAS_ERROR\n    ? `<div style=\"margin-top:4px\"><b style=\"color:var(--closing)\">⚠ tabela de correções de grafia de município não carregou:</b> ${esc(MUNI_ALIAS_ERROR)} — aliases cadastrados nela não estão em vigor nesta sessão.</div>`\n    : '';\n  if(ignNote) ignNote.innerHTML=((mv.excludedMunis&&mv.excludedMunis.length)\n    ? `<b style=\"color:var(--closing)\">${fmt(mv.excludedMunis.length)} município(s) / ${fmt(mv.excludedCbs||0)} CBs</b> fora da base de dominantes deste setor não são desenhados no mapa — as CBs seguem a líder do seu grupo (detalhe em “Municípios → setor destino”).`\n    : '')+aliasNote;\n  const legendNote=$('#mapLegendNote');\n  if(legendNote){\n    const lvl=LEVELS[level], fem=lvl.de==='da', childPl=CHILD_PLURAL[lvl.childLabel]||lc(lvl.childLabel);\n    legendNote.innerHTML=`Coloridos = ${childPl} ${lvl.de} ${lc(lvl.label)} fechad${fem?'a':'o'} nesta etapa (cor = destino). Ao redor, cada área é ${fem?'uma':'um'} <b>${lc(lvl.label)} vizinh${fem?'a':'o'} inteir${fem?'a':'o'}</b> com o código no meio — não ${childPl} soltos: cor = ${lc(lvl.label)} destino, cinza = contexto, cinza tracejado = ${lc(lvl.label)} já fechad${fem?'a':'o'} numa etapa anterior. Shapes reais IBGE/tbrugz.`;\n  }\n  applyMapView();\n}\nasync function runSql(){const sql=$('#sqlIn').value.trim();$('#sqlStatus').textContent='rodando…';$('#sqlStatus').classList.remove('err');\n  try{ await ensurePinned(); const resp=await q(sql);const rs=rows(resp);const cols=(resp.manifest?.schema?.columns||[]).map(c=>c.name);\n    $('#sqlOut').innerHTML=`<thead><tr>${cols.map(c=>`<th>${c}</th>`).join('')}</tr></thead><tbody>`+rs.slice(0,200).map(r=>`<tr>${cols.map(c=>`<td>${r[c]??'·'}</td>`).join('')}</tr>`).join('')+`</tbody>`;\n    $('#sqlStatus').textContent=rs.length+' linha(s)';}\n  catch(e){$('#sqlStatus').textContent='bloqueado/erro: '+e.message;$('#sqlStatus').classList.add('err');}\n}\nfunction renderAll(){\n  renderStage();\n  fitMapHeight();\n  renderTimeline(); renderSectorPicker();\n  const shb=$('#openShare'); if(shb) shb.disabled=!SESS.timeline.length;\n  const dpb=$('#dupSession'); if(dpb) dpb.disabled=!SESS.timeline.length;\n  const rnb=$('#renameSession'); if(rnb) rnb.disabled=!SESS.timeline.length;\n  const mv=SESS.timeline[SESS.selected];\n  // Display contract everywhere else in this file is \"never show snapshotRaw, only\n  // snapshotSyncedAt\" (see fmtSyncedAt's own comment) — deliberately broken ONLY for a historical\n  // pin, where the whole point of picking a date is confirming which one you're actually looking\n  // at; snapshotSyncedAt there would just say \"now\", telling you nothing about what you picked.\n  const pillEl=$('#liveState');\n  if(pillEl){\n    pillEl.classList.toggle('locked', !!SESS.locked);\n    // Snapshot date and ELO operational cycle are two independently-pinned things (see\n    // ensurePinned/queryEloCycle) — the pill only ever showed the first, leaving the second\n    // invisible even though every ativas/cbs number on screen depends on it just as much.\n    const cycleTxt = SESS.eloMaxCycle ? (' · ciclo ELO '+SESS.eloMaxCycle) : '';\n    if(SESS.locked){\n      pillEl.textContent='🔒 snapshot desta sessão: '+fmtSyncedAt(SESS.snapshotSyncedAt)+cycleTxt+' · clique para atualizar';\n      pillEl.title='Esta sessão foi carregada com os dados já calculados e congelados. Clique para atualizar o snapshot e liberar edição.';\n    } else {\n      pillEl.textContent = SESS.snapshotRaw\n        ? (SESS.snapshotPinnedDate ? '📌 snapshot fixado em '+fmtSyncedAt(SESS.snapshotRaw)+cycleTxt : 'snapshot atualizado em '+fmtSyncedAt(SESS.snapshotSyncedAt)+cycleTxt)\n        : '● iniciando';\n      pillEl.title='Clique pra atualizar o snapshot para o mais recente';\n    }\n  }\n  renderRuleNote(); renderSpanRule();\n  if(!mv){ ['#map','#matrix','#matrixTobe','#tobeNote','#leaders'].forEach(s=>{const el=$(s);if(el)el.innerHTML='';}); $('#kpis').innerHTML=''; $('#spans').innerHTML=''; $('#muniList').innerHTML=''; $('#plan').innerHTML=''; renderXferBreakdown(null,'matrixXfer','matrixXferBlock','matrixXferTitle'); renderXferBreakdown(null,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle'); renderXferLeaders(null); return; }\n  $('#viewToggle').querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.v===mv.view));\n  $('#policy').querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.p===mv.policy));\n  $('#optBadge').textContent=mv.optBadge||'';\n  drawMap(); renderContig(); renderSpanRule(); renderKpis(); renderSpans(); renderMuni(); renderLeaders(); renderMatrix(); renderMatrixTobe(); renderPolicy(); renderPlan(); renderReport(); renderCumulative();\n}\n\n// ---------- event wiring ----------\nwindow.addEventListener('resize', ()=>{ fitMapHeight(); drawMap(); });\n$('#confirmSnap').onclick=()=>{ SESS.stage='main'; renderAll(); };\n$('#eloCycleApply').onclick=async()=>{\n  const v=Math.round(+($('#eloCycleInput').value||''));\n  const s=$('#eloCycleStatus');\n  if(!v||v<=0){ if(s) s.textContent='informe um ciclo válido (número inteiro positivo).'; return; }\n  SESS.eloMaxCycle=v;\n  const btn=$('#eloCycleApply'); if(btn) btn.disabled=true;\n  loader(true,'congelando snapshot da sessão…');\n  try{ await ensurePinned(); }\n  catch(e){ if(s) s.textContent='erro ao congelar snapshot: '+e.message; }\n  finally{ if(btn) btn.disabled=false; loader(false); renderAll(); }\n};\n$('#eloCycleAuto').onclick=async()=>{\n  const btn=$('#eloCycleAuto'), s=$('#eloCycleStatus');\n  const origText=btn?btn.textContent:'';\n  if(btn){ btn.disabled=true; btn.textContent='buscando…'; }\n  loader(true,'congelando snapshot da sessão…');\n  try{\n    const [v]=await Promise.all([queryEloCycle(), ensurePinned()]);\n    if(!v) throw new Error('não foi possível determinar o ciclo mais recente');\n    SESS.eloMaxCycle=v;\n    const inp=$('#eloCycleInput'); if(inp) inp.value=v;\n  }catch(e){\n    if(s) s.textContent='erro: '+e.message;\n  }finally{\n    if(btn){ btn.disabled=false; btn.textContent=origText||'Usar o ciclo mais recente'; }\n    loader(false); renderAll();\n  }\n};\n$('#openMove').onclick=openMove;\n$('#moveClose').onclick=closeMove;\n$('#moveCancel').onclick=closeMove;\n$('#moveBg').addEventListener('click',e=>{ if(e.target.id==='moveBg') closeMove(); });\nroot.querySelectorAll('input[name=\"moveLevel\"]').forEach(r=>r.addEventListener('change',renderMoveForm));\n$('#moveRand').onclick=moveRandPick;\n$('#moveGo').onclick=submitMove;\ninitMacCombo($('#moveCode'), $('#moveCodeList'));\n$('#moveCode').addEventListener('keydown',e=>{ if(e.key==='Enter') submitMove(); });\n$('#confirmSessionPick').onchange=()=>loadSession($('#confirmSessionPick').value);\n$('#optim').onclick=()=>editSelected(mv=>{ mv.assign=optimizeFor(mv); });\n$('#autoTobe').onclick=()=>editSelected(mv=>{ mv.assign=tobeConnectedFor(mv); mv.optBadge='TOBE por adjacência (conectado)'; });\n$('#viewToggle').querySelectorAll('button').forEach(b=>b.onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;mv.view=b.dataset.v;renderAll();});\n$('#mapZoomIn').onclick=()=>zoomMapBy(1.4);\n$('#mapZoomOut').onclick=()=>zoomMapBy(1/1.4);\n// Rodovias é só um atalho de \"marcar/desmarcar todos os níveis viários de uma vez\" — o estado real\n// de cada nível continua vivendo em OSM_ON por chave individual, então esconder um nível depois de\n// carregado é só desmarcar aquele checkbox, sem afetar os outros nem precisar recarregar nada.\nfunction syncRodoviasMaster(){\n  const master=$('#osmRodovias'); if(!master) return;\n  const on=HIGHWAY_KEYS.map(k=>!!OSM_ON[k]);\n  const allOn=on.every(Boolean), allOff=on.every(v=>!v);\n  master.checked=allOn; master.indeterminate=!allOn&&!allOff;\n}\n[...HIGHWAY_KEYS,'rivers','airports'].forEach(k=>{ const el=$('#osm'+k[0].toUpperCase()+k.slice(1)); if(el) el.onchange=()=>{ OSM_ON[k]=el.checked; syncRodoviasMaster(); drawMap(); }; });\nconst muniOutlineEl=$('#muniOutline');\nif(muniOutlineEl) muniOutlineEl.onchange=()=>{ SHOW_MUNI_OUTLINE=muniOutlineEl.checked; drawMap(); };\nconst osmRodoviasEl=$('#osmRodovias');\nif(osmRodoviasEl) osmRodoviasEl.onchange=()=>{\n  const on=osmRodoviasEl.checked;\n  HIGHWAY_KEYS.forEach(k=>{ OSM_ON[k]=on; const el=$('#osm'+k[0].toUpperCase()+k.slice(1)); if(el) el.checked=on; });\n  osmRodoviasEl.indeterminate=false;\n  drawMap();\n};\n['muni','ctx'].forEach(k=>{ const el=$('#legend'+(k==='muni'?'Muni':'Ctx')); if(el) el.onchange=()=>{ LEGEND_ON[k]=el.checked; drawMap(); }; });\nconst osmPanelEl=$('#osmPanel'), osmPanelIconEl=$('#osmPanelIcon'), osmPanelXEl=$('#osmPanelX');\nfunction syncOsmPanelOpen(){\n  if(osmPanelEl) osmPanelEl.classList.toggle('hidden', !OSM_PANEL_OPEN);\n  if(osmPanelIconEl) osmPanelIconEl.classList.toggle('hidden', OSM_PANEL_OPEN);\n}\nif(osmPanelIconEl) osmPanelIconEl.onclick=()=>{ OSM_PANEL_OPEN=true; syncOsmPanelOpen(); };\nif(osmPanelXEl) osmPanelXEl.onclick=()=>{ OSM_PANEL_OPEN=false; syncOsmPanelOpen(); };\nsyncOsmPanelOpen();\n$('#map').addEventListener('pointerdown',mapPointerDown);\n$('#map').addEventListener('wheel',e=>{ e.preventDefault(); zoomMapAt(e.deltaY<0?1.15:1/1.15, e.clientX, e.clientY); }, {passive:false});\nroot.addEventListener('pointermove',mapPointerMove);\nroot.addEventListener('pointerup',mapPointerUp);\nroot.addEventListener('pointerleave',mapPointerUp);\n// Arrastar não pode virar \"cliquei num município e troquei o destino sem querer\": se o ponteiro se\n// moveu além do limiar de arrasto, este clique (capture, antes de chegar no <path>) é engolido.\n$('#map').addEventListener('click',e=>{ if(mapDidDrag){ e.stopPropagation(); e.preventDefault(); mapDidDrag=false; } },true);\n$('#policy').querySelectorAll('button').forEach(b=>b.onclick=()=>{if(guardLocked())return;const mv=SESS.timeline[SESS.selected];if(!mv)return;mv.policy=b.dataset.p;\n  $('#policy').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b));\n  renderKpis();renderPolicy();renderPlan();});\nroot.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('on',x===b));\n  ['matrix','tobe','report','cumulative','sql'].forEach(t=>$('#tab-'+t).classList.toggle('hidden',t!==b.dataset.tab));});\n$('#runSql').onclick=runSql;\n$('#copyPlan').onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;const payload={setor:mv.sector,politica:mv.policy,plano:mv._plan};if(mv.kind==='transfer')payload.transferencia={municipio:mName(mv.xferMuni),uf:mUf(mv.xferMuni),from:mv.xferFrom,to:mv.xferTo,cbs:mv.xferMetrics.cbs};navigator.clipboard.writeText(JSON.stringify(payload,null,2));$('#copyPlan').textContent='copiado ✓';setTimeout(()=>$('#copyPlan').textContent='Copiar',1500);};\n$('#csvPlan').onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;const rowsCsv=mv._plan.map(p=>`${p.municipio},${p.uf||''},${p.cbs},${mv.sector},${p.destino},fechamento`);if(mv.kind==='transfer')rowsCsv.push(`${mName(mv.xferMuni)},${mUf(mv.xferMuni)||''},${mv.xferMetrics.cbs},${mv.xferFrom},${mv.xferTo},transferência`);const csv='municipio,uf,cbs,setor_origem,setor_destino,tipo\\n'+rowsCsv.join('\\n');\n  const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));a.download=`plano_migracao_setor_${mv.sector}.csv`;a.click();};\n$('#saveSession').onclick=saveSession;\n$('#dupSession').onclick=duplicateSession;\n$('#renameSession').onclick=renameSession;\n$('#sessionPick').onchange=()=>loadSession($('#sessionPick').value);\n$('#liveState').onclick=pillClick;\n$('#liveState').addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); pillClick(); } });\n$('#openOptions').onclick=openOptions;\n$('#optClose').onclick=closeOptions;\n$('#optCancel').onclick=closeOptions;\n$('#optApply').onclick=applyOptions;\n['setor','gv'].forEach(lvl=>['#optTargetSpan_','#optMaxSpan_','#optMinSpan_'].forEach(pfx=>$(pfx+lvl).addEventListener('input',renderOptPreview)));\n$('#optionsBg').addEventListener('click',e=>{ if(e.target.id==='optionsBg') closeOptions(); });\n$('#lockedClose').onclick=closeLockedSheet;\n$('#lockedCancel').onclick=closeLockedSheet;\n$('#lockedBg').addEventListener('click',e=>{ if(e.target.id==='lockedBg') closeLockedSheet(); });\n$('#lockedGo').onclick=async()=>{\n  const go=$('#lockedGo');\n  go.disabled=true; go.textContent='Atualizando…';\n  await refreshSnapshot();\n  closeLockedSheet();\n  go.disabled=false; go.textContent='Atualizar snapshot';\n};\nroot.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeOptions(); closeShare(); closeMove(); closeLockedSheet(); } });\n\n$('#openShare').onclick=openShare;\n$('#shareX').onclick=closeShare;\n$('#shareCancel').onclick=closeShare;\n$('#shareGo').onclick=doShare;\n$('#shareCopy').onclick=()=>{ if(!SHARE.url) return; navigator.clipboard.writeText(SHARE.url);\n  const b=$('#shareCopy'); if(b){ b.textContent='copiado ✓'; setTimeout(()=>{ const x=$('#shareCopy'); if(x) x.textContent='Copiar link'; },1500); } };\n$('#shareOpen').onclick=()=>{ if(SHARE.url) window.open(SHARE.url,'_blank'); };\n$('#shareBg').addEventListener('click',e=>{ if(e.target.id==='shareBg') closeShare(); });\n\n// ---------- init ----------\n(async()=>{\n  loader(true,'carregando…');\n  try{\n    // Snapshot pinning (ensurePinned) is deliberately NOT run here anymore — the operator picks the\n    // ELO operational cycle first (input + \"usar o mais recente\" on the confirm card), and only\n    // THAT action actually freezes the snapshot. Everything below is independent of snapLiteral.\n    await Promise.all([loadSectors(), refreshSessionPicker(), loadMuniAliases()]);\n    SESS.stage='confirm';\n  }catch(e){ setError('erro init: '+e.message); }\n  finally{ loader(false); renderAll(); const rd=$('#ready'); if(rd) rd.setAttribute('data-loaded','1'); }\n\n  // ---------- entrada vinda de /simulador/:session_id ----------\n  // O Router já validou o session_id no onEnter da rota (dataset \"Current SEC session\" —\n  // VmnSoVcVXtrYPHkY51pKw) e manda de volta para /simulador quando o id não existe. Chegando\n  // aqui o id na URL é confiável, então só extraímos do pathname e restauramos a sessão de\n  // trabalho — mesmo caminho de código do <select id=\"sessionPick\">, só que disparado pela URL\n  // em vez de um clique.\n  try{\n    const m=window.location.pathname.match(/\\/simulador\\/([^/?#]+)\\/?$/);\n    if(m && m[1]) await loadSession(decodeURIComponent(m[1]));\n  }catch(e){ setError('erro ao carregar sessão da URL: '+e.message); }\n\n  // ---------- entrada vinda da Home (/home) ----------\n  // Fase 13 (pedido do usuário — simplifica a Fase 12): a Home NÃO cria mais sessão nenhuma — só\n  // navega direto pra /simulador com `?apply=nivel:codigo` na URL, sem :session_id nenhum. Esta\n  // entrada roda sobre a sessão FRESCA do boot normal (SESS.stage já virou 'confirm' acima, sem\n  // loadSession nenhum de permeio) e é ELA que cria a sessão, no FINAL — depois de calcular a\n  // movimentação, exatamente como o clique manual em \"Salvar sessão\" já faz. A Home nunca grava\n  // nada em geo-timeline-sessions.\n  try{\n    const alvo=new URLSearchParams(window.location.search).get('apply');\n    if(alvo){\n      // Limpa o parâmetro da URL ANTES de agir. Sem isto, qualquer F5 — ou o restore de aba do\n      // navegador — refaz o fechamento numa timeline nova, em silêncio.\n      try{ history.replaceState({}, '', window.location.pathname); }catch(_){ }\n      const i=String(alvo).indexOf(':');\n      const nivel=i<0?'':String(alvo).slice(0,i).trim(), codigo=i<0?'':String(alvo).slice(i+1).trim();\n      // hasOwnProperty, e não LEVELS[nivel] direto: 'constructor', '__proto__' e 'toString'\n      // devolvem algo truthy do Object.prototype, passariam por este guard, e o usuário receberia\n      // \"Fechar undefined ainda não está disponível\" em vez da mensagem de parâmetro inválido que\n      // existe logo abaixo.\n      if(codigo && Object.prototype.hasOwnProperty.call(LEVELS,nivel)){\n        if(SESS.stage!=='confirm'){\n          // O init falhou — o catch acima já escreveu a causa em #ds e em #confirmStatus. Seguir\n          // daqui trocaria essa causa por um sintoma bem menos claro.\n          setError('a Home pediu para fechar '+nivel+' '+codigo+', mas esta tela não terminou de carregar — a causa é o erro acima. Recarregue depois de resolvê-lo.');\n        } else {\n          // Pula o card de confirmação de snapshot: quem veio da Home já escolheu o que fechar.\n          SESS.stage='main'; renderAll();\n          // Pedido do usuário: o loader tem que ficar visível do início ao fim desta sequência\n          // INTEIRA, sem apagar entre os passos. addMovement()/saveSession() ligam e desligam o\n          // loader cada um por conta própria (para o PRÓPRIO trabalho deles) — a lacuna entre um\n          // desligar e o próximo passo ligar de novo deixava a tela parecer \"pronta\" (0 etapas,\n          // sem spinner) enquanto a rede ainda trabalhava por trás.\n          loader(true,'aplicando movimentação pedida pela Home…');\n          try{\n            // MESMA lógica do botão \"Usar o ciclo mais recente\" (#eloCycleAuto), não uma versão\n            // simplificada dela: queryEloCycle() e ensurePinned() JUNTOS via Promise.all — é\n            // assim que o botão roda —, e um resultado falso INTERROMPE tudo, igual ao botão\n            // real. Sem isto, SESS.eloMaxCycle ficava null, o JOIN do ELO em computeMovement\n            // comparava operational_cycle=NULL (nunca verdadeiro em SQL) e CB/ativas saíam todos\n            // zerados sem erro nenhum sinalizando a causa.\n            if(!SESS.eloMaxCycle){\n              const [v]=await Promise.all([queryEloCycle(), ensurePinned()]);\n              if(!v) throw new Error('não foi possível determinar o ciclo ELO mais recente');\n              SESS.eloMaxCycle=v;\n            }\n            loader(true,'aplicando movimentação pedida pela Home…');\n            await addMovement(nivel,codigo);\n            // addMovement tem saídas silenciosas por desenho (catraca de hierarquia, código já\n            // fechado, setor inexistente) — pensadas pra um clique humano que já está olhando pra\n            // tela, não pra um chamador automático. Sem este teste, qualquer uma delas fazia esta\n            // entrada inteira não fazer NADA, sem uma palavra do porquê.\n            if(!SESS.timeline.length){\n              throw new Error('a movimentação não foi adicionada — a catraca de hierarquia ou outra checagem de addMovement bloqueou silenciosamente.');\n            }\n            // Fase 14 (pedido do usuário — remove o auto-save/redirect da Fase 13): a movimentação\n            // fica na timeline em memória, IGUAL a se o operador tivesse clicado em \"Adicionar\n            // movimentação\" e digitado o código à mão — nada é salvo, a URL continua em\n            // /simulador puro. Quem decide salvar (e quando) é o operador, clicando em \"Salvar\n            // sessão\" — a mesma regra de sempre (\"sessão é o que se SALVA no fim, não o que\n            // inicia o trabalho\") agora vale também pra esta entrada, não só pra Home.\n          }finally{ loader(false); }\n        }\n      } else {\n        setError('parâmetro `apply` inválido na URL: \"'+alvo+'\" — esperado algo como setor:3504');\n      }\n    }\n  }catch(e){ setError('não foi possível aplicar a movimentação pedida pela Home: '+((e&&e.message)||e)); }\n})();\n\n  }\n}\n"}},"sessionsDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"sessionsDataset"}}}},"dominantesCsvUrl":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"dominantesCsvUrl"}}}}}}}}}},{"_id":"6a7f3001bbce4725b9b8a950","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"CNL_4z20SJcX0NMp2I2vz","package":"natura-geomarketing","extends":"data2-core-primitives@Block","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const NIVEIS=['Bronze','Cristal','Prata','Ouro','Diamante','Diamante+'];\n// ---- ported verbatim from the live simulator (pS1W650RpCdVhLm9IkwFi) so a shared session's map\n// reads exactly like the live one: same stable per-sector colour hash, same equal-width band\n// slicing for a município owned by more than one real sector. Pure geometry/math, no live state. ----\nconst PALETTE=['#5856d6','#ff9500','#28cd41','#af52de','#ff2d55','#a2845e'];\nconst CLOSE_COLOR='#8e8e93';\nconst STABLE_SECTOR_COLOR={};\n// Reconstructs the same 'NAME/UF' identity key the live map keys nbrCurrentSector/muniOwners by\n// (mName/mUf/mkey there) — raw GeoJSON feature.properties.name is NOT that key (no UF suffix, and\n// not normalized the same way), so matching a neighbour feature to \"which destino currently owns\n// it\" needs this, not a bare property read. Skips the live map's optional alias-correction table\n// (a Data2 dataset this read-only view has no access to) — it only ever corrects a handful of\n// known IBGE/CRM spelling mismatches, so the plain normalized name matches for everything else.\nconst norm=str=>(str||'').normalize('NFKD').replace(/[̀-ͯ]/g,'').toUpperCase().replace(/[^A-Z0-9]+/g,' ').trim();\nconst mkey=(name,uf)=>{ const n=norm(name), u=String(uf==null?'':uf).trim().toUpperCase(); return u?n+'/'+u:n; };\nfunction colorForSector(code){\n  const k=String(code);\n  if(STABLE_SECTOR_COLOR[k]) return STABLE_SECTOR_COLOR[k];\n  let h=2166136261>>>0;\n  for(let i=0;i<k.length;i++) h=Math.imul(h^k.charCodeAt(i),16777619);\n  const col=PALETTE[(h>>>0)%PALETTE.length];\n  STABLE_SECTOR_COLOR[k]=col;\n  return col;\n}\nfunction clipRingHalf(ring,axis,keepGe,val){\n  const out=[],n=ring.length; if(!n) return out;\n  const side=p=>keepGe?p[axis]>=val:p[axis]<=val;\n  for(let idx=0;idx<n;idx++){\n    const cur=ring[idx],prev=ring[(idx-1+n)%n];\n    const curIn=side(cur),prevIn=side(prev);\n    if(curIn!==prevIn){\n      const t=(val-prev[axis])/(cur[axis]-prev[axis]);\n      const other=1-axis,pt=[0,0]; pt[axis]=val; pt[other]=prev[other]+t*(cur[other]-prev[other]);\n      out.push(pt);\n    }\n    if(curIn) out.push(cur);\n  }\n  return out;\n}\nfunction sliceFeatureEqual(feature,n){\n  if(n<=1) return [feature];\n  const g=feature.geometry,parts=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  const rings=[]; parts.forEach(poly=>poly.forEach(r=>{ if(r&&r.length>=4) rings.push(r); }));\n  let mnx=Infinity,mxx=-Infinity,mny=Infinity,mxy=-Infinity;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  if(!isFinite(mnx)) return Array.from({length:n},()=>({...feature,__empty:true}));\n  const wide=(mxx-mnx)>=(mxy-mny), axis=wide?0:1;\n  const lo=wide?mnx:mny, hi=wide?mxx:mxy, span=(hi-lo)||1;\n  const out=[];\n  for(let idx=0;idx<n;idx++){\n    const a=lo+span*idx/n, b=lo+span*(idx+1)/n;\n    const polys=[];\n    rings.forEach(r=>{\n      let c=clipRingHalf(r,axis,true,a);\n      c=clipRingHalf(c,axis,false,b);\n      if(c.length>=3) polys.push([[...c,c[0]]]);\n    });\n    out.push(polys.length?{type:'Feature',properties:feature.properties,__uf:feature.__uf,geometry:{type:'MultiPolygon',coordinates:polys}}:{...feature,__empty:true});\n  }\n  return out;\n}\n\nexport default {\n  css: `\n  :host{display:block;height:100%}\n  *{box-sizing:border-box}\n  .rroot{--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--closing:#ff3b30;--ok:#34c759;--warn:#ff9500;\n    height:100%;overflow:auto;background:var(--bg);color:var(--ink);\n    font:13px/1.5 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;-webkit-font-smoothing:antialiased}\n  .shell{max-width:1080px;margin:0 auto;padding:26px 22px 70px}\n  header.top{margin-bottom:20px}\n  .eyebrow{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);font-weight:600}\n  h1{font-size:25px;font-weight:600;letter-spacing:-.5px;margin:7px 0 10px}\n  .pills{display:flex;gap:7px;flex-wrap:wrap;align-items:center}\n  .pill{font-size:10.5px;padding:3px 9px;border-radius:99px;border:1px solid var(--line);color:var(--mut);background:var(--panel)}\n  .pill b{color:var(--ink);font-weight:600}\n  .pill.go{cursor:pointer;border-color:var(--acc);color:var(--acc);background:#eaf3ff;font-weight:600}\n  .pill.go:hover{background:#dbe9ff}\n  .goact{margin-top:15px;background:var(--acc);color:#fff;border:1px solid transparent;border-radius:8px;\n    padding:9px 16px;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer}\n  .goact:hover{background:#0060df}\n  .strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:18px 0 22px}\n  .st{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;box-shadow:0 1px 2px rgba(0,0,0,.04)}\n  .st .n{font-size:23px;font-weight:700;letter-spacing:-.5px}\n  .st .l{font-size:10.5px;color:var(--mut);margin-top:3px;line-height:1.4}\n  .st.warn .n{color:var(--closing)}\n  .stepper{position:sticky;top:0;z-index:5;display:flex;gap:7px;overflow-x:auto;padding:11px 0;margin-bottom:6px;\n    background:rgba(245,245,247,.9);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}\n  .sc{flex:0 0 auto;display:flex;align-items:center;gap:7px;background:var(--panel);border:1px solid var(--line);\n    border-radius:99px;padding:5px 12px 5px 6px;cursor:pointer;font-size:12px}\n  .sc:hover{border-color:var(--acc)}\n  .sc .k{width:19px;height:19px;border-radius:99px;background:var(--ink);color:#fff;font-size:10px;font-weight:700;\n    display:flex;align-items:center;justify-content:center}\n  .sc .arrow{color:var(--mut)}\n  .card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin-bottom:16px;box-shadow:0 1px 2px rgba(0,0,0,.04)}\n  .card > h2{font-size:15px;font-weight:600;letter-spacing:-.2px;margin:0 0 3px}\n  .card .sub{font-size:11.5px;color:var(--mut);margin:0 0 15px}\n  h3{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);margin:20px 0 9px;font-weight:600}\n  h3:first-of-type{margin-top:0}\n  .split{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(260px,1fr);gap:20px;align-items:start}\n  @media(max-width:820px){.split{grid-template-columns:1fr}}\n  svg.map{width:100%;height:auto;display:block;background:var(--panel2);border-radius:10px;border:1px solid var(--line);cursor:grab;touch-action:none}\n  svg.map.dragging{cursor:grabbing}\n  .map-wrap{position:relative}\n  /* Everything below through .legend is copied verbatim from the live simulator's own CSS\n     (pS1W650RpCdVhLm9IkwFi), values unchanged — same shadow, same strokes, same opacities, same\n     paint-order for label halos. Only cursor/hover/.munihl states are dropped (this view has no\n     click/hover interactions to affect), and .lbl replaces the old made-up .mlabel/.nlabel/.glabel\n     with the live map's actual label system (white ink drawn inline, paint-order:stroke fill here). */\n  .zoom-pill{position:absolute;left:10px;bottom:10px;display:flex;align-items:stretch;background:var(--panel);border:1px solid var(--line);border-radius:99px;box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);overflow:hidden;z-index:3}\n  .zoom-pill button{width:30px;height:30px;border:0;background:transparent;color:var(--ink);font:16px/1 -apple-system,BlinkMacSystemFont,sans-serif;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center}\n  .zoom-pill button:hover{background:var(--panel2)}\n  .zoom-pill button:active{background:#e5e5ea}\n  .zoom-pill .zoom-div{width:1px;background:var(--line)}\n  .muni{stroke:#fff;stroke-width:1.1;stroke-linejoin:round;stroke-linecap:round}\n  .nbr{fill:#e8e8ec;stroke:#d4d4d9;stroke-width:.7}\n  .exc{fill:#f7e7d0;stroke:var(--warn);stroke-width:.9;stroke-dasharray:2 2}\n  .nlabel{fill:#9a9aa0;font-size:7.5px;text-anchor:middle}\n  .sect{stroke-width:1;stroke-linejoin:round;stroke-linecap:round;fill-opacity:.32;stroke-opacity:.6}\n  .sect.plain{fill:#e8e8ec;fill-opacity:1}\n  .sect.rejected{fill:#e4e4ea;stroke:#e4e4ea;stroke-width:0.6;fill-opacity:1}\n  .sect.unresolved{fill:#fbeceb;stroke:#e8a29c;stroke-dasharray:2 2;fill-opacity:1;stroke-opacity:1}\n  .sect-rejected-border{fill:none;stroke:#b6b6bd;stroke-width:1;stroke-opacity:.6;stroke-linejoin:round;pointer-events:none}\n  .sect-multi-border{fill:none;stroke:#1c1c1e;stroke-width:1.3;stroke-opacity:.5;stroke-linejoin:round;stroke-linecap:round;pointer-events:none}\n  .unit-outline{fill:none;stroke:#fff;stroke-width:1.1;stroke-linejoin:round;stroke-linecap:round;pointer-events:none}\n  .lbl{pointer-events:none;filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.16))}\n  .lbl text{text-anchor:middle;paint-order:stroke fill;stroke-linejoin:round;stroke-linecap:round;letter-spacing:-.1px;font-family:-apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif}\n  .hatchswatch{background:repeating-linear-gradient(45deg,#1c1c1e,#1c1c1e 1px,transparent 1px,transparent 4px);opacity:.35;border:1px solid #d4d4d9}\n  .asistobe{display:grid;grid-template-columns:1fr auto 1fr;align-items:stretch;margin:6px 0 4px}\n  .asistobe-col{padding:0 16px;min-width:0}\n  .asistobe-hd{text-align:center;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--mut);font-weight:700;margin-bottom:9px}\n  .asistobe-div{width:1px;background:var(--line);margin:22px 0}\n  @media(max-width:720px){.asistobe{grid-template-columns:1fr}.asistobe-div{display:none}}\n  .legend{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;font-size:12px}\n  .legend i{display:inline-block;width:11px;height:11px;border-radius:4px;margin-right:5px;vertical-align:-1px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}\n  .bar{display:flex;align-items:center;gap:9px;margin:7px 0}\n  .bar .tag{width:46px;font-weight:600;font-size:12px}\n  .bar .track{flex:1;height:17px;background:var(--panel2);border-radius:5px;overflow:hidden;display:flex}\n  .bar .b1{height:100%;opacity:.38}.bar .b2{height:100%}.bar .b3{height:100%;opacity:.7}\n  .bar .v{width:120px;text-align:right;font-size:11px;color:var(--mut);font-variant-numeric:tabular-nums}\n  .mrow{display:flex;align-items:center;gap:9px;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:6px 10px;margin-bottom:5px}\n  .mrow .nm{flex:1;font-weight:600;font-size:12px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n  .mrow .cb{color:var(--mut);font-size:11px;font-variant-numeric:tabular-nums}\n  .chip{font-size:11px;font-weight:700;color:#fff;border-radius:6px;padding:2px 8px}\n  table{border-collapse:collapse;width:100%;font-size:11.5px}\n  th,td{padding:6px 7px;text-align:right;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}\n  th:first-child,td:first-child{text-align:left}\n  thead th{color:var(--mut);font-weight:600;font-size:10.5px}\n  .dot{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px}\n  .delta{font-size:10px}.delta.up{color:var(--ok)}.delta.dn{color:var(--closing)}\n  .note{font-size:10.5px;color:var(--mut);line-height:1.55;margin-top:9px}\n  .flag{margin-top:11px;padding:9px 11px;border-radius:9px;font-size:11.5px;line-height:1.55;background:#fff8ef;border:1px solid #ffe0bd;color:#8a5200}\n  .scroll{max-height:300px;overflow:auto}\n  .center{display:flex;align-items:center;justify-content:center;height:100%;padding:40px}\n  .msg{max-width:420px;text-align:center}\n  .msg h2{font-size:17px;font-weight:600;margin:0 0 7px}\n  .msg p{font-size:12.5px;color:var(--mut);margin:0;line-height:1.55}\n`,\n  render(d2){\n    // Hierarquia mercado > GV > setor, espelhando o simulador ao vivo. Tudo pré-computado (label,\n    // minúscula, plural, gênero) pra nunca minusculizar \"GV\" (sigla) virando \"gv\", e pra concordar\n    // gênero certo em cada frase — GV é feminina (a Gerência de Vendas), o resto é masculino.\n    const LVL={\n      setor:{label:'Setor',low:'setor',fem:false, child:'Município',childLow:'município',childPlural:'Municípios',childFem:false, dest:'Setor',destLow:'setor'},\n      gv:{label:'GV',low:'GV',fem:true, child:'Setor',childLow:'setor',childPlural:'Setores',childFem:false, dest:'GV',destLow:'GV'},\n      mercado:{label:'Mercado',low:'mercado',fem:false, child:'GV',childLow:'GV',childPlural:'GVs',childFem:true, dest:'Mercado',destLow:'mercado'},\n    };\n    const lv=s=>LVL[(s&&s.level)||'setor'];\n    const esc=s=>(s==null?'':String(s)).replace(/[&<>\"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]));\n    const fmt=n=>n==null?'–':Math.round(+n).toLocaleString('pt-BR');\n    const money=n=>{n=+n||0;return n>=1e6?'R$'+(n/1e6).toFixed(1)+'M':n>=1e3?'R$'+(n/1e3).toFixed(0)+'k':'R$'+Math.round(n)};\n    const msg=(t,b,act)=>`<div class=\"rroot\"><div class=\"center\"><div class=\"msg\"><h2>${t}</h2><p>${b}</p>${act?`<button class=\"goact\" id=\"goSim\">Abrir o simulador</button>`:''}</div></div></div>`;\n\n    if(d2.state.error) return msg('Não foi possível abrir a sessão',esc(d2.state.error),true);\n    if(!d2.state.ready) return msg('Carregando sessão…','Buscando o registro compartilhado.');\n    const r=d2.state.rec;\n    if(!r) return msg('Sessão não encontrada','O link pode ter expirado ou o registro foi removido. Peça um novo link a quem compartilhou.',true);\n\n    const stages=Array.isArray(r.stages)?r.stages:[];\n    const sm=r.summary||{};\n    const st=r.settings||{};\n    const ruleDest=st.destRule==='dominant'?`destino: dominantes ≥${fmt(st.destMinCbs)} CBs`:'destino: qualquer município';\n    const ruleReal=st.realocRule==='relevant'?`realocação: ≥${fmt(st.realocMinCbs)} CBs`:'realocação: todos';\n    const spanBits=[];\n    if(st.targetSpan)spanBits.push('alvo '+fmt(st.targetSpan));\n    if(st.maxSpan)spanBits.push('máx '+fmt(st.maxSpan));\n    if(st.minSpan)spanBits.push('mín '+fmt(st.minSpan));\n\n    let h=`<div class=\"rroot\"><div class=\"shell\">\n      <header class=\"top\">\n        <div class=\"eyebrow\">Simulador de timeline de fechamentos · sessão compartilhada</div>\n        <h1>${esc(r.label||'Sessão sem nome')}</h1>\n        <div class=\"pills\">\n          <span class=\"pill\">● snapshot congelado · <b>${esc(r.snapshotRaw||'—')}</b></span>\n          <span class=\"pill\">◇ ${esc(ruleReal)}</span>\n          <span class=\"pill\">◈ ${esc(ruleDest)}</span>\n          <span class=\"pill\">◎ span: <b>${spanBits.length?esc(spanBits.join(' · ')):'livre'}</b></span>\n          ${r.sharedBy?`<span class=\"pill\">por <b>${esc(r.sharedBy)}</b></span>`:''}\n          ${r.sharedAt?`<span class=\"pill\">${esc(new Date(r.sharedAt).toLocaleString('pt-BR'))}</span>`:''}\n          <span class=\"pill go\" id=\"goSim\" role=\"button\" tabindex=\"0\" title=\"Ir para o simulador\">↗ abrir o simulador</span>\n        </div>\n      </header>`;\n\n    const sessLevel=(stages[0]&&stages[0].level)||'setor';\n    h+=`<div class=\"strip\">\n      <div class=\"st\"><div class=\"n\">${fmt(stages.length)}</div><div class=\"l\">Etapa(s) de fechamento</div></div>\n      <div class=\"st\"><div class=\"n\">${fmt(sm.totalCbs)}</div><div class=\"l\">CBs realocadas na cadeia</div></div>\n      ${sessLevel==='setor'?`<div class=\"st warn\"><div class=\"n\">${fmt(sm.totalDesloc)}</div><div class=\"l\">CBs deslocadas (quebra de relação)</div></div>`:`<div class=\"st\"><div class=\"n\">${fmt(sm.totalDesloc)}</div><div class=\"l\">CBs com líder dividida entre destinos</div></div>`}\n      <div class=\"st\"><div class=\"n\" style=\"font-size:15px;line-height:1.5;font-weight:600\">${esc((sm.sectors||[]).join(' → ')||'—')}</div><div class=\"l\">Ordem de fechamento</div></div>\n    </div>`;\n\n    if(stages.length>1){\n      h+=`<div class=\"stepper\">`+stages.map((s,i)=>`<div class=\"sc\" data-goto=\"stage-${i}\"><span class=\"k\">${i+1}</span>${lv(s).label} ${esc(s.sector)}<span class=\"arrow\">→</span>${esc((s.dest||[]).join(', ')||'–')}</div>`).join('')+`</div>`;\n    }\n\n    // ---- projection helpers (pure; mirrors the simulator's) ----\n    const project=feats=>{let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n      feats.forEach(f=>{const g=f&&f.geometry;if(!g)return;const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(p=>p.forEach(rg=>rg.forEach(pt=>{mnx=Math.min(mnx,pt[0]);mxx=Math.max(mxx,pt[0]);mny=Math.min(mny,pt[1]);mxy=Math.max(mxy,pt[1]);})));});\n      if(!isFinite(mnx)) return null;\n      // W/H match the live simulator's own viewBox (700x500) exactly, not just a similar ratio.\n      const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=700,H=500,pad=16;\n      const gw=Math.max((mxx-mnx)*kx,1e-9),gh=Math.max(mxy-mny,1e-9),sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n      const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n      return {W,H,f:pt=>[ox+(pt[0]-mnx)*kx*sc, oy+(mxy-pt[1])*sc]};};\n    const pathFor=(f,pr)=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      return ps.map(poly=>poly.map(rg=>'M'+rg.map(pt=>{const q=pr(pt);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');};\n    const cxy=(f,pr)=>{let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      ps.forEach(poly=>poly[0].forEach(pt=>{const q=pr(pt);x+=q[0];y+=q[1];n++;}));return n?[x/n,y/n]:[0,0];};\n    // Pole of inaccessibility (ported from the live map's labelPointFor): the interior point\n    // farthest from any boundary, so a label lands inside a concave/lobed territory instead of a\n    // plain vertex-average centroid that can fall outside it. W/H replace the live map's\n    // hardcoded 700x500 viewBox clamp (there it's W-2/H-2 — 698/498) since this viewer's own\n    // project() uses a different canvas size.\n    const labelPointFor=(fs,pr,W,H)=>{\n      const rings=[];\n      (fs||[]).forEach(f=>{ if(!f||!f.geometry) return;\n        const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(poly=>poly.forEach(r=>{\n          if(!r||r.length<4) return;\n          const step=Math.max(1,Math.floor(r.length/120)),pts=[];\n          for(let k=0;k<r.length;k+=step) pts.push(pr(r[k]));\n          if(pts.length>3) rings.push(pts);\n        }));});\n      if(!rings.length) return null;\n      let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n      rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n      mnx=Math.max(mnx,2); mny=Math.max(mny,2); mxx=Math.min(mxx,W-2); mxy=Math.min(mxy,H-2);\n      if(!(mxx>mnx&&mxy>mny)) return null;\n      const inside=(x,y)=>{ let c=false;\n        for(const r of rings) for(let a=0,b=r.length-1;a<r.length;b=a++){ const p1=r[a],p2=r[b];\n          if(((p1[1]>y)!==(p2[1]>y)) && (x<(p2[0]-p1[0])*(y-p1[1])/(p2[1]-p1[1])+p1[0])) c=!c; }\n        return c; };\n      const edgeDist=(x,y)=>{ let m=Infinity;\n        for(const r of rings) for(let a=0,b=r.length-1;a<r.length;b=a++){ const p1=r[a],p2=r[b];\n          const dx=p2[0]-p1[0],dy=p2[1]-p1[1],L=dx*dx+dy*dy;\n          let t=L?(((x-p1[0])*dx+(y-p1[1])*dy)/L):0; t=t<0?0:(t>1?1:t);\n          const ex=p1[0]+t*dx-x,ey=p1[1]+t*dy-y,d=ex*ex+ey*ey; if(d<m)m=d; }\n        return Math.sqrt(m); };\n      const Wd=mxx-mnx,Hd=mxy-mny,N=26;\n      let ccx=0,ccy=0,cn=0;\n      rings.forEach(r=>r.forEach(p=>{ ccx+=p[0]; ccy+=p[1]; cn++; }));\n      ccx/=cn||1; ccy/=cn||1;\n      const CENTER_PULL=0.22;\n      const scoreAt=(x,y,d)=>d-CENTER_PULL*Math.hypot(x-ccx,y-ccy);\n      let best=null,bd=-1,bs=-Infinity;\n      for(let a=0;a<=N;a++) for(let b=0;b<=N;b++){\n        const x=mnx+Wd*a/N,y=mny+Hd*b/N;\n        if(!inside(x,y)) continue;\n        const d=edgeDist(x,y),sc=scoreAt(x,y,d); if(sc>bs){ bs=sc; bd=d; best=[x,y]; }\n      }\n      if(!best) return null;\n      let step=Math.max(Wd,Hd)/N/2;\n      for(let k=0;k<7;k++){\n        let moved=false;\n        for(const o of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]){\n          const x=best[0]+o[0]*step,y=best[1]+o[1]*step;\n          if(x<mnx||x>mxx||y<mny||y>mxy||!inside(x,y)) continue;\n          const d=edgeDist(x,y),sc=scoreAt(x,y,d); if(sc>bs){ bs=sc; bd=d; best=[x,y]; moved=true; }\n        }\n        if(!moved) step/=2;\n      }\n      return {p:best,r:bd};\n    };\n\n    stages.forEach((s,i)=>{\n      const color=s.color||{};\n      const k=s.kpis||{};\n      const LV=lv(s);\n      h+=`<div class=\"card\" id=\"stage-${i}\" data-key=\"stage-${i}\">\n        <h2>Etapa ${i+1} · ${LV.low} <b>${esc(s.sector)}</b> fechad${LV.fem?'a':'o'}</h2>\n        <p class=\"sub\">Território redistribuído para ${esc((s.dest||[]).join(', ')||'—')}${s.domUF?' · '+esc(s.domUF):''}${s.policy?' · política: '+(s.policy==='fora'?'manter vínculo':'quebrar relação'):''}</p>`;\n\n      // ASIS | TOBE map pair, side by side. ASIS: the closing structure as one flat grey block\n      // beside each destino's own PRE-EXISTING território (unchanged). TOBE: the closing\n      // structure's pieces recoloured by their new destino, so a merge reads as literally the\n      // same colour touching itself — no seam — right next to that destino's untouched territory.\n      // Both panels share ONE projection (P), so a shape sits at the identical position/scale in\n      // both — only its colour changes. Only destinos this closure actually touches are drawn,\n      // never the rest of the state (the old context layer's problem).\n      const gf=s.geoFeat||{}, nb=Array.isArray(s.nbrFeat)?s.nbrFeat:[], ex=Array.isArray(s.excludedFeat)?s.excludedFeat:[];\n      const nbrCur=s.nbrCurrentSector||{};\n      const muniOwn=s.muniOwners||{};\n      const shared=Array.isArray(s.sharedMuniSlices)?s.sharedMuniSlices:[];\n      const dest=s.dest||[];\n      // mv.dest is every CANDIDATE destino (up to 6, by design) — not every candidate actually\n      // ends up with a piece of the closing structure. Only the ones that really received\n      // territory (appear as a value in s.assign) get drawn/coloured/legended here; an unused\n      // candidate would otherwise show up as a legend swatch pointing at nothing on the map.\n      const wonDest=dest.filter(code=>Object.values(s.assign||{}).includes(code));\n      const unitOf=m=>LV.label==='Setor'?m:((s.unitOfMuni||{})[m]||m);\n      // A município can genuinely belong to more than one real sector at once (a shared\n      // município, e.g. a big city split among several setores) — attributing it whole to a\n      // single \"current\" sector drew a wrong, sometimes bizarre sliver (one owner's real but\n      // geographically disconnected fragment, shown as if it were the whole município). Sliced\n      // (equal-width band cut, same technique the live map uses) whenever it has more than one\n      // WINNING owner, with a diagonal hatch overlay flagging the split — same signal the live\n      // map itself draws for the same município.\n      const destFeats={};\n      const pushDest=(code,feature,multi)=>{ if(!code||!wonDest.includes(code)) return; (destFeats[code]=destFeats[code]||[]).push({feature,multi}); };\n      // Border geometry for a genuinely multi-owner município is tracked separately from its fill\n      // slices, keyed by município and holding the FULL original (un-sliced) shape — exactly what\n      // the live map itself does, and for the same reason (its own comment, verbatim): sliceFeatureEqual\n      // band-cuts a real, often concave shape into straight-edged pieces, and a real município can\n      // split into several disjoint ring fragments per band; tracing any ONE slice's own edges only\n      // draws part of the true outline. Drawn once per município, from this shape, never a slice.\n      const multiBorderFeats={};\n      nb.forEach(f=>{\n        if(!f||!f.geometry) return;\n        const rawName=(f.properties&&(f.properties.name||f.properties.NOME||f.properties.nome))||'';\n        const nm=mkey(rawName,f.__uf);\n        const allOwners=[...new Set(muniOwn[nm]||[])];\n        const owners=allOwners.filter(c=>wonDest.includes(c)).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n        if(owners.length>1){\n          multiBorderFeats[nm]=f;\n          sliceFeatureEqual(f,owners.length).forEach((slice,idx)=>{ if(!slice.__empty) pushDest(owners[idx],slice,true); });\n        } else if(owners.length===1){\n          if(allOwners.length>1) multiBorderFeats[nm]=f;\n          pushDest(owners[0],f,allOwners.length>1);\n        } else {\n          pushDest(String(nbrCur[nm]||''),f,false);\n        }\n      });\n      // Municípios shared with the CLOSING structure itself (São Paulo etc.) — each sibling\n      // sector's own pre-cut slice, already resolved live. The closing side's own slice of that\n      // same município is already in gf, handled separately below. s.muniFullShape carries the\n      // same full/un-sliced shape the live map stashes before cutting it, for the border above.\n      shared.forEach(sl=>{ if(sl.feature) pushDest(sl.sector?String(sl.sector):'',sl.feature,true); });\n      Object.entries(s.muniFullShape||{}).forEach(([nm,f])=>{ if(f&&!multiBorderFeats[nm]) multiBorderFeats[nm]=f; });\n      // The CLOSING side's own slice of a shared município is exactly as multi-owner as its\n      // sibling slice(s) above — the hatch has to cover it too (setor level only: sharedMuniSlices\n      // keys are município names there, so this is a no-op, never a false match, at GV/mercado level).\n      const sharedWithClosing=new Set(shared.map(sl=>sl.muni));\n      const featsOf=arr=>(arr||[]).map(x=>x.feature);\n      const closingByUnit={};\n      Object.entries(gf).forEach(([m,f])=>{ if(!f) return; const u=unitOf(m); (closingByUnit[u]=closingByUnit[u]||[]).push(f); });\n      const tobeFeats={}; wonDest.forEach(c=>{ tobeFeats[c]=[...(destFeats[c]||[])]; });\n      Object.entries(closingByUnit).forEach(([u,feats])=>{ const c=(s.assign||{})[u]; if(c&&tobeFeats[c]) feats.forEach(f=>tobeFeats[c].push({feature:f,multi:sharedWithClosing.has(u)})); });\n      const all=[...ex,...Object.values(gf),...featsOf(Object.values(destFeats).flat())].filter(Boolean);\n      const P=project(all);\n      let usedHatch=false;\n      if(P){\n        const panel=mode=>{\n          let g=`<defs><pattern id=\"hatch-${mode}-${i}\" width=\"7\" height=\"7\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(45)\"><line x1=\"0\" y1=\"0\" x2=\"0\" y2=\"7\" stroke=\"#1c1c1e\" stroke-width=\"1\" stroke-opacity=\"0.4\"/></pattern></defs>`;\n          let labels=[];\n          ex.forEach(f=>{ if(!f||!f.geometry)return; g+=`<path class=\"exc\" d=\"${pathFor(f,P.f)}\"/>`; });\n          const byCode=mode==='asis'?destFeats:tobeFeats;\n          wonDest.forEach(code=>{ const col=esc(color[code]||'#8e8e93');\n            (byCode[code]||[]).forEach(x=>{ g+=`<path class=\"muni\" d=\"${pathFor(x.feature,P.f)}\" style=\"fill:${col};stroke:${col}\"/>`;\n              if(x.multi){ usedHatch=true; g+=`<path d=\"${pathFor(x.feature,P.f)}\" fill=\"url(#hatch-${mode}-${i})\" style=\"pointer-events:none\"/>`; } }); });\n          if(mode==='asis'){\n            Object.entries(closingByUnit).forEach(([u,feats])=>{ feats.forEach(f=>{ g+=`<path class=\"muni\" d=\"${pathFor(f,P.f)}\" style=\"fill:${CLOSE_COLOR};stroke:${CLOSE_COLOR}\"/>`;\n              if(sharedWithClosing.has(u)){ usedHatch=true; g+=`<path d=\"${pathFor(f,P.f)}\" fill=\"url(#hatch-${mode}-${i})\" style=\"pointer-events:none\"/>`; } }); });\n            const lp=labelPointFor(Object.values(closingByUnit).flat(),P.f,P.W,P.H);\n            if(lp&&lp.p) labels.push({anchor:lp.p,big:true,lines:[{t:s.sector,size:11,weight:700}]});\n          }\n          // One clean outline per multi-owner município, from its FULL original shape — drawn\n          // LAST, after every fill (both destino territory and the closing side), so an internal\n          // seam between its own pieces always ends up painted over, leaving only the true\n          // external edge visible on top. Exactly the live map's own multi-border ordering.\n          Object.values(multiBorderFeats).forEach(feat=>{ g+=`<path class=\"sect-multi-border\" d=\"${pathFor(feat,P.f)}\"/>`; });\n          wonDest.forEach(code=>{\n            const arr=byCode[code]||[]; if(!arr.length) return;\n            const lp=labelPointFor(featsOf(arr),P.f,P.W,P.H);\n            if(lp&&lp.p) labels.push({anchor:lp.p,big:true,lines:[{t:code,size:11,weight:700}]});\n          });\n          labels.forEach(L=>{\n            const hTot=L.lines.reduce((a,l)=>a+l.size*1.2,0);\n            let ty=L.anchor[1]-hTot/2+L.lines[0].size*0.86;\n            const swBase=L.big?2.4:1.8;\n            g+=`<g class=\"lbl\">`;\n            L.lines.forEach(l=>{ g+=`<text x=\"${L.anchor[0].toFixed(1)}\" y=\"${ty.toFixed(1)}\" font-size=\"${l.size}\" data-fs=\"${l.size}\" font-weight=\"${l.weight||600}\" data-sw=\"${swBase}\" style=\"stroke-width:${swBase}px;fill:#fff;stroke:rgba(0,0,0,.72)\">${esc(l.t)}</text>`; ty+=l.size*1.2; });\n            g+=`</g>`;\n          });\n          return g;\n        };\n        const gAsis=panel('asis'), gTobe=panel('tobe');\n        h+=`<div class=\"asistobe\">\n          <div class=\"asistobe-col\"><div class=\"asistobe-hd\">ASIS</div>\n            <svg class=\"map\" id=\"asis-${i}\" data-w=\"${P.W}\" data-h=\"${P.H}\" viewBox=\"0 0 ${P.W} ${P.H}\" preserveAspectRatio=\"xMidYMid meet\">${gAsis}</svg></div>\n          <div class=\"asistobe-div\"></div>\n          <div class=\"asistobe-col\"><div class=\"asistobe-hd\">TOBE</div>\n            <svg class=\"map\" id=\"tobe-${i}\" data-w=\"${P.W}\" data-h=\"${P.H}\" viewBox=\"0 0 ${P.W} ${P.H}\" preserveAspectRatio=\"xMidYMid meet\">${gTobe}</svg></div>\n        </div>\n        <div class=\"legend\"><span><i style=\"background:${esc(CLOSE_COLOR)}\"></i>${LV.low} ${esc(s.sector)} (fechando)</span>\n          ${wonDest.map(d=>`<span><i style=\"background:${esc(color[d]||'#8e8e93')}\"></i>${LV.dest} ${esc(d)}</span>`).join('')}\n          ${usedHatch?`<span><i class=\"hatchswatch\"></i>município com mais de um setor</span>`:''}\n          ${ex.length?`<span><i style=\"background:#f7e7d0;border:1px dashed #ff9500\"></i>fora da realocação</span>`:''}</div>`;\n      }\n\n      h+=`<div class=\"strip\" style=\"margin:18px 0\">\n          <div class=\"st\"><div class=\"n\">${fmt(k.cbsRealocadas)}</div><div class=\"l\">CBs realocadas</div></div>\n          ${LV.label==='Setor'?`<div class=\"st warn\"><div class=\"n\">${fmt(k.desloc)}</div><div class=\"l\">CBs deslocadas</div></div>`:`<div class=\"st\"><div class=\"n\">${fmt(k.desloc)}</div><div class=\"l\">CBs com líder dividida</div></div>`}\n          <div class=\"st\"><div class=\"n\">${fmt(k.municipios)}</div><div class=\"l\">${LV.childPlural} movid${LV.childFem?'as':'os'}</div></div>\n          <div class=\"st\"><div class=\"n\">${fmt(k.lideres)}</div><div class=\"l\">Líderes envolvidas</div></div>\n        </div>\n        <div class=\"split\"><div>`;\n\n      h+=`<h3>${LV.childPlural} → ${LV.destLow} destino</h3>`;\n      h+=(s.municipios||[]).map(m=>`<div class=\"mrow\"><span class=\"nm\">${esc(m.nome)}</span><span class=\"cb\">${fmt(m.cbs)} CBs</span>\n        <span class=\"chip\" style=\"background:${esc(color[m.destino]||'#8e8e93')}\">${esc(m.destino||'–')}</span></div>`).join('')||`<div class=\"note\">Nenhum${LV.childFem?'a':''} ${LV.childLow} realocad${LV.childFem?'a':'o'} nesta etapa.</div>`;\n\n      h+=`</div><div>`;\n      // spans\n      const ds=s.destinos||[];\n      const mx=Math.max(...ds.map(d=>+d.span||0),1);\n      h+=`<h3>Tamanho d${LV.fem?'a':'o'} ${LV.destLow} depois da etapa</h3>`;\n      h+=ds.map(d=>{const base=+d.base||0,inc=+d.inc||0,orph=+d.orph||0;\n        return `<div class=\"bar\"><span class=\"tag\" style=\"color:${esc(color[d.s]||'#8e8e93')}\">${esc(d.s)}</span>\n          <span class=\"track\"><span class=\"b1\" style=\"width:${(base/mx*100).toFixed(1)}%;background:${esc(color[d.s]||'#8e8e93')}\"></span><span class=\"b2\" style=\"width:${(inc/mx*100).toFixed(1)}%;background:${esc(color[d.s]||'#8e8e93')}\"></span>${orph?`<span class=\"b3\" style=\"width:${(orph/mx*100).toFixed(1)}%;background:${esc(color[d.s]||'#8e8e93')}\"></span>`:''}</span>\n          <span class=\"v\">${fmt(d.span)} <span style=\"opacity:.6\">(+${fmt(inc+orph)})</span></span></div>`;}).join('')||`<div class=\"note\">Sem ${LV.destLow} destino nesta etapa.</div>`;\n      h+=`<div class=\"note\">Claro = base antes da etapa · cheio = CBs entrantes${(s.orphanTotal?' · faixa final = CBs que seguiram suas líderes':'')}.</div>`;\n\n      // leaders\n      const L=s.lideres||[];\n      if(L.length){\n        const showOrph=L.some(x=>+x.orphan>0);\n        h+=`<h3>Líderes</h3><div class=\"scroll\"><table><thead><tr><th>Líder</th><th>CBs</th><th>${LV.dest} final</th>${showOrph?'<th>Órfãs</th>':''}<th>Desloc.</th></tr></thead><tbody>`\n          +L.map(x=>`<tr><td>${esc(x.lider||'—')}</td><td>${fmt(x.total)}</td>\n            <td>${x.home?`<span class=\"dot\" style=\"background:${esc(color[x.home]||'#8e8e93')}\"></span>${esc(x.home)}`:'–'}</td>\n            ${showOrph?`<td>${x.orphan?fmt(x.orphan):'·'}</td>`:''}\n            <td style=\"color:${+x.desloc?'var(--closing)':'var(--mut)'};font-weight:${+x.desloc?700:400}\">${fmt(x.desloc||0)}</td></tr>`).join('')\n          +`</tbody></table></div>`;\n      }\n      h+=`</div></div>`;\n\n      // antes -> depois\n      const M=s.metricas||[];\n      if(M.length){\n        h+=`<h3>Impacto antes → depois por ${LV.destLow} ${LV.fem?'receptora':'receptor'}</h3><div class=\"scroll\"><table><thead><tr><th>${LV.dest}</th>${M.map(m=>`<th>${esc(m.label)}</th>`).join('')}</tr></thead><tbody>`;\n        (s.dest||[]).forEach(sec=>{\n          h+=`<tr><td><span class=\"dot\" style=\"background:${esc(color[sec]||'#8e8e93')}\"></span>${esc(sec)}</td>`;\n          h+=M.map(m=>{const row=(m.rows||[]).find(x=>String(x.s)===String(sec))||{};\n            const a=+row.antes||0,b=+row.depois||0,d=a?((b-a)/a*100):0;const f=m.money?money:fmt;\n            return `<td>${f(b)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;}).join('')+`</tr>`;\n        });\n        h+=`<tr style=\"opacity:.75\"><td>${esc(s.sector)} (sai)</td>`+M.map(m=>`<td>${(m.money?money:fmt)(m.sai||0)}</td>`).join('')+`</tr>`;\n        h+=`</tbody></table></div>`;\n      }\n\n      // caveats\n      const fl=[];\n      if(s.excluidos&&s.excluidos.length) fl.push(`<b>${fmt(s.excluidos.length)} município(s)</b> com poucas CBs ficaram fora da realocação geográfica (quebra de vínculo): ${esc(s.excluidos.slice(0,6).map(x=>x.muni+' ('+x.cbs+')').join(', '))}${s.excluidos.length>6?' +'+(s.excluidos.length-6):''}. As CBs seguiram a líder do seu grupo.`);\n      if(s.orphanStranded) fl.push(`<b>${fmt(s.orphanStranded)} CB(s)</b> sem setor a seguir — a líder não ficou com nenhum município realocado.`);\n      if(s.noShapeCbs) fl.push(`<b>${fmt(s.noShapeCbs)} CB(s)</b> em municípios sem shape na UF, fora do mapa e de qualquer setor destino.`);\n      if(s.optTier===1) fl.push(`O span mínimo não era atingível nesta etapa e foi relaxado pelo modelo.`);\n      if(s.optTier===2) fl.push(`Os limites de span não eram atingíveis nesta etapa e foram relaxados pelo modelo.`);\n      if(s.optTier===-1) fl.push(`Etapa resolvida por heurística (espaço de busca grande) — os limites de span não são garantidos.`);\n      if(s.destRuleFallback) fl.push(`Nenhum município dominante nesta etapa: a regra de destino foi relaxada para \"qualquer município\".`);\n      if(s.realocFallback) fl.push(`Todos os municípios ficariam abaixo do limite: a regra de realocação foi relaxada para \"realocar todos\".`);\n      if(fl.length) h+=`<div class=\"flag\">⚠ ${fl.join('<br>')}</div>`;\n      h+=`</div>`;\n    });\n\n    // cumulative\n    // Este resumo é calculado no rastro de município (world.muniOverride) — hoje só existe pro\n    // fechamento de setor; GV/mercado têm o rastro equivalente (secOverride/gvOverride) mas essa\n    // agregação ainda não foi refeita nesse grão, então avisa em vez de mostrar tabela vazia.\n    const rc=sm.receivers||[];\n    if(!rc.length && sessLevel!=='setor'){\n      h+=`<div class=\"card\"><h2>Impacto acumulado da cadeia</h2>\n        <p class=\"note\" style=\"margin-top:0\">Ainda não disponível pra sessões de ${sessLevel==='gv'?'GV':'mercado'} — veja o relatório de cada etapa acima.</p></div>`;\n    }\n    if(rc.length){\n      h+=`<div class=\"card\"><h2>Impacto acumulado da cadeia</h2>\n        <p class=\"sub\">Efeito de <b>todas</b> as ${fmt(stages.length)} etapa(s) somadas, contra o snapshot congelado sem nenhum fechamento aplicado.</p>\n        <div class=\"scroll\"><table><thead><tr><th>Setor</th><th>Situação</th><th>CBs antes</th><th>CBs depois</th><th>Variação</th></tr></thead><tbody>`\n        +rc.map(x=>{const a=+x.antes||0,b=+x.depois||0,d=a?((b-a)/a*100):0;\n          return `<tr><td>${esc(x.s)}</td><td>${esc(x.situacao||'recebeu território')}</td><td>${fmt(a)}</td><td>${fmt(b)}</td>\n            <td class=\"delta ${d>=0?'up':'dn'}\" style=\"font-size:11.5px\">${d>0?'+':''}${d.toFixed(0)}%</td></tr>`;}).join('')\n        +`</tbody></table></div></div>`;\n    }\n\n    h+=`<div class=\"note\" style=\"text-align:center;margin-top:22px\">Visualização somente leitura de uma simulação congelada. Os números refletem o snapshot <b>${esc(r.snapshotRaw||'—')}</b> e as regras vigentes no momento do compartilhamento — não acompanham o dado ao vivo.</div>`;\n    return h+`</div></div>`;\n  },\n  mounted(d2, root){\n    d2.on('#goSim','click',()=>{\n      try{ (d2.router('timelineSimRouter')||d2.router()).goTo('/'); }catch(e){ try{ d2.router().goTo('/'); }catch(e2){} }\n    });\n    d2.on('.sc','click',e=>{\n      const el=e.target.closest('.sc'); if(!el) return;\n      const t=d2.$('#'+el.dataset.goto); if(t) t.scrollIntoView({behavior:'smooth',block:'start'});\n    });\n    // ---- map zoom/pan (viewBox só — texto e polígonos vivem no mesmo sistema de coordenadas, então\n    // escalam juntos automaticamente ao encolher/esticar o viewBox, sem lógica extra pra fonte).\n    // Delegado em `root` (nunca substituído por re-render) em vez de nos <svg> por id, porque cada\n    // atualização de estado reconstrói o HTML das etapas do zero.\n    const mapViews={};\n    const getMapView=(i,W,H)=>mapViews[i]||(mapViews[i]={x:0,y:0,w:W,h:H});\n    const applyMapView=(svg,i)=>{ const v=mapViews[i]; if(!v) return;\n      svg.setAttribute('viewBox', v.x.toFixed(2)+' '+v.y.toFixed(2)+' '+v.w.toFixed(2)+' '+v.h.toFixed(2));\n      // Rótulo tem tamanho em unidades do viewBox — sem isso ele cresceria junto com o zoom até virar\n      // um borrão. Encolher na mesma proporção que o viewBox encolhe cancela o zoom: o texto fica do\n      // mesmo tamanho na tela em qualquer nível. style.fontSize (não o atributo) porque uma regra de\n      // classe como .mlabel{font-size:...} outrankaria um atributo comum.\n      const W=+svg.dataset.w||700;\n      const fsScale=v.w/W;\n      svg.querySelectorAll('[data-fs]').forEach(t=>{\n        const base=+t.getAttribute('data-fs')||8;\n        t.style.fontSize=(base*fsScale).toFixed(2)+'px';\n      });\n      // Halo do texto (contorno branco que garante legibilidade sobre qualquer cor de fundo) tem\n      // largura de traço fixa em unidades do viewBox — sem reescalar junto, no zoom alto ele vira\n      // uma mancha cobrindo a letra inteira em vez de um contorno fino.\n      svg.querySelectorAll('[data-sw]').forEach(t=>{\n        const base=+t.getAttribute('data-sw')||1;\n        t.style.strokeWidth=(base*fsScale).toFixed(3)+'px';\n      });\n    };\n    const zoomStageMap=(i,factor)=>{\n      const svg=root.querySelector('#map-'+i); if(!svg) return;\n      const W=+svg.dataset.w||700, H=+svg.dataset.h||500;\n      const v=getMapView(i,W,H);\n      const cx=v.x+v.w/2, cy=v.y+v.h/2;\n      let w=v.w/factor, h=v.h/factor;\n      w=Math.min(W, Math.max(W/8, w)); h=Math.min(H, Math.max(H/8, h));\n      let x=cx-w/2, y=cy-h/2;\n      x=Math.min(W-w, Math.max(0,x)); y=Math.min(H-h, Math.max(0,y));\n      mapViews[i]={x,y,w,h};\n      applyMapView(svg,i);\n    };\n    d2.on('.zoom-pill button','click',e=>{\n      const b=e.target.closest('button'); if(!b) return;\n      zoomStageMap(b.dataset.i, b.dataset.zoom==='in'?1.4:1/1.4);\n    });\n    let mapDrag=null;\n    root.addEventListener('pointerdown',e=>{\n      const svg=e.target.closest('svg.map'); if(!svg) return;\n      const i=svg.id.replace('map-',''); const W=+svg.dataset.w||700, H=+svg.dataset.h||500;\n      const v=getMapView(i,W,H);\n      mapDrag={svg,i,W,H,x0:e.clientX,y0:e.clientY,vx0:v.x,vy0:v.y,rect:svg.getBoundingClientRect()};\n      svg.classList.add('dragging');\n    });\n    root.addEventListener('pointermove',e=>{\n      if(!mapDrag||!mapDrag.rect.width||!mapDrag.rect.height) return;\n      const dxPix=e.clientX-mapDrag.x0, dyPix=e.clientY-mapDrag.y0;\n      const v=getMapView(mapDrag.i,mapDrag.W,mapDrag.H);\n      const sx=v.w/mapDrag.rect.width, sy=v.h/mapDrag.rect.height;\n      let x=mapDrag.vx0-dxPix*sx, y=mapDrag.vy0-dyPix*sy;\n      x=Math.min(mapDrag.W-v.w, Math.max(0,x)); y=Math.min(mapDrag.H-v.h, Math.max(0,y));\n      mapViews[mapDrag.i]={...v,x,y};\n      applyMapView(mapDrag.svg,mapDrag.i);\n    });\n    const endMapDrag=()=>{ if(mapDrag){ mapDrag.svg.classList.remove('dragging'); mapDrag=null; } };\n    root.addEventListener('pointerup',endMapDrag);\n    root.addEventListener('pointerleave',endMapDrag);\n    (async()=>{\n      try{\n        // RACE, not a missing dataset. This DataSet is gated by timelineSimAuthFinal, so its\n        // Process is only define()d once the auth session resolves — a Process waits for its\n        // dependencies' setup before its own fires. In the field the auth lookup lands ~200ms\n        // and this DataSet's first fetch ~700-900ms, while mounted() runs immediately. d2.data()\n        // returns null for a module that is not defined YET, so bailing on the first miss threw\n        // \"dataset não disponível\" on every share link even though nothing was wrong.\n        // Poll briefly instead. Two lookups because either may win: the declared `shared` param,\n        // or the global-process path via the reference the instance binds — keep that binding.\n        let dead=false; d2.onUnmount(()=>{ dead=true; });\n        let ds=null;\n        for(let i=0;i<40 && !ds && !dead;i++){\n          ds=d2.data('shared')||d2.data('timeline-shared-sessionsDataSet');\n          if(!ds) await new Promise(r=>setTimeout(r,100));\n        }\n        if(dead) return;\n        if(!ds){ d2.setState({error:'Dataset de sessões compartilhadas não disponível.',ready:true}); return; }\n        // Name the router explicitly. Bare d2.router() looks for the conventional\n        // 'Router'/'MainRouter'; this one is 'timelineSimRouter', so the bare call can hand back a\n        // handle whose .params is empty — which is how a valid /sessions/<id> link ended up\n        // reporting \"o link não traz o identificador\". The goSim handler below already passes the\n        // ref for the same reason.\n        const rt=d2.router('timelineSimRouter')||d2.router();\n        const p=(rt&&rt.params)||{};\n        // ?id= lets the artboard be opened directly, outside the route.\n        let q=null;\n        try{ q=new URLSearchParams(window.location.search).get('id'); }catch(e){}\n        // Last resort, and the only one that cannot fail: the id is sitting in the path. Route\n        // params need the Router handle to resolve AND to have matched by the time mounted() runs\n        // (.params is a reactive getter, read once here); the pathname needs neither. Matches the\n        // current /sessions/:session_id route and the legacy /sessao/:id one.\n        let seg=null;\n        try{ const m=window.location.pathname.match(/\\/sess(?:ions|ao)\\/([^/?#]+)/); seg=m&&m[1]; }catch(e){}\n        const id=p.session_id||p.id||p.sessao||p.session||seg||q;\n        if(!id){ d2.setState({error:'O link não traz o identificador da sessão.',ready:true}); return; }\n        // .query() is a direct read and returns the rows — no refresh/loadAll ordering trap here\n        const rows=await ds.query({_id:id});\n        const rec=Array.isArray(rows)?rows[0]:(rows&&rows.data&&rows.data[0]);\n        d2.setState({rec:rec||null,ready:true,error:''});\n      }catch(err){\n        d2.setState({error:(err&&err.message)||'Falha ao carregar a sessão compartilhada.',ready:true});\n      }\n    })();\n  }\n}"}},"shared":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"shared"}}}}}}}}}},{"_id":"6a7f3024f798d7d41c74440f","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"Y7eEvdJeC4azwvfDtwrYP","package":"natura-geomarketing","extends":"data2-core-primitives@Block","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const NIVEIS=['Bronze','Cristal','Prata','Ouro','Diamante','Diamante+'];\n// ---- ported verbatim from the live simulator (pS1W650RpCdVhLm9IkwFi) so a shared session's map\n// reads exactly like the live one: same stable per-sector colour hash, same equal-width band\n// slicing for a município owned by more than one real sector. Pure geometry/math, no live state. ----\nconst PALETTE=['#5856d6','#ff9500','#28cd41','#af52de','#ff2d55','#a2845e'];\nconst CLOSE_COLOR='#8e8e93';\nconst STABLE_SECTOR_COLOR={};\nfunction colorForSector(code){\n  const k=String(code);\n  if(STABLE_SECTOR_COLOR[k]) return STABLE_SECTOR_COLOR[k];\n  let h=2166136261>>>0;\n  for(let i=0;i<k.length;i++) h=Math.imul(h^k.charCodeAt(i),16777619);\n  const col=PALETTE[(h>>>0)%PALETTE.length];\n  STABLE_SECTOR_COLOR[k]=col;\n  return col;\n}\nfunction clipRingHalf(ring,axis,keepGe,val){\n  const out=[],n=ring.length; if(!n) return out;\n  const side=p=>keepGe?p[axis]>=val:p[axis]<=val;\n  for(let idx=0;idx<n;idx++){\n    const cur=ring[idx],prev=ring[(idx-1+n)%n];\n    const curIn=side(cur),prevIn=side(prev);\n    if(curIn!==prevIn){\n      const t=(val-prev[axis])/(cur[axis]-prev[axis]);\n      const other=1-axis,pt=[0,0]; pt[axis]=val; pt[other]=prev[other]+t*(cur[other]-prev[other]);\n      out.push(pt);\n    }\n    if(curIn) out.push(cur);\n  }\n  return out;\n}\nfunction sliceFeatureEqual(feature,n){\n  if(n<=1) return [feature];\n  const g=feature.geometry,parts=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  const rings=[]; parts.forEach(poly=>poly.forEach(r=>{ if(r&&r.length>=4) rings.push(r); }));\n  let mnx=Infinity,mxx=-Infinity,mny=Infinity,mxy=-Infinity;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  if(!isFinite(mnx)) return Array.from({length:n},()=>({...feature,__empty:true}));\n  const wide=(mxx-mnx)>=(mxy-mny), axis=wide?0:1;\n  const lo=wide?mnx:mny, hi=wide?mxx:mxy, span=(hi-lo)||1;\n  const out=[];\n  for(let idx=0;idx<n;idx++){\n    const a=lo+span*idx/n, b=lo+span*(idx+1)/n;\n    const polys=[];\n    rings.forEach(r=>{\n      let c=clipRingHalf(r,axis,true,a);\n      c=clipRingHalf(c,axis,false,b);\n      if(c.length>=3) polys.push([[...c,c[0]]]);\n    });\n    out.push(polys.length?{type:'Feature',properties:feature.properties,__uf:feature.__uf,geometry:{type:'MultiPolygon',coordinates:polys}}:{...feature,__empty:true});\n  }\n  return out;\n}\n\nexport default {\n  css: `\n  :host{display:block;height:100%}\n  *{box-sizing:border-box}\n  .rroot{--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--closing:#ff3b30;--ok:#34c759;--warn:#ff9500;\n    height:100%;overflow:auto;background:var(--bg);color:var(--ink);\n    font:13px/1.5 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;-webkit-font-smoothing:antialiased}\n  .shell{max-width:1080px;margin:0 auto;padding:26px 22px 70px}\n  header.top{margin-bottom:20px}\n  .eyebrow{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);font-weight:600}\n  h1{font-size:25px;font-weight:600;letter-spacing:-.5px;margin:7px 0 10px}\n  .pills{display:flex;gap:7px;flex-wrap:wrap;align-items:center}\n  .pill{font-size:10.5px;padding:3px 9px;border-radius:99px;border:1px solid var(--line);color:var(--mut);background:var(--panel)}\n  .pill b{color:var(--ink);font-weight:600}\n  .pill.go{cursor:pointer;border-color:var(--acc);color:var(--acc);background:#eaf3ff;font-weight:600}\n  .pill.go:hover{background:#dbe9ff}\n  .goact{margin-top:15px;background:var(--acc);color:#fff;border:1px solid transparent;border-radius:8px;\n    padding:9px 16px;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer}\n  .goact:hover{background:#0060df}\n  .strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:18px 0 22px}\n  .st{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;box-shadow:0 1px 2px rgba(0,0,0,.04)}\n  .st .n{font-size:23px;font-weight:700;letter-spacing:-.5px}\n  .st .l{font-size:10.5px;color:var(--mut);margin-top:3px;line-height:1.4}\n  .st.warn .n{color:var(--closing)}\n  .stepper{position:sticky;top:0;z-index:5;display:flex;gap:7px;overflow-x:auto;padding:11px 0;margin-bottom:6px;\n    background:rgba(245,245,247,.9);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}\n  .sc{flex:0 0 auto;display:flex;align-items:center;gap:7px;background:var(--panel);border:1px solid var(--line);\n    border-radius:99px;padding:5px 12px 5px 6px;cursor:pointer;font-size:12px}\n  .sc:hover{border-color:var(--acc)}\n  .sc .k{width:19px;height:19px;border-radius:99px;background:var(--ink);color:#fff;font-size:10px;font-weight:700;\n    display:flex;align-items:center;justify-content:center}\n  .sc .arrow{color:var(--mut)}\n  .card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin-bottom:16px;box-shadow:0 1px 2px rgba(0,0,0,.04)}\n  .card > h2{font-size:15px;font-weight:600;letter-spacing:-.2px;margin:0 0 3px}\n  .card .sub{font-size:11.5px;color:var(--mut);margin:0 0 15px}\n  h3{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);margin:20px 0 9px;font-weight:600}\n  h3:first-of-type{margin-top:0}\n  .split{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:20px;align-items:start}\n  @media(max-width:820px){.split{grid-template-columns:1fr}}\n  svg.map{width:100%;height:auto;display:block;background:var(--panel2);border-radius:10px;border:1px solid var(--line);cursor:grab;touch-action:none}\n  svg.map.dragging{cursor:grabbing}\n  .map-wrap{position:relative}\n  .zoom-pill{position:absolute;left:10px;bottom:10px;display:flex;align-items:stretch;background:var(--panel);border:1px solid var(--line);border-radius:99px;box-shadow:0 2px 8px rgba(0,0,0,.14);overflow:hidden;z-index:3}\n  .zoom-pill button{width:30px;height:30px;border:0;background:transparent;color:var(--ink);font:16px/1 -apple-system,BlinkMacSystemFont,sans-serif;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center}\n  .zoom-pill button:hover{background:var(--panel2)}\n  .zoom-pill button:active{background:#e5e5ea}\n  .zoom-pill .zoom-div{width:1px;background:var(--line)}\n  .muni{stroke:#fff;stroke-width:1.1}\n  .nbr{fill:#e8e8ec;stroke:#d4d4d9;stroke-width:.7}\n  .exc{fill:#f7e7d0;stroke:var(--warn);stroke-width:.9;stroke-dasharray:2 2}\n  .mlabel{fill:#1c1c1e;font-size:9.5px;font-weight:600;text-anchor:middle;paint-order:stroke;stroke:rgba(255,255,255,.7);stroke-width:2.4px}\n  .nlabel{fill:#9a9aa0;font-size:7.5px;text-anchor:middle}\n  .glabel{fill:#1c1c1e;font-size:11.5px;font-weight:700;text-anchor:middle;paint-order:stroke;stroke:rgba(255,255,255,.75);stroke-width:2.2px}\n  .sect{stroke:none}\n  .sect.novalid{fill:#e8e8ec}\n  .sect.rejected{fill:#e4e4ea}\n  .sect.unresolved{fill:#fbeceb;stroke:#e8a29c;stroke-width:1;stroke-dasharray:2 2}\n  .sect-rejected-border{fill:none;stroke:#b6b6bd;stroke-width:1;stroke-opacity:.6}\n  .sect-multi-border{fill:none;stroke:#1c1c1e;stroke-width:.6;stroke-opacity:.4;pointer-events:none}\n  .unit-outline{fill:none;stroke:#fff;stroke-width:1.1;pointer-events:none}\n  .hatchswatch{background:repeating-linear-gradient(45deg,#1c1c1e,#1c1c1e 1px,transparent 1px,transparent 4px);opacity:.35;border:1px solid #d4d4d9}\n  .legend{display:flex;gap:11px;flex-wrap:wrap;margin-top:9px;font-size:11px;color:var(--mut)}\n  .legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px}\n  .bar{display:flex;align-items:center;gap:9px;margin:7px 0}\n  .bar .tag{width:46px;font-weight:600;font-size:12px}\n  .bar .track{flex:1;height:17px;background:var(--panel2);border-radius:5px;overflow:hidden;display:flex}\n  .bar .b1{height:100%;opacity:.38}.bar .b2{height:100%}.bar .b3{height:100%;opacity:.7}\n  .bar .v{width:120px;text-align:right;font-size:11px;color:var(--mut);font-variant-numeric:tabular-nums}\n  .mrow{display:flex;align-items:center;gap:9px;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:6px 10px;margin-bottom:5px}\n  .mrow .nm{flex:1;font-weight:600;font-size:12px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n  .mrow .cb{color:var(--mut);font-size:11px;font-variant-numeric:tabular-nums}\n  .chip{font-size:11px;font-weight:700;color:#fff;border-radius:6px;padding:2px 8px}\n  table{border-collapse:collapse;width:100%;font-size:11.5px}\n  th,td{padding:6px 7px;text-align:right;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}\n  th:first-child,td:first-child{text-align:left}\n  thead th{color:var(--mut);font-weight:600;font-size:10.5px}\n  .dot{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px}\n  .delta{font-size:10px}.delta.up{color:var(--ok)}.delta.dn{color:var(--closing)}\n  .note{font-size:10.5px;color:var(--mut);line-height:1.55;margin-top:9px}\n  .flag{margin-top:11px;padding:9px 11px;border-radius:9px;font-size:11.5px;line-height:1.55;background:#fff8ef;border:1px solid #ffe0bd;color:#8a5200}\n  .scroll{max-height:300px;overflow:auto}\n  .center{display:flex;align-items:center;justify-content:center;height:100%;padding:40px}\n  .msg{max-width:420px;text-align:center}\n  .msg h2{font-size:17px;font-weight:600;margin:0 0 7px}\n  .msg p{font-size:12.5px;color:var(--mut);margin:0;line-height:1.55}\n`,\n  render(d2){\n    // Hierarquia mercado > GV > setor, espelhando o simulador ao vivo. Tudo pré-computado (label,\n    // minúscula, plural, gênero) pra nunca minusculizar \"GV\" (sigla) virando \"gv\", e pra concordar\n    // gênero certo em cada frase — GV é feminina (a Gerência de Vendas), o resto é masculino.\n    const LVL={\n      setor:{label:'Setor',low:'setor',fem:false, child:'Município',childLow:'município',childPlural:'Municípios',childFem:false, dest:'Setor',destLow:'setor'},\n      gv:{label:'GV',low:'GV',fem:true, child:'Setor',childLow:'setor',childPlural:'Setores',childFem:false, dest:'GV',destLow:'GV'},\n      mercado:{label:'Mercado',low:'mercado',fem:false, child:'GV',childLow:'GV',childPlural:'GVs',childFem:true, dest:'Mercado',destLow:'mercado'},\n    };\n    const lv=s=>LVL[(s&&s.level)||'setor'];\n    const esc=s=>(s==null?'':String(s)).replace(/[&<>\"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]));\n    const fmt=n=>n==null?'–':Math.round(+n).toLocaleString('pt-BR');\n    const money=n=>{n=+n||0;return n>=1e6?'R$'+(n/1e6).toFixed(1)+'M':n>=1e3?'R$'+(n/1e3).toFixed(0)+'k':'R$'+Math.round(n)};\n    const msg=(t,b,act)=>`<div class=\"rroot\"><div class=\"center\"><div class=\"msg\"><h2>${t}</h2><p>${b}</p>${act?`<button class=\"goact\" id=\"goSim\">Abrir o simulador</button>`:''}</div></div></div>`;\n\n    if(d2.state.error) return msg('Não foi possível abrir a sessão',esc(d2.state.error),true);\n    if(!d2.state.ready) return msg('Carregando sessão…','Buscando o registro compartilhado.');\n    const r=d2.state.rec;\n    if(!r) return msg('Sessão não encontrada','O link pode ter expirado ou o registro foi removido. Peça um novo link a quem compartilhou.',true);\n\n    const stages=Array.isArray(r.stages)?r.stages:[];\n    const sm=r.summary||{};\n    const st=r.settings||{};\n    const ruleDest=st.destRule==='dominant'?`destino: dominantes ≥${fmt(st.destMinCbs)} CBs`:'destino: qualquer município';\n    const ruleReal=st.realocRule==='relevant'?`realocação: ≥${fmt(st.realocMinCbs)} CBs`:'realocação: todos';\n    const spanBits=[];\n    if(st.targetSpan)spanBits.push('alvo '+fmt(st.targetSpan));\n    if(st.maxSpan)spanBits.push('máx '+fmt(st.maxSpan));\n    if(st.minSpan)spanBits.push('mín '+fmt(st.minSpan));\n\n    let h=`<div class=\"rroot\"><div class=\"shell\">\n      <header class=\"top\">\n        <div class=\"eyebrow\">Simulador de timeline de fechamentos · sessão compartilhada</div>\n        <h1>${esc(r.label||'Sessão sem nome')}</h1>\n        <div class=\"pills\">\n          <span class=\"pill\">● snapshot congelado · <b>${esc(r.snapshotRaw||'—')}</b></span>\n          <span class=\"pill\">◇ ${esc(ruleReal)}</span>\n          <span class=\"pill\">◈ ${esc(ruleDest)}</span>\n          <span class=\"pill\">◎ span: <b>${spanBits.length?esc(spanBits.join(' · ')):'livre'}</b></span>\n          ${r.sharedBy?`<span class=\"pill\">por <b>${esc(r.sharedBy)}</b></span>`:''}\n          ${r.sharedAt?`<span class=\"pill\">${esc(new Date(r.sharedAt).toLocaleString('pt-BR'))}</span>`:''}\n          <span class=\"pill go\" id=\"goSim\" role=\"button\" tabindex=\"0\" title=\"Ir para o simulador\">↗ abrir o simulador</span>\n        </div>\n      </header>`;\n\n    const sessLevel=(stages[0]&&stages[0].level)||'setor';\n    h+=`<div class=\"strip\">\n      <div class=\"st\"><div class=\"n\">${fmt(stages.length)}</div><div class=\"l\">Etapa(s) de fechamento</div></div>\n      <div class=\"st\"><div class=\"n\">${fmt(sm.totalCbs)}</div><div class=\"l\">CBs realocadas na cadeia</div></div>\n      ${sessLevel==='setor'?`<div class=\"st warn\"><div class=\"n\">${fmt(sm.totalDesloc)}</div><div class=\"l\">CBs deslocadas (quebra de relação)</div></div>`:`<div class=\"st\"><div class=\"n\">${fmt(sm.totalDesloc)}</div><div class=\"l\">CBs com líder dividida entre destinos</div></div>`}\n      <div class=\"st\"><div class=\"n\" style=\"font-size:15px;line-height:1.5;font-weight:600\">${esc((sm.sectors||[]).join(' → ')||'—')}</div><div class=\"l\">Ordem de fechamento</div></div>\n    </div>`;\n\n    if(stages.length>1){\n      h+=`<div class=\"stepper\">`+stages.map((s,i)=>`<div class=\"sc\" data-goto=\"stage-${i}\"><span class=\"k\">${i+1}</span>${lv(s).label} ${esc(s.sector)}<span class=\"arrow\">→</span>${esc((s.dest||[]).join(', ')||'–')}</div>`).join('')+`</div>`;\n    }\n\n    // ---- projection helpers (pure; mirrors the simulator's) ----\n    const project=feats=>{let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n      feats.forEach(f=>{const g=f&&f.geometry;if(!g)return;const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(p=>p.forEach(rg=>rg.forEach(pt=>{mnx=Math.min(mnx,pt[0]);mxx=Math.max(mxx,pt[0]);mny=Math.min(mny,pt[1]);mxy=Math.max(mxy,pt[1]);})));});\n      if(!isFinite(mnx)) return null;\n      const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=680,H=440,pad=16;\n      const gw=Math.max((mxx-mnx)*kx,1e-9),gh=Math.max(mxy-mny,1e-9),sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n      const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n      return {W,H,f:pt=>[ox+(pt[0]-mnx)*kx*sc, oy+(mxy-pt[1])*sc]};};\n    const pathFor=(f,pr)=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      return ps.map(poly=>poly.map(rg=>'M'+rg.map(pt=>{const q=pr(pt);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');};\n    const cxy=(f,pr)=>{let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      ps.forEach(poly=>poly[0].forEach(pt=>{const q=pr(pt);x+=q[0];y+=q[1];n++;}));return n?[x/n,y/n]:[0,0];};\n    // Pole of inaccessibility (ported from the live map's labelPointFor): the interior point\n    // farthest from any boundary, so a label lands inside a concave/lobed territory instead of a\n    // plain vertex-average centroid that can fall outside it. W/H replace the live map's\n    // hardcoded 700x500 viewBox clamp (there it's W-2/H-2 — 698/498) since this viewer's own\n    // project() uses a different canvas size.\n    const labelPointFor=(fs,pr,W,H)=>{\n      const rings=[];\n      (fs||[]).forEach(f=>{ if(!f||!f.geometry) return;\n        const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(poly=>poly.forEach(r=>{\n          if(!r||r.length<4) return;\n          const step=Math.max(1,Math.floor(r.length/120)),pts=[];\n          for(let k=0;k<r.length;k+=step) pts.push(pr(r[k]));\n          if(pts.length>3) rings.push(pts);\n        }));});\n      if(!rings.length) return null;\n      let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n      rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n      mnx=Math.max(mnx,2); mny=Math.max(mny,2); mxx=Math.min(mxx,W-2); mxy=Math.min(mxy,H-2);\n      if(!(mxx>mnx&&mxy>mny)) return null;\n      const inside=(x,y)=>{ let c=false;\n        for(const r of rings) for(let a=0,b=r.length-1;a<r.length;b=a++){ const p1=r[a],p2=r[b];\n          if(((p1[1]>y)!==(p2[1]>y)) && (x<(p2[0]-p1[0])*(y-p1[1])/(p2[1]-p1[1])+p1[0])) c=!c; }\n        return c; };\n      const edgeDist=(x,y)=>{ let m=Infinity;\n        for(const r of rings) for(let a=0,b=r.length-1;a<r.length;b=a++){ const p1=r[a],p2=r[b];\n          const dx=p2[0]-p1[0],dy=p2[1]-p1[1],L=dx*dx+dy*dy;\n          let t=L?(((x-p1[0])*dx+(y-p1[1])*dy)/L):0; t=t<0?0:(t>1?1:t);\n          const ex=p1[0]+t*dx-x,ey=p1[1]+t*dy-y,d=ex*ex+ey*ey; if(d<m)m=d; }\n        return Math.sqrt(m); };\n      const Wd=mxx-mnx,Hd=mxy-mny,N=26;\n      let ccx=0,ccy=0,cn=0;\n      rings.forEach(r=>r.forEach(p=>{ ccx+=p[0]; ccy+=p[1]; cn++; }));\n      ccx/=cn||1; ccy/=cn||1;\n      const CENTER_PULL=0.22;\n      const scoreAt=(x,y,d)=>d-CENTER_PULL*Math.hypot(x-ccx,y-ccy);\n      let best=null,bd=-1,bs=-Infinity;\n      for(let a=0;a<=N;a++) for(let b=0;b<=N;b++){\n        const x=mnx+Wd*a/N,y=mny+Hd*b/N;\n        if(!inside(x,y)) continue;\n        const d=edgeDist(x,y),sc=scoreAt(x,y,d); if(sc>bs){ bs=sc; bd=d; best=[x,y]; }\n      }\n      if(!best) return null;\n      let step=Math.max(Wd,Hd)/N/2;\n      for(let k=0;k<7;k++){\n        let moved=false;\n        for(const o of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]){\n          const x=best[0]+o[0]*step,y=best[1]+o[1]*step;\n          if(x<mnx||x>mxx||y<mny||y>mxy||!inside(x,y)) continue;\n          const d=edgeDist(x,y),sc=scoreAt(x,y,d); if(sc>bs){ bs=sc; bd=d; best=[x,y]; moved=true; }\n        }\n        if(!moved) step/=2;\n      }\n      return {p:best,r:bd};\n    };\n\n    stages.forEach((s,i)=>{\n      const color=s.color||{};\n      const k=s.kpis||{};\n      const LV=lv(s);\n      h+=`<div class=\"card\" id=\"stage-${i}\" data-key=\"stage-${i}\">\n        <h2>Etapa ${i+1} · ${LV.low} <b>${esc(s.sector)}</b> fechad${LV.fem?'a':'o'}</h2>\n        <p class=\"sub\">Território redistribuído para ${esc((s.dest||[]).join(', ')||'—')}${s.domUF?' · '+esc(s.domUF):''}${s.policy?' · política: '+(s.policy==='fora'?'manter vínculo':'quebrar relação'):''}</p>\n        <div class=\"strip\" style=\"margin:0 0 18px\">\n          <div class=\"st\"><div class=\"n\">${fmt(k.cbsRealocadas)}</div><div class=\"l\">CBs realocadas</div></div>\n          ${LV.label==='Setor'?`<div class=\"st warn\"><div class=\"n\">${fmt(k.desloc)}</div><div class=\"l\">CBs deslocadas</div></div>`:`<div class=\"st\"><div class=\"n\">${fmt(k.desloc)}</div><div class=\"l\">CBs com líder dividida</div></div>`}\n          <div class=\"st\"><div class=\"n\">${fmt(k.municipios)}</div><div class=\"l\">${LV.childPlural} movid${LV.childFem?'as':'os'}</div></div>\n          <div class=\"st\"><div class=\"n\">${fmt(k.lideres)}</div><div class=\"l\">Líderes envolvidas</div></div>\n        </div>\n        <div class=\"split\"><div>`;\n\n      // map — mirrors the live simulator's drawMap: every value consumed here (nbrCurrentSector,\n      // muniOwners, rejectedCodes, each sharedMuniSlices piece's own resolved sector) was already\n      // fully resolved by the live map when the session was shared, so nothing here re-derives a\n      // business rule (same GV, already closed, etc.) — it only renders what was decided live.\n      const gf=s.geoFeat||{}, nb=Array.isArray(s.nbrFeat)?s.nbrFeat:[], ex=Array.isArray(s.excludedFeat)?s.excludedFeat:[];\n      const shared=Array.isArray(s.sharedMuniSlices)?s.sharedMuniSlices:[];\n      const fullShape=s.muniFullShape||{};\n      const nbrCur=s.nbrCurrentSector||{};\n      const muniOwn=s.muniOwners||{};\n      const rejSet=new Set(s.rejectedCodes||[]);\n      const dest=s.dest||[];\n      const all=[...nb,...ex,...Object.values(gf),...shared.map(sl=>sl.feature)].filter(Boolean);\n      const P=project(all);\n      const unitOf=m=>LV.label==='Setor'?m:((s.unitOfMuni||{})[m]||m);\n      // Migration marker: buildShareStages only started saving muniOwners (and its siblings\n      // sharedMuniSlices/muniFullShape/nbrCurrentSector/rejectedCodes) alongside this change — a\n      // stage saved by an OLDER share never has the key at all (undefined), while a stage saved\n      // from now on always has it (an EMPTY object for a closing unit with no neighbours at all is\n      // a real, valid case and must not be confused with \"this field was never saved\"). Checked\n      // against the raw field, before the `||{}` defaults below erase that distinction.\n      const hasRichCtx=s.muniOwners!=null;\n      if(P){\n        let g='';\n        ex.forEach(f=>{ if(!f||!f.geometry)return; g+=`<path class=\"exc\" d=\"${pathFor(f,P.f)}\"/>`; });\n\n        const multiBorderFeats={};\n        if(!hasRichCtx){\n          // Legacy stage (shared before this map upgrade): none of the shared-município mosaic\n          // data exists, so render context exactly as this viewer always used to — flat grey,\n          // named neighbours — rather than mis-reading \"field missing\" as \"every município here\n          // is unresolved\".\n          nb.forEach(f=>{ if(!f||!f.geometry)return; g+=`<path class=\"nbr\" d=\"${pathFor(f,P.f)}\"/>`;\n            const c=cxy(f,P.f); const nm2=(f.properties&&f.properties.name)||'';\n            if(nm2) g+=`<text class=\"nlabel\" data-fs=\"7.5\" x=\"${c[0].toFixed(1)}\" y=\"${c[1].toFixed(1)}\">${esc(nm2.slice(0,13))}</text>`; });\n        } else {\n        // ---- context mosaic: group every context piece (plain neighbours + shared-município\n        // slices) by whichever real owner it belongs to — mirrors the live map's nbrBySec. A\n        // município this viewer independently discovers as multi-owner (muniOwn, the read-only\n        // fallback for CSV real ownership) only ever lists owners already in `dest` — built from\n        // expanding EACH of mv.dest's own territory — so unlike the live map's fuller check it\n        // never needs a __MULTI_NOVALID__ bucket for THOSE; sharedMuniSlices pieces still can\n        // (a real sibling that wasn't a valid target), carried over pre-resolved as sl.sector===null.\n        const nbrBySec={};\n        nb.forEach(f=>{\n          if(!f||!f.geometry) return;\n          const nm=(f.properties&&(f.properties.name||f.properties.NOME||f.properties.nome))||'';\n          const owners=[...new Set(muniOwn[nm]||[])].sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n          if(owners.length>1){\n            multiBorderFeats[nm]=f;\n            sliceFeatureEqual(f,owners.length).forEach((slice,idx)=>{\n              if(slice.__empty) return;\n              const ow=owners[idx];\n              (nbrBySec[ow]=nbrBySec[ow]||[]).push({feature:slice,muni:nm,owners});\n            });\n            return;\n          }\n          const k=String(nbrCur[nm]||'');\n          (nbrBySec[k]=nbrBySec[k]||[]).push({feature:f,muni:nm,owners:null});\n        });\n        shared.forEach(sl=>{\n          const k=sl.sector?String(sl.sector):'__MULTI_NOVALID__';\n          (nbrBySec[k]=nbrBySec[k]||[]).push({feature:sl.feature,muni:sl.muni,owners:[s.sector,sl.sector].filter(Boolean)});\n        });\n        Object.keys(fullShape).forEach(nm=>{ if(!multiBorderFeats[nm]) multiBorderFeats[nm]=fullShape[nm]; });\n\n        Object.keys(nbrBySec).forEach(k=>{\n          const fs=nbrBySec[k];\n          const isNovalid=k==='__MULTI_NOVALID__';\n          const isDest=!isNovalid&&!!k&&dest.includes(k);\n          const isRejected=!isNovalid&&!!k&&rejSet.has(k);\n          const isUnresolved=!isNovalid&&!k;\n          const col=isNovalid?null:(isDest?(color[k]||null):(!isRejected&&!isUnresolved?colorForSector(k):null));\n          // Border-before-fill trick (same as the live map): drawn first so every opaque fill that\n          // follows paints over the internal seams, leaving only the true external edge visible.\n          if(isRejected) fs.forEach(fw=>{ g+=`<path class=\"sect-rejected-border\" d=\"${pathFor(fw.feature,P.f)}\"/>`; });\n          fs.forEach(fw=>{\n            const cls='sect'+(isRejected?' rejected':(isUnresolved?' unresolved':(isNovalid?' novalid':'')));\n            g+=`<path class=\"${cls}\" d=\"${pathFor(fw.feature,P.f)}\"${col?` fill=\"${esc(col)}\"`:''}/>`;\n            if(fw.owners&&fw.owners.length>1) g+=`<path d=\"${pathFor(fw.feature,P.f)}\" fill=\"url(#hatch-${i})\" style=\"pointer-events:none\"/>`;\n          });\n          if(!isNovalid&&k){\n            const lp=labelPointFor(fs.map(fw=>fw.feature),P.f,P.W,P.H);\n            if(lp&&lp.p&&lp.r>=5) g+=`<text class=\"glabel\" data-fs=\"11.5\" data-sw=\"2.2\" x=\"${lp.p[0].toFixed(1)}\" y=\"${lp.p[1].toFixed(1)}\">${esc(k)}</text>`;\n          }\n        });\n        }\n\n        // ---- closing unit's own território ----\n        if(LV.label==='Setor'){\n          Object.entries(gf).forEach(([m,f])=>{ if(!f||!f.geometry)return;\n            const destC=(s.assign||{})[m];\n            g+=`<path class=\"muni\" d=\"${pathFor(f,P.f)}\" fill=\"${esc(color[destC]||'#8e8e93')}\"/>`;\n            const c=cxy(f,P.f);\n            g+=`<text class=\"mlabel\" data-fs=\"9.5\" data-sw=\"2.4\" x=\"${c[0].toFixed(1)}\" y=\"${c[1].toFixed(1)}\">${esc(m.slice(0,14))}<tspan data-fs=\"8\" x=\"${c[0].toFixed(1)}\" dy=\"10\" font-size=\"8\">${esc(destC||'–')}</tspan></text>`; });\n        } else {\n          // GV/mercado: vários municípios do mesmo setor/GV são UMA unidade só na tela — mesma cor,\n          // mesmo stroke (a costura entre elas some), contorno da unidade por baixo, um rótulo só\n          // no ponto de inacessibilidade do grupo. Espelha o mapa ao vivo do simulador.\n          const groups={};\n          Object.entries(gf).forEach(([m,f])=>{ if(!f||!f.geometry)return; const u=unitOf(m); (groups[u]=groups[u]||[]).push(f); });\n          Object.entries(groups).forEach(([u,feats])=>{\n            const destC=(s.assign||{})[u]; const col2=esc(color[destC]||'#8e8e93');\n            feats.forEach(f=>{ g+=`<path class=\"unit-outline\" d=\"${pathFor(f,P.f)}\"/>`; });\n            let cx=0,cy=0,n=0;\n            feats.forEach(f=>{ g+=`<path class=\"muni\" d=\"${pathFor(f,P.f)}\" fill=\"${col2}\" stroke=\"${col2}\"/>`;\n              const c=cxy(f,P.f); cx+=c[0]; cy+=c[1]; n++; });\n            const lp=labelPointFor(feats,P.f,P.W,P.H);\n            const at=(lp&&lp.p)||(n?[cx/n,cy/n]:null);\n            if(at) g+=`<text class=\"mlabel\" data-fs=\"9.5\" data-sw=\"2.4\" x=\"${at[0].toFixed(1)}\" y=\"${at[1].toFixed(1)}\">${esc(u)}<tspan data-fs=\"8\" x=\"${at[0].toFixed(1)}\" dy=\"10\" font-size=\"8\">${esc(destC||'–')}</tspan></text>`;\n          });\n        }\n\n        // One clean outline per genuinely multi-owner município, drawn LAST — after every fill,\n        // closing side included — so an internal seam always ends up painted over by whichever\n        // opaque fill owns that stretch, leaving only the true external edge visible on top.\n        Object.values(multiBorderFeats).forEach(feat=>{ g+=`<path class=\"sect-multi-border\" d=\"${pathFor(feat,P.f)}\"/>`; });\n\n        h+=`<h3>Mapa da redistribuição</h3><div class=\"map-wrap\">\n          <svg class=\"map\" id=\"map-${i}\" data-w=\"${P.W}\" data-h=\"${P.H}\" viewBox=\"0 0 ${P.W} ${P.H}\" preserveAspectRatio=\"xMidYMid meet\">\n            <defs><pattern id=\"hatch-${i}\" width=\"7\" height=\"7\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(45)\"><line x1=\"0\" y1=\"0\" x2=\"0\" y2=\"7\" stroke=\"#1c1c1e\" stroke-width=\"0.6\" stroke-opacity=\"0.15\"/></pattern></defs>\n            ${g}</svg>\n          <div class=\"zoom-pill\"><button type=\"button\" data-zoom=\"out\" data-i=\"${i}\" title=\"Diminuir zoom\">−</button><span class=\"zoom-div\"></span><button type=\"button\" data-zoom=\"in\" data-i=\"${i}\" title=\"Aumentar zoom\">+</button></div>\n          </div>\n          <div class=\"legend\">${dest.map(d=>`<span><i style=\"background:${esc(color[d]||'#8e8e93')}\"></i>${LV.dest} ${esc(d)}</span>`).join('')}\n          <span><i style=\"background:#e8e8ec;border:1px solid #d4d4d9\"></i>vizinhos (contexto)</span>\n          ${hasRichCtx?`<span><i class=\"hatchswatch\"></i>município com mais de um setor</span>`:''}\n          ${ex.length?`<span><i style=\"background:#f7e7d0;border:1px dashed #ff9500\"></i>fora da realocação</span>`:''}</div>`;\n      }\n      h+=`<h3>${LV.childPlural} → ${LV.destLow} destino</h3>`;\n      h+=(s.municipios||[]).map(m=>`<div class=\"mrow\"><span class=\"nm\">${esc(m.nome)}</span><span class=\"cb\">${fmt(m.cbs)} CBs</span>\n        <span class=\"chip\" style=\"background:${esc(color[m.destino]||'#8e8e93')}\">${esc(m.destino||'–')}</span></div>`).join('')||`<div class=\"note\">Nenhum${LV.childFem?'a':''} ${LV.childLow} realocad${LV.childFem?'a':'o'} nesta etapa.</div>`;\n\n      h+=`</div><div>`;\n      // spans\n      const ds=s.destinos||[];\n      const mx=Math.max(...ds.map(d=>+d.span||0),1);\n      h+=`<h3>Tamanho d${LV.fem?'a':'o'} ${LV.destLow} depois da etapa</h3>`;\n      h+=ds.map(d=>{const base=+d.base||0,inc=+d.inc||0,orph=+d.orph||0;\n        return `<div class=\"bar\"><span class=\"tag\" style=\"color:${esc(color[d.s]||'#8e8e93')}\">${esc(d.s)}</span>\n          <span class=\"track\"><span class=\"b1\" style=\"width:${(base/mx*100).toFixed(1)}%;background:${esc(color[d.s]||'#8e8e93')}\"></span><span class=\"b2\" style=\"width:${(inc/mx*100).toFixed(1)}%;background:${esc(color[d.s]||'#8e8e93')}\"></span>${orph?`<span class=\"b3\" style=\"width:${(orph/mx*100).toFixed(1)}%;background:${esc(color[d.s]||'#8e8e93')}\"></span>`:''}</span>\n          <span class=\"v\">${fmt(d.span)} <span style=\"opacity:.6\">(+${fmt(inc+orph)})</span></span></div>`;}).join('')||`<div class=\"note\">Sem ${LV.destLow} destino nesta etapa.</div>`;\n      h+=`<div class=\"note\">Claro = base antes da etapa · cheio = CBs entrantes${(s.orphanTotal?' · faixa final = CBs que seguiram suas líderes':'')}.</div>`;\n\n      // leaders\n      const L=s.lideres||[];\n      if(L.length){\n        const showOrph=L.some(x=>+x.orphan>0);\n        h+=`<h3>Líderes</h3><div class=\"scroll\"><table><thead><tr><th>Líder</th><th>CBs</th><th>${LV.dest} final</th>${showOrph?'<th>Órfãs</th>':''}<th>Desloc.</th></tr></thead><tbody>`\n          +L.map(x=>`<tr><td>${esc(x.lider||'—')}</td><td>${fmt(x.total)}</td>\n            <td>${x.home?`<span class=\"dot\" style=\"background:${esc(color[x.home]||'#8e8e93')}\"></span>${esc(x.home)}`:'–'}</td>\n            ${showOrph?`<td>${x.orphan?fmt(x.orphan):'·'}</td>`:''}\n            <td style=\"color:${+x.desloc?'var(--closing)':'var(--mut)'};font-weight:${+x.desloc?700:400}\">${fmt(x.desloc||0)}</td></tr>`).join('')\n          +`</tbody></table></div>`;\n      }\n      h+=`</div></div>`;\n\n      // antes -> depois\n      const M=s.metricas||[];\n      if(M.length){\n        h+=`<h3>Impacto antes → depois por ${LV.destLow} ${LV.fem?'receptora':'receptor'}</h3><div class=\"scroll\"><table><thead><tr><th>${LV.dest}</th>${M.map(m=>`<th>${esc(m.label)}</th>`).join('')}</tr></thead><tbody>`;\n        (s.dest||[]).forEach(sec=>{\n          h+=`<tr><td><span class=\"dot\" style=\"background:${esc(color[sec]||'#8e8e93')}\"></span>${esc(sec)}</td>`;\n          h+=M.map(m=>{const row=(m.rows||[]).find(x=>String(x.s)===String(sec))||{};\n            const a=+row.antes||0,b=+row.depois||0,d=a?((b-a)/a*100):0;const f=m.money?money:fmt;\n            return `<td>${f(b)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;}).join('')+`</tr>`;\n        });\n        h+=`<tr style=\"opacity:.75\"><td>${esc(s.sector)} (sai)</td>`+M.map(m=>`<td>${(m.money?money:fmt)(m.sai||0)}</td>`).join('')+`</tr>`;\n        h+=`</tbody></table></div>`;\n      }\n\n      // caveats\n      const fl=[];\n      if(s.excluidos&&s.excluidos.length) fl.push(`<b>${fmt(s.excluidos.length)} município(s)</b> com poucas CBs ficaram fora da realocação geográfica (quebra de vínculo): ${esc(s.excluidos.slice(0,6).map(x=>x.muni+' ('+x.cbs+')').join(', '))}${s.excluidos.length>6?' +'+(s.excluidos.length-6):''}. As CBs seguiram a líder do seu grupo.`);\n      if(s.orphanStranded) fl.push(`<b>${fmt(s.orphanStranded)} CB(s)</b> sem setor a seguir — a líder não ficou com nenhum município realocado.`);\n      if(s.noShapeCbs) fl.push(`<b>${fmt(s.noShapeCbs)} CB(s)</b> em municípios sem shape na UF, fora do mapa e de qualquer setor destino.`);\n      if(s.optTier===1) fl.push(`O span mínimo não era atingível nesta etapa e foi relaxado pelo modelo.`);\n      if(s.optTier===2) fl.push(`Os limites de span não eram atingíveis nesta etapa e foram relaxados pelo modelo.`);\n      if(s.optTier===-1) fl.push(`Etapa resolvida por heurística (espaço de busca grande) — os limites de span não são garantidos.`);\n      if(s.destRuleFallback) fl.push(`Nenhum município dominante nesta etapa: a regra de destino foi relaxada para \"qualquer município\".`);\n      if(s.realocFallback) fl.push(`Todos os municípios ficariam abaixo do limite: a regra de realocação foi relaxada para \"realocar todos\".`);\n      if(fl.length) h+=`<div class=\"flag\">⚠ ${fl.join('<br>')}</div>`;\n      h+=`</div>`;\n    });\n\n    // cumulative\n    // Este resumo é calculado no rastro de município (world.muniOverride) — hoje só existe pro\n    // fechamento de setor; GV/mercado têm o rastro equivalente (secOverride/gvOverride) mas essa\n    // agregação ainda não foi refeita nesse grão, então avisa em vez de mostrar tabela vazia.\n    const rc=sm.receivers||[];\n    if(!rc.length && sessLevel!=='setor'){\n      h+=`<div class=\"card\"><h2>Impacto acumulado da cadeia</h2>\n        <p class=\"note\" style=\"margin-top:0\">Ainda não disponível pra sessões de ${sessLevel==='gv'?'GV':'mercado'} — veja o relatório de cada etapa acima.</p></div>`;\n    }\n    if(rc.length){\n      h+=`<div class=\"card\"><h2>Impacto acumulado da cadeia</h2>\n        <p class=\"sub\">Efeito de <b>todas</b> as ${fmt(stages.length)} etapa(s) somadas, contra o snapshot congelado sem nenhum fechamento aplicado.</p>\n        <div class=\"scroll\"><table><thead><tr><th>Setor</th><th>Situação</th><th>CBs antes</th><th>CBs depois</th><th>Variação</th></tr></thead><tbody>`\n        +rc.map(x=>{const a=+x.antes||0,b=+x.depois||0,d=a?((b-a)/a*100):0;\n          return `<tr><td>${esc(x.s)}</td><td>${esc(x.situacao||'recebeu território')}</td><td>${fmt(a)}</td><td>${fmt(b)}</td>\n            <td class=\"delta ${d>=0?'up':'dn'}\" style=\"font-size:11.5px\">${d>0?'+':''}${d.toFixed(0)}%</td></tr>`;}).join('')\n        +`</tbody></table></div></div>`;\n    }\n\n    h+=`<div class=\"note\" style=\"text-align:center;margin-top:22px\">Visualização somente leitura de uma simulação congelada. Os números refletem o snapshot <b>${esc(r.snapshotRaw||'—')}</b> e as regras vigentes no momento do compartilhamento — não acompanham o dado ao vivo.</div>`;\n    return h+`</div></div>`;\n  },\n  mounted(d2, root){\n    d2.on('#goSim','click',()=>{\n      try{ (d2.router('timelineSimRouter')||d2.router()).goTo('/'); }catch(e){ try{ d2.router().goTo('/'); }catch(e2){} }\n    });\n    d2.on('.sc','click',e=>{\n      const el=e.target.closest('.sc'); if(!el) return;\n      const t=d2.$('#'+el.dataset.goto); if(t) t.scrollIntoView({behavior:'smooth',block:'start'});\n    });\n    // ---- map zoom/pan (viewBox só — texto e polígonos vivem no mesmo sistema de coordenadas, então\n    // escalam juntos automaticamente ao encolher/esticar o viewBox, sem lógica extra pra fonte).\n    // Delegado em `root` (nunca substituído por re-render) em vez de nos <svg> por id, porque cada\n    // atualização de estado reconstrói o HTML das etapas do zero.\n    const mapViews={};\n    const getMapView=(i,W,H)=>mapViews[i]||(mapViews[i]={x:0,y:0,w:W,h:H});\n    const applyMapView=(svg,i)=>{ const v=mapViews[i]; if(!v) return;\n      svg.setAttribute('viewBox', v.x.toFixed(2)+' '+v.y.toFixed(2)+' '+v.w.toFixed(2)+' '+v.h.toFixed(2));\n      // Rótulo tem tamanho em unidades do viewBox — sem isso ele cresceria junto com o zoom até virar\n      // um borrão. Encolher na mesma proporção que o viewBox encolhe cancela o zoom: o texto fica do\n      // mesmo tamanho na tela em qualquer nível. style.fontSize (não o atributo) porque uma regra de\n      // classe como .mlabel{font-size:...} outrankaria um atributo comum.\n      const W=+svg.dataset.w||700;\n      const fsScale=v.w/W;\n      svg.querySelectorAll('[data-fs]').forEach(t=>{\n        const base=+t.getAttribute('data-fs')||8;\n        t.style.fontSize=(base*fsScale).toFixed(2)+'px';\n      });\n      // Halo do texto (contorno branco que garante legibilidade sobre qualquer cor de fundo) tem\n      // largura de traço fixa em unidades do viewBox — sem reescalar junto, no zoom alto ele vira\n      // uma mancha cobrindo a letra inteira em vez de um contorno fino.\n      svg.querySelectorAll('[data-sw]').forEach(t=>{\n        const base=+t.getAttribute('data-sw')||1;\n        t.style.strokeWidth=(base*fsScale).toFixed(3)+'px';\n      });\n    };\n    const zoomStageMap=(i,factor)=>{\n      const svg=root.querySelector('#map-'+i); if(!svg) return;\n      const W=+svg.dataset.w||700, H=+svg.dataset.h||500;\n      const v=getMapView(i,W,H);\n      const cx=v.x+v.w/2, cy=v.y+v.h/2;\n      let w=v.w/factor, h=v.h/factor;\n      w=Math.min(W, Math.max(W/8, w)); h=Math.min(H, Math.max(H/8, h));\n      let x=cx-w/2, y=cy-h/2;\n      x=Math.min(W-w, Math.max(0,x)); y=Math.min(H-h, Math.max(0,y));\n      mapViews[i]={x,y,w,h};\n      applyMapView(svg,i);\n    };\n    d2.on('.zoom-pill button','click',e=>{\n      const b=e.target.closest('button'); if(!b) return;\n      zoomStageMap(b.dataset.i, b.dataset.zoom==='in'?1.4:1/1.4);\n    });\n    let mapDrag=null;\n    root.addEventListener('pointerdown',e=>{\n      const svg=e.target.closest('svg.map'); if(!svg) return;\n      const i=svg.id.replace('map-',''); const W=+svg.dataset.w||700, H=+svg.dataset.h||500;\n      const v=getMapView(i,W,H);\n      mapDrag={svg,i,W,H,x0:e.clientX,y0:e.clientY,vx0:v.x,vy0:v.y,rect:svg.getBoundingClientRect()};\n      svg.classList.add('dragging');\n    });\n    root.addEventListener('pointermove',e=>{\n      if(!mapDrag||!mapDrag.rect.width||!mapDrag.rect.height) return;\n      const dxPix=e.clientX-mapDrag.x0, dyPix=e.clientY-mapDrag.y0;\n      const v=getMapView(mapDrag.i,mapDrag.W,mapDrag.H);\n      const sx=v.w/mapDrag.rect.width, sy=v.h/mapDrag.rect.height;\n      let x=mapDrag.vx0-dxPix*sx, y=mapDrag.vy0-dyPix*sy;\n      x=Math.min(mapDrag.W-v.w, Math.max(0,x)); y=Math.min(mapDrag.H-v.h, Math.max(0,y));\n      mapViews[mapDrag.i]={...v,x,y};\n      applyMapView(mapDrag.svg,mapDrag.i);\n    });\n    const endMapDrag=()=>{ if(mapDrag){ mapDrag.svg.classList.remove('dragging'); mapDrag=null; } };\n    root.addEventListener('pointerup',endMapDrag);\n    root.addEventListener('pointerleave',endMapDrag);\n    (async()=>{\n      try{\n        // RACE, not a missing dataset. This DataSet is gated by timelineSimAuthFinal, so its\n        // Process is only define()d once the auth session resolves — a Process waits for its\n        // dependencies' setup before its own fires. In the field the auth lookup lands ~200ms\n        // and this DataSet's first fetch ~700-900ms, while mounted() runs immediately. d2.data()\n        // returns null for a module that is not defined YET, so bailing on the first miss threw\n        // \"dataset não disponível\" on every share link even though nothing was wrong.\n        // Poll briefly instead. Two lookups because either may win: the declared `shared` param,\n        // or the global-process path via the reference the instance binds — keep that binding.\n        let dead=false; d2.onUnmount(()=>{ dead=true; });\n        let ds=null;\n        for(let i=0;i<40 && !ds && !dead;i++){\n          ds=d2.data('shared')||d2.data('timeline-shared-sessionsDataSet');\n          if(!ds) await new Promise(r=>setTimeout(r,100));\n        }\n        if(dead) return;\n        if(!ds){ d2.setState({error:'Dataset de sessões compartilhadas não disponível.',ready:true}); return; }\n        // Name the router explicitly. Bare d2.router() looks for the conventional\n        // 'Router'/'MainRouter'; this one is 'timelineSimRouter', so the bare call can hand back a\n        // handle whose .params is empty — which is how a valid /sessions/<id> link ended up\n        // reporting \"o link não traz o identificador\". The goSim handler below already passes the\n        // ref for the same reason.\n        const rt=d2.router('timelineSimRouter')||d2.router();\n        const p=(rt&&rt.params)||{};\n        // ?id= lets the artboard be opened directly, outside the route.\n        let q=null;\n        try{ q=new URLSearchParams(window.location.search).get('id'); }catch(e){}\n        // Last resort, and the only one that cannot fail: the id is sitting in the path. Route\n        // params need the Router handle to resolve AND to have matched by the time mounted() runs\n        // (.params is a reactive getter, read once here); the pathname needs neither. Matches the\n        // current /sessions/:session_id route and the legacy /sessao/:id one.\n        let seg=null;\n        try{ const m=window.location.pathname.match(/\\/sess(?:ions|ao)\\/([^/?#]+)/); seg=m&&m[1]; }catch(e){}\n        const id=p.session_id||p.id||p.sessao||p.session||seg||q;\n        if(!id){ d2.setState({error:'O link não traz o identificador da sessão.',ready:true}); return; }\n        // .query() is a direct read and returns the rows — no refresh/loadAll ordering trap here\n        const rows=await ds.query({_id:id});\n        const rec=Array.isArray(rows)?rows[0]:(rows&&rows.data&&rows.data[0]);\n        d2.setState({rec:rec||null,ready:true,error:''});\n      }catch(err){\n        d2.setState({error:(err&&err.message)||'Falha ao carregar a sessão compartilhada.',ready:true});\n      }\n    })();\n  }\n}"}},"shared":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"shared"}}}}}}}}}},{"_id":"6a7f3140b7014a3fb17a76ce","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"ZbSMa0nRld-Ond2eo6tjs","package":"natura-geomarketing","extends":"data2-core-primitives@Block","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const AUTH='timelineSimAuthFinal';\n// SSP origin is derived at runtime from a known listener's resolved URL; this is only the fallback.\nconst SSP_FALLBACK='https://natura-geomarketing.data2apis.com';\nconst RUSH_REF='AwW_11Nkg-dKksP7ic8V8';\nconst ROUTER='timelineSimRouter';\n\nexport default {\n  css: `\n  :host{display:block;height:100%}\n  *{box-sizing:border-box}\n  .lroot{--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--acc2:#0060df;--closing:#ff3b30;--ok:#34c759;\n    min-height:100%;display:flex;align-items:center;justify-content:center;padding:36px 20px;background:var(--bg);\n    font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;color:var(--ink);-webkit-font-smoothing:antialiased}\n  .card{width:100%;max-width:400px;background:var(--panel);border:1px solid var(--line);border-radius:14px;\n    box-shadow:0 12px 34px rgba(0,0,0,.07),0 1px 2px rgba(0,0,0,.04);padding:26px 26px 22px}\n  .pill{display:inline-block;font-size:10.5px;padding:2.5px 9px;border-radius:99px;border:1px solid var(--line);color:var(--mut);background:var(--panel2)}\n  h1{font-size:17px;font-weight:600;letter-spacing:-.25px;margin:13px 0 5px}\n  .sub{font-size:12px;color:var(--mut);margin:0 0 20px;line-height:1.5}\n  .fl{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600;margin:0 0 5px}\n  .inp{width:100%;background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:9px 11px;font:inherit;font-size:13px}\n  .inp:focus{outline:2px solid rgba(0,122,255,.32);outline-offset:0;border-color:var(--acc)}\n  .inp:disabled{opacity:.55}\n  .row{margin-bottom:14px}\n  .act{width:100%;background:var(--acc);color:#fff;border:1px solid transparent;border-radius:8px;padding:10px 14px;font:inherit;font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 1px 1px rgba(0,0,0,.08);margin-top:4px}\n  .act:hover{background:var(--acc2)}\n  .act:disabled{opacity:.5;cursor:not-allowed}\n  .ghost{width:100%;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 12px;font:inherit;font-size:12.5px;cursor:pointer;margin-top:8px}\n  .ghost:hover{background:var(--panel2)}\n  .okbox{margin:12px 0 0;padding:8px 10px;border-radius:8px;background:#eefaf0;border:1px solid #bdeecb;color:#1c7c33;font-size:11.5px;font-weight:600}\n  .err{margin:12px 0 0;padding:8px 10px;border-radius:8px;background:#fff1f0;border:1px solid #ffd1cc;color:var(--closing);font-size:11.5px;font-weight:600}\n  .who{font-size:12px;color:var(--mut);margin:0 0 16px}\n  .who b{color:var(--ink)}\n  .foot{margin-top:18px;padding-top:14px;border-top:1px solid var(--line);font-size:10.5px;color:var(--mut);line-height:1.55}\n`,\n  render(d2){\n    const esc=s=>(s==null?'':String(s)).replace(/[&<>\"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]));\n    const busy=!!d2.state.busy;\n    const err=d2.state.error||'';\n    const a=d2.auth(AUTH);\n    if(!a){\n      return `<div class=\"lroot\"><div class=\"card\">\n        <span class=\"pill\">configuração</span>\n        <h1>Estratégia de autenticação não encontrada</h1>\n        <p class=\"sub\">Não foi possível resolver <b>${esc(AUTH)}</b>. Confirme a referência da AuthenticationStrategy do sistema.</p>\n      </div></div>`;\n    }\n    // Deliberately NOT branching on a.isAuthed. The Router renders this artboard ONLY from its\n    // `login` slot, i.e. only when the route policy rejected the caller — so anyone looking at it\n    // is unauthenticated by construction. isAuthed also reads TRUE under a builder token, which\n    // would show an \"already signed in\" card that bounces to / , gets rejected, and loops.\n    return `<div class=\"lroot\"><div class=\"card\">\n      <span class=\"pill\">acesso restrito</span>\n      <h1>Simulador de Timeline de Fechamentos</h1>\n      <p class=\"sub\">Entre com suas credenciais pra abrir a timeline de fechamentos de setores.</p>\n      <div class=\"row\">\n        <label class=\"fl\" for=\"em\">E-mail</label>\n        <input class=\"inp\" id=\"em\" type=\"email\" autocomplete=\"username\" autocapitalize=\"off\" autocorrect=\"off\" spellcheck=\"false\" placeholder=\"voce@natura.com.br\"${busy?' disabled':''}>\n      </div>\n      <div class=\"row\">\n        <label class=\"fl\" for=\"pw\">Senha</label>\n        <input class=\"inp\" id=\"pw\" type=\"password\" autocomplete=\"current-password\" placeholder=\"••••••••\"${busy?' disabled':''}>\n      </div>\n      <button class=\"act\" id=\"go\"${busy?' disabled':''}>${busy?'Entrando…':'Entrar'}</button>\n      ${err?`<div class=\"err\">⚠ ${esc(err)}</div>`:''}\n      ${d2.state.ok?`<div class=\"okbox\">✓ Autenticado. No app publicado o redirecionamento é automático.</div>`:''}\n      <div class=\"foot\">Geomarketing · Natura. O simulador é read-only sobre o Databricks — cada sessão salva fica presa ao snapshot congelado da simulação.</div>\n    </div></div>`;\n  },\n  mounted(d2, root){\n    // The router re-runs its policy hook when the session changes, so a successful login normally\n    // swaps the gated content on its own. If it does not (same path), reload as a last resort —\n    // cancelled on unmount, so the reload only ever fires when the swap did NOT happen.\n    // Login now runs through the strategy, so the runtime DOES hold the session in memory and the\n    // route policy would re-evaluate on its own. The hard navigation is kept deliberately: it is\n    // the one path that cannot land on stale client state. In the builder there is no app root to\n    // go to, so we just report success instead of navigating out of /preview/<ref>. [agent:ad78d19d]\n    const enter=()=>{\n      if(d2.context.isBuilder){ d2.setState({busy:false,error:'',ok:true}); return; }\n      try{ window.location.replace('/'); }\n      catch(e){ try{ window.location.reload(); }catch(e2){} }\n    };\n    // pt-BR (ABNT2) keyboards put ^ ~ ´ ¨ on DEAD keys: pressing one before a character that cannot\n    // take the accent (like @) emits the bare mark, or leaves a combining codepoint behind. Either\n    // way it must never reach /id/ln, so strip it plus zero-width paste junk and any whitespace.\n    // The PASSWORD is deliberately left untouched — these characters are legitimate in a password.\n    const cleanEmail=s=>String(s==null?'':s)\n      .normalize('NFKC')\n      .replace(/[̀-ͯ]/g,'')\n      .replace(/[​-‍﻿]/g,'')\n      .replace(/[\\^~´¨`]/g,'')\n      .replace(/\\s+/g,'');\n    // A failed login must NEVER navigate or reload: keep the card, keep what was typed (the morph\n    // patches in place so input values survive), show the error and put the cursor back in the\n    // password so it can be corrected immediately.\n    const failWith=msg=>{\n      d2.setState({busy:false,ok:false,error:msg});\n      setTimeout(()=>{ const p=d2.$('#pw'); if(p){ try{ p.focus(); p.select(); }catch(e){} } },0);\n    };\n    const submit=async()=>{\n      if(d2.state.busy) return;\n      const rawEm=(d2.$('#em')||{}).value||'';\n      const em=cleanEmail(rawEm);\n      if(em!==rawEm && d2.$('#em')) d2.$('#em').value=em;   // show the cleaned address back to the user\n      const pw=((d2.$('#pw')||{}).value||'');\n      if(!em||!pw){ failWith('Preencha e-mail e senha.'); return; }\n      d2.setState({busy:true,error:'',ok:false});\n      try{\n        // Direct interaction with the AuthenticationStrategy — no hand-rolled fetch. The runtime\n        // owns the session envelope, so IT attaches `authorization: Bearer <jwt>` (and the session\n        // cookie) to the login POST. The previous hand-rolled fetch could not: it derived the jwt\n        // from document.cookie on the APP origin while the cookie lives on the SSP origin, so the\n        // header was silently dropped on every attempt — the 401 in devtools. [agent:ad78d19d]\n        // ⚠️ `credentials` MUST stay NESTED — a flat {email,password} authenticates nobody and\n        // fails as an UNCATCHABLE 400/403 (no exception, no rejected promise).\n        // ⚠️ A rejected login is NOT an exception either — after the promise settles, read\n        // `isAuthed`. Do not gate the error UI on catch().\n        const a=d2.auth(AUTH);\n        if(!a){ failWith('Estratégia de autenticação não encontrada ('+AUTH+').'); return; }\n        await a.login({credentials:{email:em,password:pw}});\n        if(a.isAuthed){ enter(); return; }\n        failWith('E-mail ou senha inválidos.');\n      }catch(e){\n        failWith('Falha ao autenticar: '+((e&&e.message)||'erro desconhecido'));\n      }\n    };\n    d2.on('#go','click',submit);\n    d2.on('.inp','keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); submit(); } });\n  }\n}"}}}}}}}},{"_id":"6a8c9a8d58fe54b9af8258f6","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"tuKExBY8jVSIpLVJlzOoK","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const T='product.perfil_cb.crm_perfil_cb_diaria_historico';\nconst ELO='product.elo_modelo_combinado.commercial_consultant_index';\nconst PALETTE=['#5856d6','#ff9500','#28cd41','#af52de','#ff2d55','#a2845e'];\nconst CLOSE_COLOR='#8e8e93';\nconst UF_CODE={RO:'11',AC:'12',AM:'13',RR:'14',PA:'15',AP:'16',TO:'17',MA:'21',PI:'22',CE:'23',RN:'24',PB:'25',PE:'26',AL:'27',SE:'28',BA:'29',MG:'31',ES:'32',RJ:'33',SP:'35',PR:'41',SC:'42',RS:'43',MS:'50',MT:'51',GO:'52',DF:'53'};\nconst UF_BORDERS={AC:['AM','RO'],AL:['BA','PE','SE'],AM:['AC','RO','RR','PA','MT'],AP:['PA'],\n BA:['AL','SE','PE','PI','TO','GO','MG','ES'],CE:['PI','PB','PE','RN'],DF:['GO','MG'],\n ES:['BA','MG','RJ'],GO:['DF','MT','MS','MG','BA','TO'],MA:['PA','TO','PI'],\n MG:['BA','GO','DF','MS','SP','RJ','ES'],MS:['MT','GO','MG','SP','PR'],MT:['RO','AM','PA','TO','GO','MS'],\n PA:['AP','AM','MT','TO','MA','RR'],PB:['RN','CE','PE'],PE:['PB','CE','PI','BA','AL'],\n PI:['MA','TO','BA','PE','CE'],PR:['SP','MS','SC'],RJ:['ES','MG','SP'],RN:['CE','PB'],\n RO:['AC','AM','MT'],RR:['AM','PA'],RS:['SC'],SC:['PR','RS'],SE:['BA','AL'],\n SP:['MG','RJ','PR','MS'],TO:['PA','MA','PI','BA','GO','MT']};\nconst NIVEIS=['Bronze','Cristal','Prata','Ouro','Diamante','Diamante+'];\nconst METRICS_DEF=[['cbs','CBs'],['ativas','Ativas'],['pedidos','Pedidos'],['pontos','Pontos'],['receita','Receita','$'],['rec_nat','Rec. Natura','$'],['rec_avon','Rec. Avon','$']];\nconst OPT_K=1.5;\nconst SESSIONS_DS='geo-timeline-sessionsDataSet';   // dedicated saved-sessions table, separate from SHARED_DS — see notes at listSessions()\n// Span defaults are the real operating band, not placeholders: setor 2200 ≤ span ≤ 3500 (alvo 2700),\n// GV 10000 ≤ span ≤ 22000 (alvo 16000). Mercado não tem um histórico equivalente ainda, então nasce\n// sem limite (null) até alguém configurar. Span agora é uma escala POR NÍVEL: fechar um setor só olha\n// span.setor, fechar uma GV só olha span.gv, fechar um mercado só olha span.mercado — nunca se misturam.\n// Existing sessions are NOT touched — their persisted settings carry explicit values that spread OVER\n// these in openOptions/applyOptions, which is deliberate: their etapas were computed under the old\n// band and changing it silently would leave the stored result and the rule that produced it out of sync.\nconst DEFAULT_SETTINGS={span:{\n  setor:{targetSpan:2700,maxSpan:3500,minSpan:2200},\n  gv:{targetSpan:16000,maxSpan:22000,minSpan:10000},\n  mercado:{targetSpan:null,maxSpan:null,minSpan:null},\n},\n  forceContiguity:true,\n};\nconst DOM_MEDIA_BASE='https://natura-geomarketing.data2.media/file/';\nconst DOM_CSV_FALLBACK=DOM_MEDIA_BASE+'yw_TQdp0QXEk6JsrliAqP'; \nconst numOrNull=v=>{const n=Math.round(+v);return (isFinite(n)&&n>0)?n:null;};\nconst spanShort=st=>{const p=[];if(st&&st.targetSpan)p.push('alvo '+st.targetSpan);if(st&&st.maxSpan)p.push('máx '+st.maxSpan);if(st&&st.minSpan)p.push('mín '+st.minSpan);return p.length?p.join(' · '):'livre';};\n\nexport default {\n  css: `\n  :host{display:block;height:100%;--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--acc2:#0060df;--closing:#ff3b30;--ok:#28cd41;\n    /* Tokens do AppKit real (macOS Big Sur): raio 6 e sombra y1/blur3/15% preto são os valores\n       documentados de botão/controle do sistema, não um chute — usados em todo botão, select,\n       segmented control e tab pra ler como controle nativo em vez de elemento web plano. */\n    --radius-ctrl:6px;--shadow-ctrl:0 1px 3px rgba(0,0,0,.15)}\n  *{box-sizing:border-box}\n  .simroot{position:relative;height:100%;overflow:auto;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}\n  header{padding:10px 16px;background:rgba(246,246,248,.85);backdrop-filter:blur(20px) saturate(1.8);-webkit-backdrop-filter:blur(20px) saturate(1.8);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-wrap:wrap}\n  header h1{font-size:14.5px;margin:0;font-weight:600;letter-spacing:-.1px;color:var(--ink)}\n  .mac-spacer{flex:1}\n  .howto{padding:8px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--mut);align-items:center}\n  .howto b{color:var(--ink)} .howto span{max-width:340px}\n  .pill{font-size:10.5px;padding:2.5px 9px;border-radius:99px;border:1px solid var(--line);color:var(--mut);background:var(--panel2)}\n  .pill.live{color:var(--ink);cursor:pointer;user-select:none;transition:border-color .1s,color .1s,background .1s}\n  .pill.live:hover{border-color:var(--acc);color:var(--acc);background:#eaf3ff}\n  .pill.live:focus-visible{outline:2px solid rgba(0,122,255,.35);outline-offset:1px}\n  .pill.live.busy{border-color:var(--acc);color:var(--acc);cursor:progress}\n  @keyframes pullspin{from{transform:rotate(0)}to{transform:rotate(360deg)}}\n  .spinning{display:inline-block;animation:pullspin .8s linear infinite}\n  .pill.live.locked{border-color:#ff9500;color:#ff9500;background:#fff6ea}\n  .status{font-size:11px;color:var(--mut)}.status.err{color:var(--closing)}\n  select,input,textarea,button{font:inherit}\n  select,input{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:var(--radius-ctrl);padding:5.5px 9px;box-shadow:var(--shadow-ctrl)}\n  /* Seta nativa do <select> cola na borda direita sem respiro nenhum — appearance:none tira a seta\n     do sistema e desenha uma própria, com o mesmo respiro (9px) do padding horizontal do resto. */\n  select{appearance:none;-webkit-appearance:none;-moz-appearance:none;padding-right:26px;\n    background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5.5 8L10 12.5L14.5 8' fill='none' stroke='%238e8e93' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n    background-repeat:no-repeat;background-position:right 9px center;background-size:13px}\n  select:focus,input:focus{outline:2px solid rgba(0,122,255,.35);outline-offset:0;border-color:var(--acc)}\n  /* Native <select>/<datalist> OPEN-LIST popups are drawn by the browser itself and cannot be\n     restyled on any engine — only the closed control can be skinned. .macsel repaints the open list\n     as a real DOM element (an actual AppKit-style popover) while the underlying <select>/<input>\n     stays the real state — same .value/.onchange/.options, every existing read/write untouched. */\n  .macsel{position:relative}\n  .macsel-btn{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;text-align:left;background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:var(--radius-ctrl);padding:5.5px 9px;box-shadow:var(--shadow-ctrl);cursor:pointer}\n  .macsel-btn:hover{background:var(--panel2)}\n  .macsel-btn:focus-visible{outline:2px solid rgba(0,122,255,.35);outline-offset:0;border-color:var(--acc)}\n  .macsel-btn .macsel-lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n  .macsel-btn .macsel-car{color:#8e8e93;flex:0 0 auto;font-size:11px;transform:translateY(1px)}\n  /* .macsel-panel/.macsel-opt/.macsel-empty are styled from a plain <style> injected inside\n     macPortal() (mounted(), below), not here — panels live in a document.body portal, which can't\n     see CSS scoped to this shadow root. Body, not this shadow tree: a shadow-root-relative fixed\n     portal empirically lost the stacking fight against the \"Adicionar movimentação\" modal's own\n     backdrop-filter layer despite a far higher z-index; swapping to a body portal (the platform's\n     documented overlay pattern) painted correctly on the first try. */\n  #moveCode{padding-right:26px;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5.5 8L10 12.5L14.5 8' fill='none' stroke='%238e8e93' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");background-repeat:no-repeat;background-position:right 9px center;background-size:13px}\n  button.act{background:var(--acc);color:#fff;border:1px solid var(--acc2);border-radius:var(--radius-ctrl);padding:6px 13px;font-weight:600;cursor:pointer;box-shadow:var(--shadow-ctrl);transition:background .1s}\n  button.act:hover{background:var(--acc2)}\n  button.act:active{background:#0052c4;box-shadow:inset 0 1px 2px rgba(0,0,0,.25)}\n  button.ghost{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:var(--radius-ctrl);padding:5.5px 12px;cursor:pointer;box-shadow:var(--shadow-ctrl);transition:background .1s}\n  button.ghost:hover{background:var(--panel2)}\n  button.ghost:active{background:#e5e5ea;box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}\n  button.ghost.on{border-color:var(--acc);color:var(--acc);background:#eaf3ff}\n  button.ghost:disabled,button.act:disabled{opacity:.4;cursor:not-allowed;box-shadow:none}\n  .sessbar{padding:9px 20px;background:var(--panel2);border-bottom:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap;align-items:center}\n  .rail{padding:9px 16px;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:8px;align-items:center;overflow-x:auto}\n  #movementChips{display:flex;gap:8px;flex:0 0 auto}\n  #addMovementGroup{display:flex;gap:8px;align-items:center;flex:0 0 auto;margin-left:auto;padding-left:12px;border-left:1px solid var(--line)}\n  #addMovementGroup input{min-width:150px}\n  .tchip{display:flex;align-items:center;gap:7px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;cursor:pointer;font-size:12px;flex:0 0 auto}\n  .tchip.on{border-color:var(--acc);background:#eaf3ff}\n  .tchip.stale{border-style:dashed;opacity:.85}\n  .tchip .tstale{color:#ff9500;font-weight:700}\n  .tchip .tnum{background:var(--ink);color:#fff;border-radius:99px;width:16px;height:16px;font-size:9.5px;display:flex;align-items:center;justify-content:center;font-weight:700}\n  .tchip .tsec{font-weight:600}\n  .tchip .tstat{color:var(--mut)}\n  .tchip .trm{color:var(--mut);font-weight:700;padding:0 3px}\n  .tchip .trm:hover{color:var(--closing)}\n  .tchip.tchip-transfer{background:var(--panel)}\n  .tchip.tchip-transfer .tsec{font-weight:500}\n  .tchip .tarw{color:var(--mut);font-weight:700;font-size:11px}\n  .rail .empty{color:var(--mut);font-size:12px}\n  .wrap{display:grid;grid-template-columns:minmax(360px,1fr) 430px;gap:14px;padding:14px 16px;align-items:start}\n  @media(max-width:1150px){.wrap{grid-template-columns:1fr}}\n  .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:14px;box-shadow:0 1px 3px rgba(0,0,0,.08)}\n  .card h2{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);margin:0 0 9px;font-weight:600}\n  /* user-select, not pointer-events. .lbl already sets pointer-events:none, which stops the text from\n     RECEIVING clicks but does not exclude it from the browser's selection range — so cycling a\n     município's destination with repeated clicks kept dragging a double-click selection across the\n     labels. Nothing drawn in an svg here is text anyone needs to copy. */\n  svg{width:100%;height:auto;display:block;user-select:none;-webkit-user-select:none}\n  /* O <svg> tinha width:100%;height:auto — a altura era só uma função da LARGURA da coluna, então\n     numa tela larga e curta o mapa virava mais alto que o espaço vertical sobrando e a página\n     inteira crescia além da tela. max-height no wrapper + height:100%;width:auto;max-width:100% no\n     próprio svg é o equivalente de object-fit:contain: o navegador encaixa o viewBox 700x500 (via\n     preserveAspectRatio, que já existia) dentro do que sobrar de altura OU largura, o que for menor\n     primeiro, sem distorcer nem cortar nada. */\n  /* Altura real vem de fitMapHeight() (JS): mede quanto sobrou de viewport abaixo do topo do\n     card e aplica isso como altura inline — assim o mapa para exatamente na borda da tela\n     independente do que tem acima (cabeçalho, trilho de etapas, toolbar) ou abaixo dele.\n     60vh aqui é só o valor antes do primeiro paint do JS rodar. */\n  .map-wrap{position:relative;height:60vh;display:flex;justify-content:center;overflow:hidden}\n  #map{cursor:grab;touch-action:none;width:100%;height:100%}\n  #map.dragging{cursor:grabbing}\n  .zoom-pill{position:absolute;left:10px;bottom:10px;display:flex;align-items:stretch;background:var(--panel);border:1px solid var(--line);border-radius:99px;box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);overflow:hidden;z-index:3}\n  .zoom-pill button{width:30px;height:30px;border:0;background:transparent;color:var(--ink);font:16px/1 -apple-system,BlinkMacSystemFont,sans-serif;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center}\n  .zoom-pill button:hover{background:var(--panel2)}\n  .zoom-pill button:active{background:#e5e5ea}\n  .zoom-pill .zoom-div{width:1px;background:var(--line)}\n  .osm-panel{position:absolute;left:10px;top:10px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);padding:8px 10px;font-size:11.5px;z-index:3;min-width:104px;max-height:calc(100% - 20px);overflow-y:auto}\n  .osm-panel-icon{position:absolute;left:10px;top:10px;width:30px;height:30px;display:flex;align-items:center;justify-content:center;background:var(--panel);border:1px solid var(--line);border-radius:9px;box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);cursor:pointer;color:var(--ink);z-index:3;transition:background .1s}\n  .osm-panel-icon:hover{background:var(--panel2)}\n  .osm-panel-icon:active{background:#e5e5ea}\n  .osm-panel-hd{font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between}\n  .osm-group-hd{font-size:9.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600;margin:2px 0 4px}\n  .osm-group-hd.sep{margin-top:8px;padding-top:8px;border-top:1px solid var(--line)}\n  .osm-row{display:flex;align-items:center;gap:6px;padding:2px 0;cursor:pointer;color:var(--ink)}\n  .osm-row.osm-sub{padding-left:14px;font-size:10.5px;color:var(--mut)}\n  .osm-row input{accent-color:var(--acc);margin:0}\n  .osm-status{margin-top:6px;font-size:10px;color:var(--mut);max-width:150px;line-height:1.4}\n  /* Small/short windows (a laptop at 100% zoom, a split-screen browser): the layers panel can list\n     a couple dozen rows (todo nível viário + aeroportos + legendas) and, at full size, run past the\n     bottom of a short map area. max-height+overflow above is the hard backstop so it never draws\n     outside the map no matter what; this shrinks the type scale first so a short-but-not-tiny screen\n     fits everything WITHOUT needing to scroll at all. */\n  @media (max-height:760px){\n    .osm-panel{padding:6px 8px;font-size:10.5px;min-width:92px}\n    .osm-panel-hd{font-size:9px;margin-bottom:4px}\n    .osm-group-hd{font-size:8.5px;margin:1px 0 3px}\n    .osm-row{padding:1px 0;gap:5px}\n    .osm-row.osm-sub{padding-left:12px;font-size:9.5px}\n    .osm-status{font-size:9px;max-width:130px;margin-top:4px}\n  }\n  /* White hairlines inside the closing sector: the municipios read as one territory cut into parts\n     rather than as separate outlined units, and the saturated fills carry the grouping instead of the\n     ink. Neighbour sectors keep their own coloured strokes (.sect) — that contrast is what separates\n     what is being reallocated from what is only context. Selection no longer draws an outline; the\n     white border was the click affordance AND the default is now white, so it had nothing left to say. */\n  .muni{stroke:#fff;stroke-width:1.1;stroke-linejoin:round;stroke-linecap:round;cursor:pointer;transition:filter .1s}.muni:hover{filter:brightness(1.15)}.muni.munihl{filter:brightness(1.15)}\n  /* GV/mercado closing (\"Contorno de Setores\" toggle): traces one unit's (setor's) DISSOLVED\n     territory as a whole, never a per-município line — drawn once per member município, but always\n     BEFORE that unit's own opaque fills, so every internal edge (shared by two municípios of the\n     SAME unit) gets painted over twice by those fills and only the unit's true external boundary\n     survives. Same border-before-fill trick already used above for .sect-rejected-border. */\n  .unit-outline{fill:none;stroke:#fff;stroke-width:1.1;stroke-linejoin:round;stroke-linecap:round;pointer-events:none}\n  .sect{stroke-width:1;stroke-linejoin:round;stroke-linecap:round;fill-opacity:.32;stroke-opacity:.6}\n  /* .sect's normal look is translucent (fill-opacity .32) — the plain brightness bump .muni.munihl\n     uses would barely register on top of that, so this highlight also raises opacity so the piece\n     visibly pops when a fix-card plan hovers it (see moveWork/setPlanHl). */\n  .sect.munihl{fill-opacity:.68;filter:brightness(1.12)}\n  /* Mesmo truque do .sect.rejected acima — stroke = fill, não uma cor de borda diferente. O bucket\n     __MULTI_NOVALID__ pode cobrir VÁRIOS pedaços já pré-recortados do mesmo município compartilhado\n     (mv.sharedMuniSlices fatia geometricamente uma vez, antes de saber quais fatias são válidas) —\n     um stroke diferente do fill desenhava uma costura visível entre cada pedaço mesmo todos pintados\n     do mesmo cinza. */\n  .sect.plain{fill:#e8e8ec;fill-opacity:1}\n  .sect.closed-ctx{fill:#e2e2e7;stroke:#aeaeb2;stroke-dasharray:3 2;fill-opacity:1;stroke-opacity:1}\n  /* stroke igual ao fill, não \"sem stroke\": dois preenchimentos opacos idênticos lado a lado ainda\n     rasterizam com uma costura fina de antialiasing na borda compartilhada — um traço fino da MESMA\n     cor cobre exatamente esse pixel de costura sem acrescentar nenhuma borda visível de verdade.\n     Diferente do truque \"sem stroke\" usado no destino (fill-opacity .32, translúcido): lá, stroke\n     igual ao fill dobra a composição e escurece; aqui, opaco sobre opaco é só a mesma cor de novo. */\n  .sect.rejected{fill:#e4e4ea;stroke:#e4e4ea;stroke-width:0.6;fill-opacity:1;cursor:help}\n  .sect-rejected-border{fill:none;stroke:#b6b6bd;stroke-width:1;stroke-opacity:.6;stroke-linejoin:round;pointer-events:none}\n  /* Same halo trick used by .sect-rejected-border, drawn for the OPPOSITE reason: a município with\n     >1 real sector already gets the diagonal hatch overlay (see multiSectorHatch) — this border makes\n     that same município's outline read clearly against whatever sits behind/around it, since the\n     hatch alone is easy to miss at a glance. */\n  .sect-multi-border{fill:none;stroke:#1c1c1e;stroke-width:0.6;stroke-opacity:.4;stroke-linejoin:round;pointer-events:none}\n  .sect.unresolved{fill:#fbeceb;stroke:#e8a29c;stroke-dasharray:2 2;fill-opacity:1;stroke-opacity:1;cursor:help}\n  .lbl{pointer-events:none;filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.16))}\n  /* No plate behind the text: a halo painted UNDER the glyphs (paint-order) keeps a name legible on any\n     fill without stamping an opaque box over the territory it names. Ink and halo are chosen per label\n     from the luminance of the fill underneath, so they are deliberately NOT declared here — a\n     stylesheet rule outranks a presentation attribute and would silently override that choice. */\n  .lbl text{text-anchor:middle;paint-order:stroke fill;stroke-width:1.8px;stroke-linejoin:round;stroke-linecap:round;letter-spacing:-.1px;font-family:-apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif}\n  .lbl.big text{stroke-width:2.4px}\n  .lbl line.lead{stroke-opacity:.6;stroke-width:.8;stroke-linecap:round}\n  .legend{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;font-size:12px}\n  .legend b{display:inline-block;width:11px;height:11px;border-radius:4px;margin-right:5px;vertical-align:-1px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}\n  .kpis{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-bottom:12px}\n  .kpi{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:9px 11px}\n  .kpi .n{font-size:19px;font-weight:700}.kpi .l{font-size:10.5px;color:var(--mut);margin-top:2px}\n  .kpi.warn{border-color:#ffd1cc}.kpi.warn .n{color:var(--closing)}.kpi.ok{border-color:#bdeecb}.kpi.ok .n{color:var(--ok)}\n  .bar-row{display:flex;align-items:center;gap:8px;margin:6px 0}\n  .bar-row .tag{width:42px;font-weight:600;font-size:12px}\n  .bar-track{flex:1;height:18px;background:var(--panel2);border-radius:5px;overflow:hidden;display:flex;position:relative}\n  .bar-mark{position:absolute;top:0;bottom:0;width:2px;opacity:.9;z-index:2}\n  .bar-base{height:100%;opacity:.4}.bar-inc{height:100%}\n  /* Faixa listrada pra diferenciar um ganho vindo de uma TRANSFERÊNCIA manual de município do ganho\n     normal de uma etapa de fechamento (sólido) — mesma cor do setor por baixo, só o padrão muda.\n     !important pra vencer o style inline (shorthand 'background', que já zera background-image). */\n  .bar-inc.xfer{background-image:repeating-linear-gradient(135deg,rgba(255,255,255,.55) 0 5px,rgba(255,255,255,0) 5px 10px)!important}\n  .bar-val{width:132px;text-align:right;font-size:11px;color:var(--mut);font-variant-numeric:tabular-nums}\n  table{border-collapse:collapse;width:100%;font-size:11.5px}\n  th,td{padding:5px 6px;text-align:right;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}\n  th:first-child,td:first-child{text-align:left}\n  #matrixTobe th,#matrixTobe td{text-align:center}\n  #matrix th,#matrix td{text-align:center}\n  #matrix th:first-child,#matrix td:first-child,#matrixTobe th:first-child,#matrixTobe td:first-child{text-align:left}\n  thead th{color:var(--mut);font-weight:600;font-size:10.5px;position:sticky;top:0;background:var(--panel)}\n  .home{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:4px}\n  .muni-item{display:flex;flex-direction:column;gap:6px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin-bottom:6px}\n  .muni-item-hd{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}\n  .muni-item .nm{font-weight:600;font-size:12px}.muni-item .ct{flex:0 0 auto;color:var(--mut);font-size:11px}\n  .muni-item>.badge{align-self:flex-start}\n  /* Nome + contagem moram na propria linha (.muni-item-hd), separada da linha de destino — nao mais\n     uma unica linha disputando espaco entre nome, contagem e os botoes de setor. GV/mercado pode\n     oferecer bem mais que os 6 destinos do setor (todo irmao da mercado, as vezes dezenas): a linha\n     de destino agora usa a LARGURA INTEIRA do card (stretch do flex-column do .muni-item), e um nome\n     de municipio comprido cresce pra baixo (flex-wrap na linha de cima) em vez de espremer o destino.\n     .seg mantem scroll horizontal proprio (min-width:0 e o que deixa um item flex encolher alem do\n     tamanho do conteudo, liberando a barra) pros casos com destinos demais mesmo com a largura cheia. */\n  .seg{display:flex;background:var(--panel2);border:1px solid rgba(0,0,0,.22);border-radius:7px;overflow-x:auto;overflow-y:hidden;min-width:0}\n  /* Without this, .seg's overflow-x:auto falls back to the OS/browser's native scrollbar — full-size\n     track + arrow buttons on Windows Chromium, dwarfing the 26px-tall control it belongs to. A thin\n     WebKit scrollbar (no buttons, no track chrome) reads as part of the control instead of a foreign\n     OS widget bolted under it. */\n  .seg::-webkit-scrollbar{height:5px}\n  .seg::-webkit-scrollbar-button{display:none;width:0;height:0}\n  .seg::-webkit-scrollbar-track{background:transparent}\n  .seg::-webkit-scrollbar-thumb{background:rgba(0,0,0,.18);border-radius:99px}\n  .seg::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3)}\n  .seg button{border:0;border-right:1px solid var(--line);background:transparent;color:var(--mut);padding:4px 8px;font-size:11px;font-weight:600;cursor:pointer;flex:1 1 auto;min-width:42px;white-space:nowrap}\n  .seg button:last-child{border-right:0}\n  .seg button.on{color:#fff}\n  .toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}\n  /* Segmented control de verdade: trilho cinza (panel2) + o segmento ativo vira uma cápsula branca\n     flutuando por cima (shadow-ctrl), não só uma mudança de cor plana. */\n  .toggle{display:flex;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:2px;gap:1px;font-size:12px}\n  .toggle button{background:transparent;border:0;border-radius:6px;color:var(--mut);padding:5px 10px;cursor:pointer;font-weight:600;transition:background .12s,box-shadow .12s,color .12s}\n  .toggle button.on{background:#fff;color:var(--ink);box-shadow:var(--shadow-ctrl)}\n  .note{color:var(--mut);font-size:10.5px;margin-top:7px;line-height:1.5}\n  .rule-item{padding:9px 0}\n  .rule-item+.rule-item{border-top:1px solid var(--line);margin-top:2px;padding-top:11px}\n  .rule-cap{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin-bottom:5px}\n  .rule-ok{color:var(--ok);font-size:12.5px}\n  .rule-issues{display:flex;flex-direction:column;gap:8px}\n  .rule-issue-row{display:flex;flex-direction:column;gap:7px}\n  .rule-issue-hd{display:flex;align-items:flex-start;gap:6px}\n  .rule-issue-hd button.rule-issue{flex:1 1 auto;min-width:0}\n  button.fixdismiss{flex:0 0 auto;background:transparent;border:1px solid var(--line);color:var(--mut);font-weight:600;font-size:12px;line-height:1;padding:5px 8px;border-radius:var(--radius-ctrl);cursor:pointer;transition:background .1s,color .1s}\n  button.fixdismiss:hover{background:var(--panel2);color:var(--ink)}\n  button.rule-issue{align-self:flex-start;background:#fdeceb;border:1px solid #f3b4ae;color:var(--closing);font-weight:600;font-size:12px;padding:5px 10px;border-radius:var(--radius-ctrl);box-shadow:var(--shadow-ctrl);cursor:pointer;text-align:left;transition:background .1s;max-width:100%;white-space:normal}\n  button.rule-issue:hover{background:#fbdedb}\n  button.rule-issue:disabled{opacity:.6;cursor:progress}\n  .fixresult{display:flex;flex-direction:column;gap:8px}\n  .fixcard{border:1px solid var(--line);background:var(--panel2);border-radius:10px;padding:10px 12px;font-size:12px;line-height:1.55;display:flex;flex-direction:column;gap:7px}\n  .fixcard .chips{display:flex;flex-wrap:wrap;gap:5px}\n  .chip{display:inline-flex;align-items:center;font-size:11px;font-variant-numeric:tabular-nums;padding:2.5px 8px;border-radius:99px;background:var(--panel);border:1px solid var(--line);color:var(--ink)}\n  .chip.bad{color:var(--closing);border-color:#f3b4ae;background:#fdeceb;font-weight:600}\n  .fixcard .verdict{font-weight:600}\n  .fixcard .verdict.ok{color:var(--ok)}\n  .fixcard .verdict.bad{color:var(--closing)}\n  .fixcard .hint{color:var(--mut);font-size:11px}\n  .fixcard .applyfix{align-self:flex-start}\n  .cell{font-variant-numeric:tabular-nums}\n  .lider-foot td{background:var(--panel2);font-weight:600;font-size:11px;color:var(--ink);border-bottom:2px solid var(--line);padding:5px 6px;text-align:left}\n  .scroll{max-height:240px;overflow:auto}\n  /* Tables meant to be READ in full rather than sampled. Only the vertical cap is dropped — overflow\n     stays auto so the wide grupo grid can still scroll sideways inside its card, which is the one axis\n     that genuinely cannot fit. */\n  .scroll.grow{max-height:none}\n  .tip{position:fixed;pointer-events:none;background:#1c1c1e;color:#fff;border-radius:10px;padding:8px 10px;font-size:11px;line-height:1.5;opacity:0;transition:opacity .1s;z-index:9;max-width:280px;max-height:min(420px,80vh);overflow:auto;box-shadow:0 4px 16px rgba(0,0,0,.35)}\n  .tipmuni{margin-top:6px;padding-top:6px;border-top:1px solid rgba(255,255,255,.25)}\n  .tiprow{padding:3px 0}\n  .tiprow-hd{display:flex;align-items:baseline;gap:5px}\n  .tipdot{flex:none;width:11px;text-align:center;font-size:10px;color:#fff}\n  .tiplbl{font-size:9.5px;color:rgba(255,255,255,.65);line-height:1.35}\n  .tipcodes{display:block;margin-left:16px;margin-top:1px;color:#fff;font-weight:600;font-size:12px}\n  .spark{display:flex;align-items:flex-end;gap:3px;height:44px}\n  .spark .b{flex:1;background:var(--acc);border-radius:2px 2px 0 0;min-height:2px;opacity:.85}\n  .delta{font-size:10px}.delta.up{color:var(--ok)}.delta.dn{color:var(--closing)}\n  .tabs{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap}\n  .tabs button{background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--mut);padding:6px 12px;cursor:pointer;font-weight:600;box-shadow:var(--shadow-ctrl);transition:background .1s,box-shadow .1s}\n  .tabs button.on{background:#fff;color:var(--ink);border-color:var(--acc);box-shadow:0 1px 3px rgba(0,0,0,.2)}\n  .hidden{display:none}\n  .sqlbox{width:100%;height:60px;font-family:ui-monospace,Menlo,monospace;font-size:11px;resize:vertical}\n  .badge{font-size:10px;padding:1px 6px;border-radius:5px;background:var(--panel2);border:1px solid var(--line);color:var(--mut)}\n  /* #optBadge é o único badge que costuma quebrar em várias linhas (resumo inteiro do otimizador) —\n     o padding vertical de 1px do .badge genérico (pensado pra um badge de uma linha só) deixa o\n     texto quase colado nas bordas de cima/baixo quando quebra; display:inline-block faz o padding\n     valer pra caixa inteira, não só a ponta da primeira/última linha de um span inline. */\n  #optBadge{display:inline-block;padding:5px 8px;line-height:1.5}\n  .sheet-bg{position:fixed;inset:0;background:rgba(28,28,30,.34);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;z-index:40;padding:24px}\n  .sheet-bg.hidden{display:none}\n  .sheet{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:0 24px 60px rgba(0,0,0,.22);width:min(580px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden}\n  .sheet-hd{display:flex;align-items:center;gap:9px;padding:12px 16px;border-bottom:1px solid var(--line)}\n  .sheet-hd h3{margin:0;font-size:14px;font-weight:600;letter-spacing:-.1px}\n  .sheet-x{background:transparent;border:0;color:var(--mut);font-size:13px;cursor:pointer;padding:3px 7px;border-radius:6px}\n  .sheet-x:hover{background:var(--panel2);color:var(--ink)}\n  .sheet-bd{padding:14px 16px;overflow:auto}\n  .sheet-ft{display:flex;align-items:center;gap:8px;padding:11px 16px;border-top:1px solid var(--line);background:var(--panel2)}\n  .opt-lbl{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);font-weight:600}\n  .opt-sub{font-size:11.5px;color:var(--mut);margin:4px 0 10px;line-height:1.5}\n  .opt-row{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-bottom:8px;cursor:pointer;background:var(--panel)}\n  .opt-row:hover{background:var(--panel2)}\n  .opt-row.on{border-color:var(--acc);background:#eaf3ff}\n  .opt-row:has(input:disabled){opacity:.5;cursor:not-allowed}\n  .opt-row input[type=radio]{margin:2px 0 0;accent-color:var(--acc);flex:0 0 auto}\n  .opt-t{font-size:12.5px;font-weight:600;display:flex;align-items:center;gap:6px;flex-wrap:wrap}\n  .opt-d{font-size:11.5px;color:var(--mut);margin-top:3px;line-height:1.55}\n  .opt-inline{font-size:11.5px;color:var(--mut);margin-top:9px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}\n  .opt-inline.off{opacity:.4;pointer-events:none}\n  .opt-note{border:1px solid var(--line);background:var(--panel2);border-radius:10px;padding:9px 11px;font-size:11.5px;color:var(--mut);line-height:1.6;margin-top:12px}\n  .opt-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:2px}\n  .opt-fld{display:flex;flex-direction:column;gap:5px;border:1px solid var(--line);border-radius:9px;padding:8px 10px;background:var(--panel2)}\n  .opt-fld span{font-size:11px;color:var(--mut);font-weight:600;line-height:1.35}\n  .opt-fld i{font-style:normal;font-weight:400;opacity:.72}\n  .opt-fld input{width:100%;background:var(--panel)}\n  .opt-err{margin-top:9px;font-size:11.5px;color:var(--closing);font-weight:600}\n  .opt-hr{height:1px;background:var(--line);margin:17px 0 0}\n  .shareurl{margin-top:9px;padding:9px 10px;border-radius:8px;background:var(--panel);border:1px solid var(--acc);\n    font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--ink);word-break:break-all;user-select:all}\n`,\n  render(d2){\n    return `<div class=\"simroot\"><header id=\"topHeader\" class=\"hidden\">\n  <span class=\"pill live\" id=\"liveState\" role=\"button\" tabindex=\"0\" title=\"Clique pra atualizar o snapshot para o mais recente\">● iniciando</span>\n  <span class=\"status\" id=\"ds\"></span>\n  <div class=\"mac-spacer\"></div>\n  <select id=\"sessionPick\" style=\"min-width:200px\"></select>\n  <button class=\"ghost\" id=\"renameSession\" title=\"Renomear esta sessão\">✎</button>\n  <button class=\"ghost\" id=\"openOptions\" title=\"Configurações desta sessão\">⚙ Opções</button>\n  <button class=\"act\" id=\"saveSession\">Salvar sessão</button>\n  <button class=\"ghost\" id=\"dupSession\" title=\"Cria uma cópia independente desta sessão — a original fica intacta e a cópia passa a ser a sessão ativa\">⧉ Duplicar</button>\n  <button class=\"ghost\" id=\"openShare\" title=\"Publicar uma cópia congelada desta sessão em modo leitura\">⇪ Compartilhar</button>\n  <span class=\"status\" id=\"sessStatus\"></span>\n</header>\n\n<div class=\"rail hidden\" id=\"timelineRail\">\n  <div id=\"movementChips\"></div>\n  <div id=\"addMovementGroup\">\n    <button class=\"act\" id=\"openMove\">Adicionar movimentação</button>\n  </div>\n</div>\n\n<div class=\"card\" id=\"confirmCard\" style=\"margin:14px 20px\">\n  <h2 style=\"margin:0 0 9px\">Confirmar snapshot antes de simular</h2>\n  <div class=\"note\" id=\"confirmBody\">congelando snapshot…</div>\n  <div class=\"opt-hr\"></div>\n  <div class=\"opt-lbl\">Ciclo operacional (ELO)</div>\n  <div class=\"opt-sub\">Usado no relatório de tendência (ELO) por etapa. Escolha antes de simular — digite um ciclo específico ou use o mais recente fechado.</div>\n  <div class=\"opt-inline\">\n    <input type=\"number\" id=\"eloCycleInput\" placeholder=\"ex: 202608\" style=\"min-width:140px\">\n    <button class=\"ghost\" id=\"eloCycleApply\">Usar este ciclo</button>\n    <button class=\"ghost\" id=\"eloCycleAuto\">Usar o ciclo mais recente</button>\n  </div>\n  <div class=\"status\" id=\"eloCycleStatus\" style=\"margin-top:4px\"></div>\n  <button class=\"act\" id=\"confirmSnap\" style=\"margin-top:10px\">Confirmar snapshot ▶ ir pra simulação</button>\n  <div class=\"note\" style=\"margin-top:16px\">ou continue uma sessão salva:</div>\n  <select id=\"confirmSessionPick\" style=\"min-width:260px;margin-top:4px\"></select>\n  <div class=\"status\" id=\"confirmStatus\" style=\"margin-top:8px\"></div>\n</div>\n\n<div class=\"wrap hidden\" id=\"mainWrap\">\n  <div>\n    <div class=\"card\">\n      <div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:9px\">\n        <div class=\"toggle\" id=\"viewToggle\" title=\"ASIS = estrutura do setor antes desta etapa (já considerando etapas anteriores) · TOBE = após a redistribuição desta etapa\">\n          <button data-v=\"asis\">◧ ASIS (antes desta etapa)</button>\n          <button data-v=\"tobe\" class=\"on\">◨ TOBE (simulação)</button>\n        </div>\n      </div>\n      <div class=\"map-wrap\">\n        <svg id=\"map\" viewBox=\"0 0 700 500\" preserveAspectRatio=\"xMidYMid meet\"></svg>\n        <button class=\"osm-panel-icon hidden\" id=\"osmPanelIcon\" title=\"Mostrar camadas\">\n          <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\">\n            <path d=\"M12 2 2 7l10 5 10-5-10-5Z\" fill=\"currentColor\"/>\n            <path d=\"m2 12 10 5 10-5\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" opacity=\".55\"/>\n            <path d=\"m2 17 10 5 10-5\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" opacity=\".3\"/>\n          </svg>\n        </button>\n        <div class=\"osm-panel\" id=\"osmPanel\" title=\"Camadas exibidas no mapa, limitadas à área desta etapa\">\n          <div class=\"osm-panel-hd\">Camadas<button class=\"sheet-x osm-panel-x\" id=\"osmPanelX\" title=\"Recolher\">✕</button></div>\n          <div class=\"osm-group-hd\">Navegação</div>\n          <label class=\"osm-row\" title=\"Marca liga todos os níveis viários de uma vez; depois de carregado, esconda um nível por vez abaixo\"><input type=\"checkbox\" id=\"osmRodovias\"><span>Rodovias</span></label>\n          <label class=\"osm-row osm-sub\" title=\"highway=motorway\"><input type=\"checkbox\" id=\"osmMotorway\"><span>Rodovia expressa</span></label>\n          <label class=\"osm-row osm-sub\" title=\"highway=trunk\"><input type=\"checkbox\" id=\"osmTrunk\"><span>Via arterial</span></label>\n          <label class=\"osm-row osm-sub\" title=\"highway=primary\"><input type=\"checkbox\" id=\"osmPrimary\"><span>Rodovia principal</span></label>\n          <label class=\"osm-row osm-sub\" title=\"highway=secondary\"><input type=\"checkbox\" id=\"osmSecondary\"><span>Rodovia secundária</span></label>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"osmRivers\"><span>Rios</span></label>\n          <div class=\"osm-group-hd sep\">Outros</div>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"osmAirports\"><span>Aeroportos</span></label>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"muniOutline\"><span id=\"muniOutlineLbl\">Contorno de municípios</span></label>\n          <div class=\"osm-group-hd sep\">Legendas</div>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"legendMuni\"><span id=\"legendMuniLbl\">Nome de municípios</span></label>\n          <label class=\"osm-row\"><input type=\"checkbox\" id=\"legendCtx\"><span>Código de setores adjacentes</span></label>\n          <div class=\"osm-status\" id=\"osmStatus\"></div>\n        </div>\n        <div class=\"zoom-pill\" id=\"mapZoom\">\n          <button type=\"button\" id=\"mapZoomOut\" title=\"Diminuir zoom\">−</button>\n          <span class=\"zoom-div\"></span>\n          <button type=\"button\" id=\"mapZoomIn\" title=\"Aumentar zoom\">+</button>\n        </div>\n      </div>\n      <div class=\"legend\" id=\"legend\"></div>\n      <div class=\"note\" id=\"mapLegendNote\">Coloridos = municípios do setor fechado nesta etapa (cor = destino). Ao redor, cada área é um <b>setor vizinho inteiro</b> com o código no meio — não municípios soltos: cor = setor destino, cinza = contexto, cinza tracejado = setor já fechado numa etapa anterior. Shapes reais IBGE/tbrugz.</div>\n      <div class=\"note\" id=\"mapIgnoredNote\"></div>\n    </div>\n\n    <div class=\"card\">\n      <h2 id=\"leadersTitle\">Líderes — destino e CBs deslocadas (nesta etapa)</h2>\n      <div class=\"note\" id=\"leadersNote\" style=\"margin:-4px 0 9px\"><b>Home</b> = setor que ficou com a maioria das CBs da líder. <b style=\"color:var(--closing)\">Deslocadas</b> = CBs dela que caíram noutro setor.</div>\n      <div class=\"scroll grow\"><table id=\"leaders\"></table></div>\n    </div>\n\n    <div class=\"card hidden\" id=\"leadersXferBlock\">\n      <h2 id=\"leadersXferTitle\">Líder da transferência manual</h2>\n      <div class=\"scroll grow\"><table id=\"leadersXfer\"></table></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"tabs\">\n        <button data-tab=\"matrix\" class=\"on\">Matriz ASIS</button>\n        <button data-tab=\"tobe\">Matriz TOBE</button>\n        <button data-tab=\"report\">Relatório de impacto (etapa)</button>\n        <button data-tab=\"cumulative\">Impacto acumulado (timeline)</button>\n        <button data-tab=\"sql\">Console SQL</button>\n      </div>\n      <div id=\"tab-matrix\"><div class=\"scroll grow\"><table id=\"matrix\"></table></div>\n        <div id=\"matrixXferBlock\" class=\"hidden\" style=\"margin-top:14px\">\n          <h2 id=\"matrixXferTitle\">Município transferido</h2>\n          <div class=\"scroll grow\"><table id=\"matrixXfer\"></table></div>\n        </div>\n      </div>\n      <div id=\"tab-tobe\" class=\"hidden\"><div class=\"scroll grow\"><table id=\"matrixTobe\"></table></div>\n        <div id=\"matrixTobeXferBlock\" class=\"hidden\" style=\"margin-top:14px\">\n          <h2 id=\"matrixTobeXferTitle\">Município transferido</h2>\n          <div class=\"scroll grow\"><table id=\"matrixTobeXfer\"></table></div>\n        </div>\n        <div class=\"note\" id=\"tobeNote\"></div></div>\n      <div id=\"tab-report\" class=\"hidden\"></div>\n      <div id=\"tab-cumulative\" class=\"hidden\"></div>\n      <div id=\"tab-sql\" class=\"hidden\">\n        <textarea class=\"sqlbox\" id=\"sqlIn\">SELECT DS_MUNICIPIO, COUNT(DISTINCT CD_CONSULTORA) qtd FROM product.perfil_cb.crm_perfil_cb_diaria_historico WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR=3504 GROUP BY DS_MUNICIPIO ORDER BY qtd DESC</textarea>\n        <div class=\"toolbar\" style=\"margin:8px 0\"><button class=\"act\" id=\"runSql\">▶ Rodar</button><span class=\"status\" id=\"sqlStatus\"></span></div>\n        <div class=\"note\" style=\"margin:-4px 0 8px\">Roda contra o snapshot congelado desta sessão (não \"ao vivo\") — mesma coerência da timeline.</div>\n        <div class=\"scroll\"><table id=\"sqlOut\"></table></div>\n      </div>\n    </div>\n  </div>\n\n  <div>\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"margin-bottom:11px\">\n        <button class=\"ghost\" id=\"optim\" title=\"testa alocações que mantêm cada setor conectado e escolhe a que equilibra a carga entre setores + minimiza a quebra (evita empilhar tudo num setor)\">★ Modelo ÓTIMO (equilíbrio + quebra)</button>\n        <button class=\"ghost\" id=\"autoTobe\" title=\"preenche cada município pelo setor vizinho mais próximo (geografia)\">TOBE por adjacência</button>\n        <span class=\"badge\" id=\"optBadge\"></span>\n      </div>\n      <div class=\"note\" style=\"margin:-4px 0 10px\"><b style=\"color:var(--ink)\">★ ÓTIMO</b> = equilibra a carga entre os setores <b>e</b> minimiza a quebra (mantém o território conectado; não empilha tudo num setor). <b style=\"color:var(--ink)\">TOBE</b> = sugestão por vizinhança. Editar esta etapa recalcula automaticamente as etapas seguintes da timeline.</div>\n      <div class=\"note\" id=\"ruleNote\" style=\"margin:-6px 0 11px;padding:7px 9px;background:var(--panel2);border:1px solid var(--line);border-radius:8px\"></div>\n      <div class=\"kpis\" id=\"kpis\"></div>\n      <div id=\"contig\"></div>\n      <div id=\"spanRule\"></div>\n      <h2 id=\"spansHd\">Span por setor (CBs) — base + entrantes</h2>\n      <div id=\"spans\"></div>\n      <div id=\"policySection\">\n      <div style=\"display:flex;justify-content:space-between;align-items:center;margin:14px 0 7px\">\n        <h2 style=\"margin:0\">CBs deslocadas</h2>\n        <div class=\"toggle\" id=\"policy\">\n          <button data-p=\"fora\" class=\"on\">= fora do setor</button>\n          <button data-p=\"quebra\">= quebra de relação</button>\n        </div>\n      </div>\n      <div class=\"note\" id=\"policyNote\"></div>\n      </div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"justify-content:space-between\">\n        <h2 id=\"muniListTitle\" style=\"margin:0\">Municípios → setor destino</h2>\n        <button class=\"ghost\" id=\"togglePull\" title=\"Habilitar movimentação de municípios de outros setores\">✎</button>\n      </div>\n      <div class=\"note\" id=\"muniListNote\" style=\"margin:-4px 0 9px\">Clique no <b>código do setor</b> pra mandar o município <b>inteiro</b> pra lá (regra de negócio). A cor acompanha o mapa.</div>\n      <div id=\"muniList\"></div>\n    </div>\n\n    <div class=\"card\">\n      <div class=\"toolbar\" style=\"justify-content:space-between\">\n        <h2 style=\"margin:0\">Plano de migração (etapa selecionada)</h2>\n        <div><button class=\"ghost\" id=\"copyPlan\">Copiar</button> <button class=\"ghost\" id=\"csvPlan\">CSV</button></div>\n      </div>\n      <div id=\"plan\" class=\"note\"></div>\n    </div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"shareBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"shareTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"shareTitle\">Compartilhar sessão</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"shareX\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\" id=\"shareBody\"></div>\n    <div class=\"sheet-ft\">\n      <span class=\"status\" id=\"shareStatus\"></span>\n      <div class=\"mac-spacer\"></div>\n      <button class=\"ghost hidden\" id=\"shareCopy\">Copiar link</button>\n      <button class=\"ghost hidden\" id=\"shareOpen\">Abrir</button>\n      <button class=\"ghost\" id=\"shareCancel\">Cancelar</button>\n      <button class=\"act\" id=\"shareGo\">Compartilhar</button>\n    </div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"optionsBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"optTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"optTitle\">Opções da sessão</h3><span class=\"badge\" id=\"optSessName\"></span><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"optClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-lbl\">Pré-requisitos do destino — tamanho em CBs</div>\n      <div class=\"opt-sub\">Vale pro tamanho de cada unidade receptora <b>depois</b> da redistribuição (base + entrantes) — uma escala por nível de fechamento. Em branco = desligado.</div>\n      <div class=\"opt-t\" style=\"margin-top:2px\">Setor</div>\n      <div class=\"opt-grid\">\n        <label class=\"opt-fld\"><span>Span alvo <i>(tamanho ideal)</i></span><input type=\"number\" id=\"optTargetSpan_setor\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"livre\"></label>\n        <label class=\"opt-fld\"><span>Span máximo <i>(nenhum setor acima)</i></span><input type=\"number\" id=\"optMaxSpan_setor\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n        <label class=\"opt-fld\"><span>Span mínimo <i>(nenhum setor abaixo)</i></span><input type=\"number\" id=\"optMinSpan_setor\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n      </div>\n      <div class=\"opt-t\" style=\"margin-top:12px\">GV</div>\n      <div class=\"opt-grid\">\n        <label class=\"opt-fld\"><span>Span alvo <i>(tamanho ideal)</i></span><input type=\"number\" id=\"optTargetSpan_gv\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"livre\"></label>\n        <label class=\"opt-fld\"><span>Span máximo <i>(nenhuma GV acima)</i></span><input type=\"number\" id=\"optMaxSpan_gv\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n        <label class=\"opt-fld\"><span>Span mínimo <i>(nenhuma GV abaixo)</i></span><input type=\"number\" id=\"optMinSpan_gv\" min=\"1\" max=\"99999\" step=\"1\" placeholder=\"sem limite\"></label>\n      </div>\n      <div class=\"opt-hr\"></div>\n      <div class=\"opt-lbl\" style=\"margin-top:14px\">Conectividade de região</div>\n      <div class=\"opt-sub\">Vale para fechamento de Setor, GV e Mercado. Controla o que acontece quando um clique no mapa deixaria alguma unidade (município, setor ou GV, conforme o nível) com o território partido em pedaços desconectados — o aviso <b style=\"color:var(--closing)\">⚠ Território desconectado</b> abaixo do mapa.</div>\n      <label class=\"opt-row\"><input type=\"radio\" name=\"forceContig\" value=\"0\"><div><div class=\"opt-t\">Livre</div><div class=\"opt-d\">Clicar avança pro próximo destino da lista, mesmo que isso deixe o território de alguma unidade desconectado. Resolva depois com ★ ÓTIMO ou reassociando manualmente no mapa.</div></div></label>\n      <label class=\"opt-row\"><input type=\"radio\" name=\"forceContig\" value=\"1\"><div><div class=\"opt-t\">Forçar conectividade de território <i style=\"font-weight:400;color:var(--mut)\">(padrão)</i></div><div class=\"opt-d\">Clicar nunca deixa uma unidade desconectada: pula o(s) destino(s) da lista que quebrariam a conectividade e escolhe o próximo que mantém tudo conectado. Se nenhum destino for seguro, o clique não muda nada.</div></div></label>\n      <div class=\"opt-err hidden\" id=\"optSpanErr\"></div>\n      <div class=\"opt-note\" id=\"optPreview\"></div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"optStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"optCancel\">Cancelar</button><button class=\"act\" id=\"optApply\">Aplicar</button></div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"moveBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"moveTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"moveTitle\">Adicionar movimentação</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"moveClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-lbl\">Tipo de movimentação</div>\n      <div class=\"opt-sub\" id=\"moveLockNote\">Escolha o nível a fechar. Você pode fechar setores à vontade até fechar uma GV; aí só GV (ou mercado) daqui pra frente. Fechar um mercado trava a sessão em mercado.</div>\n      <label class=\"opt-row\" data-lvl=\"setor\"><input type=\"radio\" name=\"moveLevel\" value=\"setor\"><div><div class=\"opt-t\">Fechar Setor</div><div class=\"opt-d\">Redistribui os municípios do setor entre setores vizinhos da mesma GV.</div></div></label>\n      <label class=\"opt-row\" data-lvl=\"gv\"><input type=\"radio\" name=\"moveLevel\" value=\"gv\"><div><div class=\"opt-t\">Fechar GV</div><div class=\"opt-d\">Redistribui os setores (com municípios e CBs) da GV entre GVs vizinhas do mesmo mercado.</div></div></label>\n      <label class=\"opt-row\" data-lvl=\"mercado\"><input type=\"radio\" name=\"moveLevel\" value=\"mercado\"><div><div class=\"opt-t\">Fechar Mercado</div><div class=\"opt-d\">Redistribui as GVs (com setores, municípios e CBs) do mercado entre mercados vizinhos.</div></div></label>\n      <div class=\"opt-hr\"></div>\n      <div class=\"opt-lbl\" style=\"margin-top:15px\" id=\"moveCodeLbl\">Setor a fechar</div>\n      <div class=\"opt-inline\" id=\"moveCodeRow\">\n        <input id=\"moveCode\" list=\"moveCodeList\" placeholder=\"código\" autocomplete=\"off\" style=\"min-width:150px\">\n        <datalist id=\"moveCodeList\"></datalist>\n        <button class=\"ghost\" id=\"moveRand\" title=\"Aleatório\">🎲</button>\n      </div>\n      <div class=\"opt-err hidden\" id=\"moveErr\"></div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"moveStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"moveCancel\">Cancelar</button><button class=\"act\" id=\"moveGo\">Fechar ▶ (nova etapa)</button></div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"pullBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"pullTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"pullTitle\">Mover município</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"pullClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-lbl\">Município</div>\n      <div class=\"opt-sub\" id=\"pullMuniInfo\"></div>\n      <div class=\"opt-hr\"></div>\n      <div class=\"opt-lbl\" style=\"margin-top:15px\">Mandar pra</div>\n      <div class=\"seg\" id=\"pullDestSeg\"></div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"pullStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"pullCancel\">Cancelar</button><button class=\"act\" id=\"pullGo\">Aplicar</button></div>\n  </div>\n</div>\n\n<div class=\"sheet-bg hidden\" id=\"lockedBg\">\n  <div class=\"sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"lockedTitle\">\n    <div class=\"sheet-hd\"><h3 id=\"lockedTitle\">Sessão travada num snapshot antigo</h3><div class=\"mac-spacer\"></div><button class=\"sheet-x\" id=\"lockedClose\" title=\"Fechar\">✕</button></div>\n    <div class=\"sheet-bd\">\n      <div class=\"opt-sub\" id=\"lockedBody\">Esta sessão foi carregada a partir dos dados já calculados e congelados quando ela foi salva — nenhuma consulta rodou ainda. Atualizar o snapshot agora recalcula a timeline inteira contra os dados mais recentes, o que pode mudar os resultados desta simulação (destinos, spans, CBs deslocadas etc).</div>\n      <div class=\"opt-note\">Depois de atualizar, a sessão deixa de estar travada e você pode repetir a ação que tentou fazer.</div>\n    </div>\n    <div class=\"sheet-ft\"><span class=\"status\" id=\"lockedStatus\"></span><div class=\"mac-spacer\"></div><button class=\"ghost\" id=\"lockedCancel\">Cancelar</button><button class=\"act\" id=\"lockedGo\">Atualizar snapshot</button></div>\n  </div>\n</div>\n\n\n\n\n\n<div class=\"tip\" id=\"tip\"></div>\n<span id=\"ready\" style=\"display:none\"></span></div>`;\n  },\n  mounted(d2, root){\n\nconst $=s=>root.querySelector(s);\nconst fmt=n=>n==null?'–':Math.round(+n).toLocaleString('pt-BR');\nconst money=n=>{n=+n||0;return n>=1e6?'R$'+(n/1e6).toFixed(1)+'M':n>=1e3?'R$'+(n/1e3).toFixed(0)+'k':'R$'+Math.round(n)};\nconst norm=s=>(s||'').normalize('NFKD').replace(/[̀-ͯ]/g,'').toUpperCase().replace(/[^A-Z0-9]+/g,' ').trim();\nconst esc=s=>(s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\n// Single #tip element shared by every hover surface on the map (setores, municípios, aeroportos,\n// descartadas). Positioned relative to the cursor with a small offset, then clamped against the\n// viewport so a long list (a shared município with a couple dozen setores) never gets cut off past\n// the right/bottom edge — offsetWidth/Height are read AFTER innerHTML is set, since the box's size\n// depends on its content, not on wherever it was last drawn.\nconst showTip=(e,html)=>{\n  const t=$('#tip'); if(!t) return;\n  t.innerHTML=html;\n  t.style.opacity=1;\n  let x=e.clientX+12, y=e.clientY+12;\n  const w=t.offsetWidth, h=t.offsetHeight;\n  if(x+w>window.innerWidth-8) x=e.clientX-w-12;\n  if(y+h>window.innerHeight-8) y=e.clientY-h-12;\n  t.style.left=Math.max(8,x)+'px';\n  t.style.top=Math.max(8,y)+'px';\n};\n// ---------- macOS-style dropdown ----------\n// A native <select>/<datalist> OPEN-LIST popup is drawn by the browser itself and cannot be\n// restyled on any engine — only the closed control can be skinned. Everything below repaints the\n// open list as a real DOM element (an actual AppKit-style popover) while the underlying\n// <select>/<input> stays the real state — same .value/.onchange/.options, every existing\n// read/write call site untouched.\nlet macGlobalCloseBound=false;\n// Panels used to be a plain child of their .macsel wrapper, positioned relative to it — fine until\n// one landed inside the movimentação modal (.sheet, overflow:hidden ⊃ .sheet-bd, overflow:auto),\n// which clipped it to the modal's own box the moment it tried to extend past it. A first fix (a\n// fixed-position portal still inside this shadow root) escaped the clipping but NOT the stacking\n// fight against the modal's own backdrop-filter layer — panels rendered behind it despite a far\n// higher z-index. macPortal() lives in document.body instead, the platform's own documented\n// pattern for any overlay a code component draws (see code-component-overlays): body content\n// stacks against the WHOLE page, immune to any shadow-DOM/backdrop-filter compositing quirk, and\n// isn't reachable by this shadow tree's scoped `css`, so its rules are injected as a plain <style>\n// right here instead — colours hardcoded since var(--ink) etc. only resolve inside :host.\nfunction macPortal(){\n  let p=document.getElementById('macPortal');\n  if(p) return p;\n  p=document.createElement('div'); p.id='macPortal';\n  // isolation:isolate + its own z-index, not just relying on DOM order/appendChild-puts-it-last:\n  // without this, #macPortal (z-index:auto) never forms its own stacking context, so its child\n  // .macsel-panel's z-index couldn't be trusted to outrank whatever ELSE the host page already has\n  // sitting at the same fixed/root level (verified empirically — the panel rendered fully invisible,\n  // behind an unrelated body-level layer belonging to the app's own chrome, despite a much higher\n  // z-index on the panel itself, until the portal was made its own isolated context here).\n  p.style.cssText='position:fixed;left:0;top:0;width:0;height:0;z-index:2147483000;isolation:isolate;';\n  const style=document.createElement('style');\n  style.textContent=`\n    #macPortal .macsel-panel{position:fixed;min-width:160px;max-width:min(420px,90vw);max-height:280px;overflow:auto;z-index:2147483000;\n      background:rgba(255,255,255,.96);backdrop-filter:blur(30px) saturate(180%);-webkit-backdrop-filter:blur(30px) saturate(180%);\n      border:1px solid rgba(0,0,0,.08);border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.20);padding:5px;\n      font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;color:#1c1c1e;-webkit-font-smoothing:antialiased}\n    #macPortal .macsel-panel.hidden{display:none}\n    #macPortal .macsel-opt{padding:6px 9px;border-radius:6px;cursor:pointer;font-size:13px;white-space:nowrap}\n    #macPortal .macsel-opt:hover{background:rgba(0,0,0,.05)}\n    #macPortal .macsel-opt.sel{background:#0064e1;color:#fff}\n    #macPortal .macsel-empty{padding:6px 9px;font-size:12px;color:#6e6e73}\n  `;\n  p.appendChild(style);\n  document.body.appendChild(p);\n  if(d2.onUnmount) d2.onUnmount(()=>p.remove());\n  return p;\n}\n// #loader lived inside .simroot (position:relative;height:100%;overflow:auto) as position:absolute;inset:0 —\n// anchored to .simroot's own (one-screenful) padding box, not to its taller scrollable CONTENT, so it only\n// ever dimmed the top screenful and never moved with scroll, leaving real content exposed below it once the\n// user scrolled past that first screen. Same class of bug macPortal() above already fixed for select panels:\n// portal it to document.body as a true position:fixed;inset:0 overlay, immune to any shadow-DOM containing\n// block or .simroot's own scrolling.\nfunction loaderPortal(){\n  let p=document.getElementById('loaderPortal');\n  if(p) return p;\n  p=document.createElement('div'); p.id='loaderPortal'; p.className='hidden';\n  p.style.cssText='position:fixed;inset:0;z-index:2147483000;';\n  const style=document.createElement('style');\n  style.textContent=`\n    #loaderPortal{background:rgba(245,245,247,.85);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;color:#007aff;font-weight:600;text-align:center;padding:20px}\n    #loaderPortal.hidden{display:none}\n  `;\n  document.body.appendChild(style);\n  document.body.appendChild(p);\n  if(d2.onUnmount) d2.onUnmount(()=>{ p.remove(); style.remove(); });\n  return p;\n}\nfunction ensureMacGlobalClose(){\n  if(macGlobalCloseBound) return; macGlobalCloseBound=true;\n  // e.target is retargeted to the shadow HOST for a listener bound on document for any click that\n  // originated inside the shadow tree (the trigger buttons/inputs still live there). composedPath()\n  // carries the real, un-retargeted ancestor chain through the shadow boundary, so checking it\n  // instead is what lets the click that OPENS a panel not immediately close it again on the very\n  // same tick. Panels live in macPortal() (document.body), detached from their trigger's DOM\n  // subtree entirely — panel and button are in different documents-worth of nesting, not\n  // parent/child — so p.closest('.macsel') can never answer \"was this click on the trigger\"; each\n  // panel keeps its trigger on ._macTrigger at creation time instead, checked against the same path.\n  document.addEventListener('click',e=>{\n    const path=e.composedPath();\n    document.querySelectorAll('#macPortal .macsel-panel').forEach(p=>{ if(!path.includes(p)&&!(p._macTrigger&&path.includes(p._macTrigger))) p.classList.add('hidden'); });\n  });\n}\nfunction closeAllMacSelects(){ document.querySelectorAll('#macPortal .macsel-panel').forEach(p=>p.classList.add('hidden')); }\nfunction positionMacPanel(trigger,panel){\n  const r=trigger.getBoundingClientRect();\n  const up=window.innerHeight-r.bottom<280 && r.top>280;\n  panel.classList.toggle('up', up);\n  panel.style.left=Math.round(r.left)+'px';\n  panel.style.minWidth=Math.round(r.width)+'px';\n  if(up){ panel.style.bottom=Math.round(window.innerHeight-r.top+4)+'px'; panel.style.top=''; }\n  else{ panel.style.top=Math.round(r.bottom+4)+'px'; panel.style.bottom=''; }\n}\nfunction buildMacPanel(sel,panel){\n  const opts=[...sel.options];\n  panel.innerHTML=opts.length?'':'<div class=\"macsel-empty\">nenhuma opção</div>';\n  opts.forEach(o=>{\n    const row=document.createElement('div'); row.className='macsel-opt'+(o.selected?' sel':''); row.setAttribute('role','option');\n    row.textContent=o.textContent;\n    row.onmousedown=e=>{ e.preventDefault(); sel.value=o.value; panel.classList.add('hidden'); syncMacSelect(sel); sel.dispatchEvent(new Event('change')); };\n    panel.appendChild(row);\n  });\n}\nfunction syncMacSelect(sel){\n  const wrap=sel.closest('.macsel'); if(!wrap) return;\n  const lbl=wrap.querySelector('.macsel-lbl'); if(!lbl) return;\n  const cur=sel.options[sel.selectedIndex];\n  lbl.textContent=cur?cur.textContent:'';\n}\nfunction initMacSelect(sel){\n  if(!sel) return;\n  if(sel.dataset.macInit){ syncMacSelect(sel); return; }\n  sel.dataset.macInit='1';\n  const wrap=document.createElement('div'); wrap.className='macsel';\n  wrap.style.cssText=sel.getAttribute('style')||'';\n  sel.parentNode.insertBefore(wrap,sel); wrap.appendChild(sel);\n  sel.style.display='none';\n  const btn=document.createElement('button'); btn.type='button'; btn.className='macsel-btn';\n  btn.innerHTML='<span class=\"macsel-lbl\"></span><span class=\"macsel-car\">▾</span>';\n  const panel=document.createElement('div'); panel.className='macsel-panel hidden'; panel.setAttribute('role','listbox');\n  panel._macTrigger=btn;\n  wrap.appendChild(btn); macPortal().appendChild(panel);\n  btn.onclick=()=>{\n    const willOpen=panel.classList.contains('hidden');\n    closeAllMacSelects();\n    if(!willOpen) return;\n    buildMacPanel(sel,panel); panel.classList.remove('hidden'); positionMacPanel(btn,panel);\n  };\n  ensureMacGlobalClose();\n  syncMacSelect(sel);\n}\nfunction initMacCombo(inp,dl){\n  if(!inp||!dl||inp.dataset.macInit) return;\n  inp.dataset.macInit='1';\n  inp.removeAttribute('list');\n  const wrap=document.createElement('div'); wrap.className='macsel'; wrap.style.display='inline-block';\n  inp.parentNode.insertBefore(wrap,inp); wrap.appendChild(inp);\n  const panel=document.createElement('div'); panel.className='macsel-panel hidden'; panel.setAttribute('role','listbox');\n  panel._macTrigger=inp;\n  macPortal().appendChild(panel);\n  const buildPanel=()=>{\n    const qv=norm(inp.value);\n    const opts=[...dl.options].filter(o=>!qv||norm(o.value+' '+o.textContent).indexOf(qv)>=0);\n    // Options come from an async load (ensureDominantes) that can take a moment on a cold cache —\n    // an empty <datalist> mid-load looked identical to a genuinely empty result set, so opening the\n    // combo right after the modal appeared always read \"nenhum resultado\" instead of \"ainda\n    // carregando\". dl.dataset.loading (set by loadMoveOptions) is what tells the two apart.\n    panel.innerHTML=opts.length?'':(dl.dataset.loading==='1'?'<div class=\"macsel-empty\">carregando…</div>':'<div class=\"macsel-empty\">nenhum resultado</div>');\n    opts.forEach(o=>{\n      const row=document.createElement('div'); row.className='macsel-opt'+(o.value===inp.value?' sel':'');\n      row.textContent=o.textContent;\n      // dispatchEvent runs synchronously — firing it BEFORE hiding meant our own 'input' listener\n      // (openFiltered) reopened the panel on the same tick, right after this handler had just\n      // closed it: picking a row looked like it never closed. Hiding last, after the dispatch has\n      // already run and reopened it, is what actually wins.\n      row.onmousedown=e=>{ e.preventDefault(); inp.value=o.value; inp.dispatchEvent(new Event('input')); panel.classList.add('hidden'); };\n      panel.appendChild(row);\n    });\n  };\n  const openFiltered=()=>{\n    // Defensive: nothing else in this file re-adds `list`, but this is the one control whose panel\n    // reopens on every keystroke — cheap insurance against the native popup ever winning a race\n    // against the attribute removal that runs once at init, above.\n    inp.removeAttribute('list');\n    buildPanel();\n    panel.classList.remove('hidden'); positionMacPanel(inp,panel);\n  };\n  inp.addEventListener('focus',openFiltered);\n  inp.addEventListener('input',openFiltered);\n  ensureMacGlobalClose();\n  // Lets an async option load (loadMoveOptions) push a refreshed panel once it lands, WITHOUT\n  // force-opening a panel the user never touched — only rebuilds when the panel is already visible.\n  inp._macRefresh=()=>{ if(!panel.classList.contains('hidden')) buildPanel(); };\n}\nconst blankM=()=>({cbs:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0,ativas:0});\n// ---------- município identity ----------\n// Brazil has many municipalities sharing a name (Bom Jesus alone exists in PI, RN, RS, SC, PB, GO).\n// A stage loads the dominant UF PLUS every bordering UF, so those collisions land in the SAME\n// feature set routinely: keyed by name alone the last shape loaded won, the map could draw a\n// município from the wrong state, buildAdj merged two unrelated municipalities into one node, and\n// GROUP BY DS_MUNICIPIO summed two real municipalities into one row. The key is NAME/UF everywhere\n// — SQL rows, GeoJSON features, the dominantes base, assignments, persisted sessions. norm() maps\n// every non-alphanumeric to a space, so '/' can never occur inside a name and the split is exact.\n// A missing UF degrades to a bare NAME key rather than inventing one.\n// A handful of municípios carry two spellings in the wild — an older one still used internally (CRM\n// export, this dominantes CSV) and the current official IBGE spelling used by the geodata mirror.\n// Left alone, the two spellings key to different places: the shape draws with no dominance entry\n// (\"setor não encontrado\") even though the base has it under the other spelling. Canonicalized here,\n// once, to whichever spelling the CRM base uses — so display stays consistent with every other\n// município name in this app, which comes from the CRM, never from IBGE.\n// Aliases base (embutidos, sempre valem mesmo se a tabela abaixo estiver vazia ou fora do ar).\n// A tabela Data2 (MUNI_ALIAS_DS) deixa o time acrescentar/corrigir grafias sem precisar mexer\n// neste componente — carregada uma vez no boot (loadMuniAliases) e mesclada por cima destes.\nlet MUNI_ALIAS={BRASOPOLIS:'BRAZOPOLIS','DONA EUSEBIA':'DONA EUZEBIA'};\nconst MUNI_ALIAS_DS='gb9Mu-5_9AcCDK-myWwSyDataSet';\n// This table's whole point is letting someone WITHOUT code access fix a município name mismatch —\n// a failure that only ever shows up in devtools defeats that. It has in fact sat silently broken\n// before (a policy with no linked auth strategy, fixed separately) with zero visible signal anywhere.\n// Kept module-scope so drawMap's mapIgnoredNote (below) can render it next to the other \"why is this\n// município missing/wrong\" notes, not just log it.\nlet MUNI_ALIAS_ERROR=null;\nasync function loadMuniAliases(){\n  // A freshly policy-gated DataSet's backend definition can take a moment to finish registering\n  // after the policy lands — d2.data() returns null until it does (\"Definition not found:\n  // UserApplicationLayer <ref>\" in the console — the SAME warning fires once for the Databricks\n  // rush listener too, which plainly works, because THAT one gets called repeatedly over a session\n  // and gets more than one shot at winning the race). This function is a single boot-time call with\n  // no natural second attempt, so it needs its own bounded retry to get the same fair shot.\n  let ds=null;\n  for(let attempt=0; attempt<5 && !ds; attempt++){\n    if(attempt) await new Promise(r=>setTimeout(r,1200));\n    ds=(d2.data('muniAliasDataset')||d2.data(MUNI_ALIAS_DS));\n  }\n  if(!ds){ MUNI_ALIAS_ERROR='dataset de correções não resolvido após 5 tentativas (muniAliasDataset/'+MUNI_ALIAS_DS+' indisponível nesta instância)'; console.warn('[muniAlias] '+MUNI_ALIAS_ERROR); return; }\n  try{\n    await ds.refresh();\n    const recs=await ds.loadAll();\n    recs.forEach(r=>{ const de=norm(r.de), pa=norm(r.para); if(de&&pa) MUNI_ALIAS[de]=pa; });\n    MUNI_ALIAS_ERROR=null;\n  }catch(e){\n    // tabela de aliases é um complemento opcional — falha aqui não trava o boot, mas também não pode\n    // desaparecer sem rastro: guardada pra aparecer no mapa em vez de só no console, que ninguém\n    // sem devtools abertos jamais vai olhar.\n    MUNI_ALIAS_ERROR=(e&&e.message)||'falha desconhecida ao carregar';\n    console.warn('[muniAlias] falha ao carregar tabela de correções de grafia:',e);\n  }\n}\nconst muniN=s=>{const n=norm(s);return MUNI_ALIAS[n]||n;};\nconst mkey=(name,uf)=>{const n=muniN(name),u=String(uf==null?'':uf).trim().toUpperCase();return u?n+'/'+u:n;};\nconst mName=k=>{const s=String(k==null?'':k),i=s.lastIndexOf('/');return i<0?s:s.slice(0,i);};\nconst mUf=k=>{const s=String(k==null?'':k),i=s.lastIndexOf('/');return i<0?'':s.slice(i+1);};\nconst mDisp=k=>{const u=mUf(k);return u?mName(k)+' ('+u+')':mName(k);};\n// The base CSV is the ONLY source of a município's UF now, so its column is accepted in whatever\n// shape the base is maintained in: sigla, IBGE numeric code, or full state name. Anything that does\n// not resolve to a real sigla is treated as UNKNOWN rather than passed through — a bogus UF would key\n// a município to a shape that does not exist and drop it off the map without a word.\nconst UF_OF_CODE={}; Object.keys(UF_CODE).forEach(k=>{ UF_OF_CODE[UF_CODE[k]]=k; });\nconst UF_OF_NAME={ACRE:'AC',ALAGOAS:'AL',AMAPA:'AP',AMAZONAS:'AM',BAHIA:'BA',CEARA:'CE','DISTRITO FEDERAL':'DF','ESPIRITO SANTO':'ES',GOIAS:'GO',MARANHAO:'MA','MATO GROSSO':'MT','MATO GROSSO DO SUL':'MS','MINAS GERAIS':'MG',PARA:'PA',PARAIBA:'PB',PARANA:'PR',PERNAMBUCO:'PE',PIAUI:'PI','RIO DE JANEIRO':'RJ','RIO GRANDE DO NORTE':'RN','RIO GRANDE DO SUL':'RS',RONDONIA:'RO',RORAIMA:'RR','SANTA CATARINA':'SC','SAO PAULO':'SP',SERGIPE:'SE',TOCANTINS:'TO'};\nconst ufNorm=v=>{const s=norm(v); if(!s) return '';\n  if(UF_CODE[s]) return s;\n  if(UF_OF_CODE[s]) return UF_OF_CODE[s];\n  return UF_OF_NAME[s]||'';};\nconst loader=(on,t)=>{const l=loaderPortal();l.classList.toggle('hidden',!on);if(t)l.textContent=t;const sr=$('.simroot');if(sr)sr.style.overflow=on?'hidden':'';};\nconst setError=(msg)=>{ const t=$('#ds'); if(t){t.textContent=msg;t.classList.add('err');} const c=$('#confirmStatus'); if(c){c.textContent=msg;c.classList.add('err');} };\n\n// ---------- base de municípios dominantes (CSV) ----------\n// WHICH municipalities belong to a sector is a business fact, not something to infer from CB volume:\n// it comes from a maintained CSV (one row per setor x município) linked through the `dominantesCsvUrl`\n// param. Anything the closing sector has CBs in but that the base does NOT assign to it is spillover:\n// not realocated, not drawn on the map, and its CBs follow their leader. Loaded once per URL and\n// cached for the whole session; a failure is thrown, never swallowed — a silent fallback here would\n// quietly change every number on the screen.\nconst DOM={url:null,promise:null,bySetor:null,ufBySetorMuni:null,rows:0,setores:0,hasUf:false,ufCol:null,badUf:0,clashes:0,headers:null,error:null};\nfunction parseCsv(text){\n  const out=[]; let row=[],cur='',q=false;\n  const push=()=>{ row.push(cur); cur=''; };\n  const endRow=()=>{ push(); if(row.length>1||row[0]!=='') out.push(row); row=[]; };\n  for(let i=0;i<text.length;i++){ const ch=text[i];\n    if(q){ if(ch==='\"'){ if(text[i+1]==='\"'){ cur+='\"'; i++; } else q=false; } else cur+=ch; }\n    else if(ch==='\"') q=true;\n    else if(ch===',') push();\n    else if(ch==='\\n') endRow();\n    else if(ch!=='\\r') cur+=ch;\n  }\n  if(cur!==''||row.length) endRow();\n  return out;\n}\n// `dominantesCsvUrl` is declared as a FileComponentUrlReference, so the instance points at the Drive\n// FILE, not at a URL literal — the Drive is policy-gated behind the app's auth strategy and a baked\n// string would be both stale and unauthorised. What the resolver hands back varies by where it is\n// resolved (URL string / file record / bare reference), so accept every shape instead of silently\n// reading undefined and falling through to the fallback.\n// The Drive is gated by the app's AuthenticationStrategy, so this read has to travel as the LOGGED-IN\n// USER — an anonymous CDN GET is exactly what the policy is there to refuse. `<system>.data2.media` is\n// only a cache in front of the SSP: on a cache miss it 302s to `<system>.data2apis.com`, which is where\n// the policy is actually evaluated.\n// COOKIES CANNOT CARRY THAT SESSION, which is why the previous `credentials:'include'` attempt could\n// never have worked: the app runs on <app>.<system>.data2.app and the SSP on <system>.data2apis.com —\n// cross-site — and the SSP answers `Access-Control-Allow-Origin: *` with no `Allow-Credentials`, so the\n// browser rejects a credentialed CORS read before it ever leaves the page. What the SSP actually reads\n// is `Authorization: Bearer <token>`: the runtime keeps that token in localStorage under\n// `__data2-session-<systemDomain>` (`__data2-session-data2` inside the builder preview) and replays it\n// on every dataset and rush call — same token, same header, same session, so the Drive policy sees the\n// logged-in user. Preflight passes because the SSP echoes whatever header the request asks for.\nfunction d2SessionToken(sysDomain){\n  const read=k=>{ try{ return (k&&window.localStorage.getItem(k))||''; }catch(e){ return ''; } };\n  return read(sysDomain?('__data2-session-'+sysDomain):'')||read('__data2-session-data2')||'';\n}\nfunction domCsvSystemDomain(url){\n  const m=/^https?:\\/\\/([^.\\/]+)\\.data2\\.(?:media|app)\\//i.exec(url)||/^https?:\\/\\/([^.\\/]+)\\.data2apis\\.com\\//i.exec(url);\n  return m?m[1]:'';\n}\nfunction domCsvCandidates(url){\n  const out=[];\n  const isMedia=/^https?:\\/\\/[^.\\/]+\\.data2\\.media\\//i.test(url);\n  const api=isMedia?url.replace(/^(https?:\\/\\/[^.\\/]+)\\.data2\\.media\\//i,'$1.data2apis.com/'):null;\n  const tok=d2SessionToken(domCsvSystemDomain(url));\n  const auth=tok?{authorization:'Bearer '+tok}:null;\n  // Authenticated origin read first — the only candidate a GATED Drive can actually answer.\n  if(api&&auth) out.push({url:api,headers:auth,label:'api+sessão'});\n  if(!api&&auth) out.push({url:url,headers:auth,label:'origem+sessão'});\n  // Anonymous last: only ever succeeds on an UNGATED Drive — or on a stale CDN cache hit, which is NOT\n  // proof the file is public (the origin keeps answering 401 once the edge entry expires).\n  if(api) out.push({url:api,headers:null,label:'api anônimo'});\n  out.push({url:url,headers:null,label:'origem anônima'});\n  return out;\n}\nfunction domCsvUrl(){\n  const pick=v=>{\n    if(!v) return '';\n    if(typeof v==='string') return v;\n    const q=v.params||{};\n    const ref=q.reference||v.reference;\n    return q.fileUrl||q.url||v.fileUrl||v.url||(ref?DOM_MEDIA_BASE+ref:'');\n  };\n  return String(pick(d2.param('dominantesCsvUrl'))||DOM_CSV_FALLBACK||'').trim();\n}\nasync function ensureDominantes(){\n  const url=domCsvUrl();\n  if(!url) throw new Error('base de municípios dominantes não configurada — ligue o arquivo CSV no parâmetro dominantesCsvUrl da instância do componente');\n  if(DOM.url===url && DOM.bySetor) return DOM.bySetor;\n  if(DOM.url===url && DOM.promise) return DOM.promise;\n  DOM.url=url; DOM.error=null;\n  DOM.promise=(async()=>{\n    // Self-diagnosing on purpose: every candidate reports its own outcome and the message names the\n    // session it was carrying. \"Unauthorized\" alone cannot distinguish \"not logged into THIS strategy\"\n    // from \"cookie not sent cross-site\" from \"param never bound, so it tried the fallback URL\" — and\n    // guessing between those from the outside costs a round trip each time.\n    let r=null; const tried=[];\n    for(const c of domCsvCandidates(url)){\n      let res=null;\n      try{ res=await fetch(c.url,{cache:'no-store',credentials:'omit',headers:c.headers||undefined}); }\n      catch(e){ tried.push(c.label+' → rede/CORS'); continue; }\n      if(res.ok){ r=res; break; }\n      tried.push(c.label+' → '+res.status);\n    }\n    if(!r){\n      let sess='sessão desconhecida';\n      try{\n        const a=d2.auth(AUTH_REF);\n        sess=(a&&a.isAuthed)?('logado como '+((a.user&&(a.user.email||a.user.nome))||'?')):('NÃO logado na estratégia '+AUTH_REF);\n      }catch(e){ sess='estratégia '+AUTH_REF+' não resolvida'; }\n      const tk=d2SessionToken(domCsvSystemDomain(url));\n      throw new Error('base de municípios dominantes: não foi possível ler o arquivo — '+sess+' · token de sessão '+(tk?'presente':'AUSENTE no localStorage')+'. Tentativas: '+tried.join(' · '));\n    }\n    const table=parseCsv(await r.text());\n    if(table.length<2) throw new Error('base de municípios dominantes vazia');\n    const head=table[0].map(h=>norm(h));\n    const iS=head.indexOf('CD SETOR'), iM=head.indexOf('MUNICIPIO');\n    if(iS<0||iM<0) throw new Error('base de municípios dominantes sem as colunas CD_SETOR / MUNICIPIO');\n    // CD_GV is the gerência de vendas the setor answers to, and it is the authority for the hard rule\n    // that a closing sector only ever hands territory to sectors of its OWN gerência. Its absence is\n    // fatal rather than a warning: with no GV there is no way to PROVE two sectors share one, and\n    // falling through unchecked is exactly the leak this column exists to close.\n    const iG=['CD GV','CDGV','GV','CD GERENCIA','CD GERENCIA VENDAS','CD GERENCIA DE VENDAS','GERENCIA DE VENDAS','GERENCIA'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    if(iG==null) throw new Error('base de municípios dominantes sem a coluna CD_GV — a regra de gerência de vendas não pode ser aplicada. Colunas lidas: '+head.slice(0,40).join(', '));\n    // MERCADO fica ACIMA de GV na hierarquia (mercado > GV > setor) — fechamento de Mercado e a regra\n    // \"GV só fecha pra GV do mesmo mercado\" dependem dela do mesmo jeito que a regra de GV depende\n    // de CD_GV, então a ausência é fatal pelo mesmo motivo.\n    const iK=['MERCADO','MERCADO REGIONAL','MKT','MARKET'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    if(iK==null) throw new Error('base de municípios dominantes sem a coluna MERCADO — fechamento de GV/Mercado precisa dela. Colunas lidas: '+head.slice(0,40).join(', '));\n    // Nomes de exibição — opcionais: a hierarquia funciona só com os códigos, mas o código sozinho\n    // não diz nada pro operador escolhendo o que fechar.\n    const iDS=['DC SETOR','NOME SETOR','DESCRICAO SETOR','NM SETOR'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    const iDG=['DC GV','NOME GV','DESCRICAO GV','NM GV','GV NOME'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    // The UF column is what the whole keying now rests on — SQL no longer carries DS_ESTADO — so its\n    // absence is a first-class finding, not a footnote. The header actually matched is reported on the\n    // base pill, and when nothing matches the pill lists every column read, because guessing a header\n    // name from the outside costs a round trip each time.\n    const iU=['UF','SIGLA UF','SG UF','CD UF','ESTADO','DS ESTADO','SIGLA','ESTADO SIGLA','UF MUNICIPIO'].map(h=>head.indexOf(h)).find(i=>i>=0);\n    const by={},ufBy={},gvBy={},mkBySetor={},setorLabel={},gvLabel={}; let n=0,bad=0; const clash={},gvClash={},mkClash={};\n    for(let i=1;i<table.length;i++){\n      const line=table[i]; if(!line) continue;\n      const s=String(line[iS]==null?'':line[iS]).trim(), m=muniN(line[iM]);\n      if(!s||!m) continue;\n      // One setor spans many rows (one per município) and every one of them repeats its CD_GV. Two\n      // DIFFERENT values for the same setor means the base itself disagrees about which gerência owns\n      // it — counted and reported, never keyed to whichever row happened to be read last.\n      const gvv=String(line[iG]==null?'':line[iG]).trim();\n      if(gvv){ if(gvBy[s]!==undefined && gvBy[s]!==gvv) gvClash[s]=true; else gvBy[s]=gvv; }\n      const mkv=String(line[iK]==null?'':line[iK]).trim();\n      if(mkv){ if(mkBySetor[s]!==undefined && mkBySetor[s]!==mkv) mkClash[s]=true; else mkBySetor[s]=mkv; }\n      if(iDS!=null){ const lbl=String(line[iDS]==null?'':line[iDS]).trim(); if(lbl && !setorLabel[s]) setorLabel[s]=lbl; }\n      if(iDG!=null && gvv){ const lbl=String(line[iDG]==null?'':line[iDG]).trim(); if(lbl && !gvLabel[gvv]) gvLabel[gvv]=lbl; }\n      const raw=(iU==null)?'':line[iU];\n      const u=(iU==null)?'':ufNorm(raw);\n      if(iU!=null && String(raw==null?'':raw).trim() && !u) bad++;\n      // The same name twice in ONE setor under two UFs is the single case the reverted SQL cannot\n      // survive: mSQL groups by DS_MUNICIPIO alone, so both municipalities arrive summed into one row\n      // and no lookup can split them again. Counted and reported — never quietly keyed to whichever\n      // UF happened to be read last.\n      const kk=s+'|'+m;\n      // An UNRESOLVED uf must never outrank a resolved one. Reading a blank/bogus UF row first wrote ''\n      // here, and the next row carrying the real sigla was then rejected as a clash — so the key stayed\n      // falsy for good, ufFor() fell through, and the município degraded to a bare NAME key that matches\n      // no shape and no dominance entry. With DISTRITO/ZONA in the base a município spans several rows,\n      // so which row lands first is pure luck. A clash is only two RESOLVED siglas that disagree.\n      if(!u){ if(ufBy[kk]===undefined) ufBy[kk]=''; }\n      else if(!ufBy[kk]) ufBy[kk]=u;\n      else if(ufBy[kk]!==u) clash[kk]=true;\n      (by[s]=by[s]||new Set()).add(u?m+'/'+u:m); n++;\n    }\n    if(!n) throw new Error('base de municípios dominantes sem linhas válidas');\n    DOM.bySetor=by; DOM.ufBySetorMuni=ufBy; DOM.rows=n; DOM.setores=Object.keys(by).length;\n    // Reverse município→setor index (static, built once from the same CSV) — lets the GV-closing\n    // map gray out a geographically-touching GV with no live query, matching the \"GV/mercado\n    // hierarchy is organizational, read from the CSV only\" rule the rest of this level already follows.\n    { const revBy={},revAll={}; Object.entries(by).forEach(([s,ms])=>ms.forEach(m=>{ if(!revBy[m]) revBy[m]=s; (revAll[m]=revAll[m]||new Set()).add(s); })); DOM.setorByMuni=revBy; DOM.setoresByMuni=revAll; }\n    DOM.gvBySetor=gvBy; DOM.gvClashes=Object.keys(gvClash).length; DOM.gvCol=String(table[0][iG]||'').trim();\n    DOM.gvSetores=Object.keys(gvBy).length;\n    DOM.hasUf=(iU!=null); DOM.ufCol=(iU==null?null:String(table[0][iU]||'').trim());\n    DOM.badUf=bad; DOM.clashes=Object.keys(clash).length; DOM.headers=head.slice(0,40); DOM.error=null;\n    // ---- hierarquia: setor ⊂ GV ⊂ mercado, tudo derivado do MESMO csv que a regra de setor já\n    // confia — fechamento de GV/Mercado lê a mesma fonte, nunca uma segunda base paralela.\n    DOM.mercadoBySetor=mkBySetor; DOM.mercadoClashes=Object.keys(mkClash).length; DOM.mercadoCol=String(table[0][iK]||'').trim();\n    DOM.setorLabel=setorLabel; DOM.gvLabel=gvLabel;\n    const sectorsByGv={},sectorsByMercado={},gvsByMercado={},mercadoByGv={},gvMercClash={};\n    Object.keys(by).forEach(s=>{\n      const gv=gvBy[s], mk=mkBySetor[s];\n      if(gv) (sectorsByGv[gv]=sectorsByGv[gv]||new Set()).add(s);\n      if(mk) (sectorsByMercado[mk]=sectorsByMercado[mk]||new Set()).add(s);\n      if(gv&&mk){ if(mercadoByGv[gv]!==undefined && mercadoByGv[gv]!==mk) gvMercClash[gv]=true; else mercadoByGv[gv]=mk; }\n    });\n    Object.entries(mercadoByGv).forEach(([gv,mk])=>(gvsByMercado[mk]=gvsByMercado[mk]||new Set()).add(gv));\n    DOM.sectorsByGv=sectorsByGv; DOM.sectorsByMercado=sectorsByMercado; DOM.gvsByMercado=gvsByMercado;\n    DOM.mercadoByGv=mercadoByGv; DOM.gvMercadoClashes=Object.keys(gvMercClash).length;\n    return by;\n  })();\n  DOM.promise.catch(e=>{ DOM.promise=null; DOM.bySetor=null; DOM.error=e.message; renderAll(); });\n  return DOM.promise;\n}\n\nlet SESS={ snapshotRaw:null, snapshotSyncedAt:null, snapshotPinnedDate:null, snapLiteral:null, eloMaxCycle:null, timeline:[], selected:0,\n           sessionId:null, sessionLabel:null, sessionsList:[], sectorsList:[], sectorBaseCache:{}, stage:'loading', dirtyFrom:null, locked:false,\n           snapBusy:false, settings:{...DEFAULT_SETTINGS} };\n\nasync function q(sql){\n  let dbx,res;\n  try{\n    res=await d2.rush('AwW_11Nkg-dKksP7ic8V8').invoke({sql});\n  }catch(e){\n    throw new Error('Databricks indisponível (rush AwW_11Nkg-dKksP7ic8V8): '+e.message);\n  }\n  // The listener answers 200 with {sql} and NO `result` when its upstream probe times out: the\n  // handler builds `result: $result.data`, so an empty upstream simply drops the key rather than\n  // failing. Reading straight through to res.data.result.data reported \"Cannot read properties of\n  // undefined (reading 'data')\" — which reads like a bug in THIS component and sent a debugging\n  // session after the wrong thing. Name the layer that actually failed instead.\n  const payload=res&&res.data;\n  if(!payload) throw new Error('a sonda Databricks não devolveu corpo — listener fora do ar?');\n  if(!payload.result) throw new Error('a sonda Databricks não respondeu: a requisição devolveu 200 sem `result`, o que normalmente é timeout do upstream (natura-x-app). O SQL não chegou a rodar.');\n  dbx=payload.result.data;\n  if(!dbx) throw new Error('sonda Databricks respondeu sem dados (result.data vazio).');\n  if(dbx.state && dbx.state!=='SUCCEEDED') throw new Error('Databricks '+dbx.state+': '+(dbx.error?.message||JSON.stringify(dbx.error)||'erro desconhecido'));\n  return dbx;\n}\nfunction rows(resp){const c=(resp.columns||[]).map(x=>x.name);\n  return (resp.rows||[]).map(a=>Object.fromEntries(c.map((k,i)=>[k,a[i]])));}\n// Stamps the UF onto every feature at load: the fetch is per-UF, so this is the ONLY point where a\n// shape's state is known for certain. Downstream the features are flattened into one array and the\n// origin would be unrecoverable. Persisted with the feature in saved sessions.\nasync function geoUF(uf){const code=UF_CODE[uf]||uf;const r=await fetch('https://raw.githubusercontent.com/tbrugz/geodata-br/master/geojson/geojs-'+code+'-mun.json');if(!r.ok)throw new Error('geo '+uf);\n  const g=await r.json(); const U=String(uf).toUpperCase();\n  (g.features||[]).forEach(f=>{ f.__uf=U; });\n  return g;}\n\nfunction buildAdj(features){\n  // Encaixe exato de vértice (grid ~111m) resolve vizinhança DENTRO de uma UF, porque todos os\n  // municípios de uma UF saem do MESMO arquivo geoJSON, com a mesma malha de pontos. Atravessando\n  // fronteira de UF, os dois lados vêm de arquivos buscados/digitalizados SEPARADAMENTE — a mesma\n  // linha real de fronteira quase nunca cai nos mesmos vértices nos dois arquivos, e alargar o grid\n  // pra compensar (tentativa anterior) só troca \"não encontra vizinho de UF diferente\" por \"pode\n  // grudar município que só está perto, dentro da própria UF\" — não é uma correção geométrica de\n  // verdade, só empurra o problema. A solução de verdade é uma segunda checagem, geométrica (menor\n  // distância real entre os dois contornos, em km), aplicada SÓ a pares de UF diferente que o\n  // encaixe exato não achou — dentro da mesma UF o encaixe exato continua sendo a única fonte,\n  // sem alteração de comportamento nem de peso de voto.\n  const pts=f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates],s=new Set();\n    ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>s.add(Math.round(p[0]*1000)/1000+','+Math.round(p[1]*1000)/1000))));return s;};\n  const rawPts=f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates],out=[];\n    ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>out.push(p))));return out;};\n  const feat={},idx={};\n  features.forEach(f=>{\n    const nm=mkey(f.properties.name||f.properties.NOME||f.properties.nome,f.__uf);\n    const raw=rawPts(f);\n    let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n    raw.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; });\n    feat[nm]={f,pts:pts(f),raw,bbox:{mnx,mny,mxx,mxy},uf:f.__uf};\n  });\n  for(const nm in feat) feat[nm].pts.forEach(p=>(idx[p]=idx[p]||[]).push(nm));\n  // Limiar de \"encosta de verdade\" cruzando UF: cobre a folga típica entre dois arquivos\n  // digitalizados separadamente pra MESMA linha real de fronteira, sem juntar município que só\n  // está na vizinhança geral mas não faz fronteira de fato.\n  const TOUCH_KM=1.2;\n  const nbrs=nm=>{\n    const A=feat[nm]; if(!A) return [];\n    const c={};\n    A.pts.forEach(p=>idx[p].forEach(o=>{if(o!==nm)c[o]=(c[o]||0)+1;}));\n    const exact=Object.entries(c).filter(([,n])=>n>=2).map(([o,n])=>({o,w:n}));\n    const seen=new Set(exact.map(e=>e.o));\n    const latK=Math.max(0.2,Math.cos(((A.bbox.mny+A.bbox.mxy)/2)*Math.PI/180));\n    const padLon=TOUCH_KM/111/latK, padLat=TOUCH_KM/111;\n    const extra=[];\n    for(const o in feat){\n      if(o===nm||seen.has(o)) continue;\n      const B=feat[o];\n      if(B.uf===A.uf) continue; // mesma UF: se o encaixe exato não achou, não está encostando\n      if(B.bbox.mxx<A.bbox.mnx-padLon||B.bbox.mnx>A.bbox.mxx+padLon||B.bbox.mxy<A.bbox.mny-padLat||B.bbox.mny>A.bbox.mxy+padLat) continue;\n      let best=Infinity;\n      outer: for(const p of A.raw){\n        for(const q of B.raw){\n          const d=Math.hypot((p[0]-q[0])*latK*111,(p[1]-q[1])*111);\n          if(d<best){ best=d; if(best<0.05) break outer; }\n        }\n      }\n      if(best<=TOUCH_KM) extra.push({o,w:1});\n    }\n    return exact.concat(extra);\n  };\n  return {feat,nbrs};\n}\nfunction centroidLL(f){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  ps.forEach(poly=>poly[0].forEach(p=>{x+=p[0];y+=p[1];n++;}));return[x/n,y/n];}\n\n// ---------- snapshot pinning ----------\nasync function resolveSnapshotPin(range){\n  // range: {from,to} (both optional YYYY-MM-DD strings, from the snapshot sheet's período search),\n  // or omitted entirely for the unconditional \"latest available\" (live) pin. This used to filter\n  // with CAST(DT_HISTORICO AS DATE)<=DATE'...' directly in SQL — and matched ZERO rows for every\n  // date tested. DT_HISTORICO's real underlying type is uncertain enough that even an EXACT\n  // equality match against it (the candidates/tests loop below) already has to empirically try\n  // three different literal forms rather than trust one; a CAST hit that same uncertainty, and\n  // Spark returns NULL — not an error — from a CAST it can't make sense of, so NULL<=anything\n  // silently matched nothing, for every date, with no exception to reveal why. Fixed by never\n  // comparing dates in SQL at all: SQL's own JSON serialization always normalizes a DATE/TIMESTAMP\n  // column's OUTPUT to an ISO-shaped string regardless of its real underlying type (exactly how\n  // `raw` already round-trips safely in the unconditional/live case below), so GROUP BY hands back\n  // every distinct snapshot with its row count, and the target is picked by comparing that string's\n  // first 10 characters (YYYY-MM-DD) in JS — no CAST, no SQL-side range comparison, no ambiguity.\n  const from=range&&range.from, to=range&&range.to;\n  let raw, liveCount, diag=null;\n  if(from||to){\n    // Direct SQL-side range filter, mirroring the SAME self-referential-subquery pattern the\n    // live/unconditional branch below already relies on for a safe exact-match count (comparing\n    // DT_HISTORICO against a SUBQUERY's own re-derived MAX, never against an externally re-injected\n    // literal for the COUNT step) — only the boundary comparison itself needs a literal, so that's\n    // the one spot the three candidate forms tried for equality elsewhere in this function get\n    // tried again. An earlier version used COUNT(*) over the WHOLE RANGE just to check \"did this\n    // literal flavor match anything\" — wrong, because that range-wide count then got compared\n    // downstream against a SINGLE exact date's row count, which a multi-week/month window will\n    // essentially never equal. Half-open interval on the upper bound (< first instant after `to`,\n    // not <= 'to 23:59:59.999') so a DT_HISTORICO with finer-than-millisecond precision can't\n    // silently land just outside an inclusive boundary.\n    const fromB=from?from+' 00:00:00.000':null;\n    const toExclusive=to?(()=>{ const d=new Date(to+'T00:00:00Z'); d.setUTCDate(d.getUTCDate()+1); return d.toISOString().slice(0,10)+' 00:00:00.000'; })():null;\n    const flavors=[v=>`TIMESTAMP'${v}'`, v=>`'${v}'`, v=>`DATE'${v.slice(0,10)}'`];\n    for(const mk of flavors){\n      const clauses=[]; if(fromB) clauses.push(`DT_HISTORICO>=${mk(fromB)}`); if(toExclusive) clauses.push(`DT_HISTORICO<${mk(toExclusive)}`);\n      const whereRange=' AND '+clauses.join(' AND ');\n      const maxR=await q(`SELECT MAX(DT_HISTORICO) snap FROM ${T} WHERE CD_PAIS=1${whereRange}`).then(rows).then(rs=>rs[0]).catch(()=>null);\n      const candRaw=maxR&&maxR.snap;\n      if(!candRaw) continue;\n      const cntR=await q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1${whereRange})`).then(rows).then(rs=>rs[0]).catch(()=>null);\n      const n=cntR?+cntR.n:0;\n      if(n>0){ raw=candRaw; liveCount=n; break; }\n    }\n    if(!raw){\n      // A clean, single-row aggregate (MIN/MAX/COUNT DISTINCT) to get the TRUE bounds of what's\n      // actually in the table, so a genuinely-empty period is distinguishable from a filter that\n      // silently matched nothing.\n      const b=await q(`SELECT MIN(DT_HISTORICO) mn, MAX(DT_HISTORICO) mx, COUNT(DISTINCT DT_HISTORICO) nd FROM ${T} WHERE CD_PAIS=1`).then(rows).then(rs=>rs[0]).catch(()=>null);\n      diag = b ? `base cobre de ${String(b.mn||'?').slice(0,10)} a ${String(b.mx||'?').slice(0,10)} (${b.nd||'?'} data(s) distinta(s))` : 'não foi possível checar o alcance da base';\n    }\n  } else {\n    const [rawR,liveCountR]=await Promise.all([\n      q(`SELECT COALESCE((SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO<(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)),(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)) snap`).then(rows),\n      q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=COALESCE((SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO<(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)),(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1))`).then(rows),\n    ]);\n    raw=rawR[0]&&rawR[0].snap; liveCount=+((liveCountR[0]||{}).n||0);\n  }\n  if(!raw) return {snapshotRaw:null, snapLiteral:null, syncedAt:new Date().toISOString(), pinnedDate:null, diag};\n  const LIVE_SUBQ=`(SELECT COALESCE((SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO<(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)),(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)))`;\n  const dbTs=(raw||'').replace('T',' ').replace(/Z$/,'');\n  const candidates=[`TIMESTAMP'${dbTs}'`,`'${dbTs}'`,`DATE'${dbTs}'`];\n  const tests=await Promise.all(candidates.map(c=>\n    q(`SELECT COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO=${c}`).then(rows).then(r=>+((r[0]||{}).n||0)).catch(()=>-1)\n  ));\n  // LIVE_SUBQ (unconditionally re-deriving MAX) is only a safe fallback for the live/unconditional\n  // case — for a historical pin it would just silently re-resolve to today's snapshot, discarding\n  // the picked range, so the fallback there is the best-guess candidate literal instead.\n  let chosen=(from||to)?candidates[0]:LIVE_SUBQ;\n  for(let i=0;i<candidates.length;i++){ if(liveCount>0 && tests[i]===liveCount){ chosen=candidates[i]; break; } }\n  return {snapshotRaw:raw, snapLiteral:chosen, syncedAt:new Date().toISOString(), pinnedDate:(from||to)?String(raw).slice(0,10):null};\n}\n// DISPLAY CONTRACT: the UI never prints the snapshot's own DT_HISTORICO (snapshotRaw). What the user\n// sees is `snapshotSyncedAt` — the date AND hour at which THIS instance last pulled/pinned the\n// snapshot it is querying. snapshotRaw is still resolved, still drives every SQL literal, and is\n// still persisted by saveSession/doShare; it is only kept off the screen.\nfunction fmtSyncedAt(iso){\n  if(!iso) return '—';\n  const d=new Date(iso);\n  if(isNaN(d.getTime())) return String(iso);\n  return d.toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});\n}\nasync function ensurePinned(){\n  if(SESS.snapLiteral) return;\n  const p=await resolveSnapshotPin();\n  SESS.snapshotRaw=p.snapshotRaw; SESS.snapLiteral=p.snapLiteral;\n  SESS.snapshotSyncedAt=p.syncedAt; SESS.snapshotPinnedDate=p.pinnedDate;\n}\n// ---------- operational cycle (ELO) ----------\n// Deliberately NOT auto-resolved alongside the DT_HISTORICO snapshot above anymore: the operator\n// picks it explicitly on the confirm screen — either this same auto-detect query (same one that\n// always ran here before) via the \"usar o mais recente\" button, or a specific cycle typed by hand.\n// product.elo_modelo_combinado keys by an opaque operational_cycle number this component has no\n// established mapping from a calendar date to, so leaving the choice explicit avoids silently\n// mis-scoping the \"últimos N ciclos\" trend chart against a cycle the operator didn't ask for.\nasync function queryEloCycle(){\n  // Fase 15 (pedido do usuário): MAX(operational_cycle)-1 dava o penúltimo ciclo que aparece na\n  // tabela do ELO — mas um ciclo pode já ter ALGUMAS linhas processadas sem estar FECHADO ainda\n  // (a maioria das CBs continua no ciclo anterior no CRM). A query nova acha o ciclo FECHADO de\n  // verdade: pega o ciclo que a MAIORIA das CBs ainda registra como atual no CRM (na mesma data\n  // de snapshot que resolveSnapshotPin usa — penúltimo DT_HISTORICO, com o mesmo COALESCE de\n  // fallback pro mais recente) e devolve o maior ciclo do ELO que seja ANTERIOR a esse — ou\n  // seja, o último que já fechou pra maioria, não o mais recente que só começou a preencher.\n  // Fase 17 (pedido do usuário): sem desempate, \"maioria\" podia trocar de ciclo entre duas\n  // execuções quando dois ciclos ficam empatados (ou quase) em contagem — um município cujas CBs\n  // ainda estão no ciclo anterior no ELO então saía com 0 CBs numa execução e correto na outra,\n  // sem nada mudar de verdade nos dados. Empate agora resolve sempre para o ciclo mais recente.\n  const eloR=await q(`WITH ciclos AS (SELECT DISTINCT operational_cycle c FROM ${ELO} WHERE country_code=1), maioria AS (SELECT CD_CICLO_ATUAL c FROM ${T} WHERE CD_PAIS=1 AND CD_SETOR>0 AND CD_GRUPO>0 AND DT_HISTORICO=COALESCE((SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO<(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)),(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)) GROUP BY CD_CICLO_ATUAL ORDER BY COUNT(*) DESC, CD_CICLO_ATUAL DESC LIMIT 1) SELECT MAX(c) AS last_closed_cycle FROM ciclos WHERE c<(SELECT c FROM maioria)`).then(rows).catch(()=>[]);\n  return +((eloR[0]||{}).last_closed_cycle||0)||null;\n}\n\n// ---------- world-state fold ----------\nfunction accMetrics(base,add){\n  const out=base?{...base}:blankM();\n  Object.keys(blankM()).forEach(k=>{ out[k]=(out[k]||0)+((add&&add[k])||0); });\n  return out;\n}\nfunction foldWorldState(timeline,uptoIdx){\n  const closedSectors=new Set(),muniOverride={},muniGeo={},muniRow={},muniLevel={},sectorInflow={},sectorOutflow={},sectorInflowLevel={},sectorOrphanRows={};\n  // GV/mercado: setores e GVs não se dissolvem quando o dono deles fecha — eles só passam a\n  // responder a um destino novo. Por isso o rastro do cascade mora numa camada PRÓPRIA\n  // (secOverride/gvOverride), separada da camada de município do fechamento de setor — a\n  // hierarquia trava a sessão num nível só, então nunca as duas camadas têm conteúdo ao mesmo tempo.\n  const closedGvs=new Set(),secOverride={},gvInflow={};\n  const closedMercados=new Set(),gvOverride={},mercadoInflow={};\n  for(let k=0;k<uptoIdx;k++){\n    const mv=timeline[k]; if(!mv) continue;\n    if(mv.kind==='transfer'){\n      muniOverride[mv.xferMuni]=mv.xferTo;\n      sectorInflow[mv.xferTo]=accMetrics(sectorInflow[mv.xferTo],mv.xferMetrics);\n      sectorOutflow[mv.xferFrom]=accMetrics(sectorOutflow[mv.xferFrom],mv.xferMetrics);\n      continue;\n    }\n    if(!mv.sector||!mv.munis) continue;\n    const level=mv.level||'setor';\n    if(level==='gv'){\n      closedGvs.add(String(mv.sector));\n      Object.keys(mv.munis).forEach(u=>{\n        if(u==='__OUTROS__') return;\n        const dest=mv.assign[u]; if(!dest) return;\n        secOverride[u]=String(dest);\n        gvInflow[dest]=accMetrics(gvInflow[dest],mv.munis[u]);\n      });\n      continue;\n    }\n    if(level==='mercado'){\n      closedMercados.add(String(mv.sector));\n      Object.keys(mv.munis).forEach(u=>{\n        if(u==='__OUTROS__') return;\n        const dest=mv.assign[u]; if(!dest) return;\n        gvOverride[u]=String(dest);\n        mercadoInflow[dest]=accMetrics(mercadoInflow[dest],mv.munis[u]);\n      });\n      continue;\n    }\n    closedSectors.add(String(mv.sector));\n    Object.keys(mv.munis).forEach(m=>{\n      if(m==='__OUTROS__') return;\n      const dest=mv.assign[m]; if(!dest) return;\n      muniOverride[m]=String(dest);\n      if(mv.geoFeat&&mv.geoFeat[m]) muniGeo[m]=mv.geoFeat[m];\n      const rws=(mv.matrix||[]).filter(r=>mkey(r.municipio,r.uf)===m);\n      muniRow[m]=(muniRow[m]||[]).concat(rws);\n      if(mv.levelByMuni&&mv.levelByMuni[m]) muniLevel[m]={...(muniLevel[m]||{}),...mv.levelByMuni[m]};\n      sectorInflow[dest]=accMetrics(sectorInflow[dest],mv.munis[m]);\n      const lv=(mv.levelByMuni&&mv.levelByMuni[m])||{};\n      sectorInflowLevel[dest]=sectorInflowLevel[dest]||{};\n      Object.entries(lv).forEach(([nv,c])=>{ sectorInflowLevel[dest][nv]=(sectorInflowLevel[dest][nv]||0)+c; });\n    });\n    // Orphan CBs follow their leader's home sector, so later etapas must inherit them the same way\n    // the allocated municipalities are inherited — otherwise a stage's span would not match the\n    // base carried into the next one.\n    if(mv.orphanMetricsByGroup && Object.keys(mv.orphanMetricsByGroup).length){\n      const d=computeDerived(mv);\n      d.leaders.forEach(L=>{\n        if(!L.home) return;\n        const om=mv.orphanMetricsByGroup[L.grupo];\n        if(om) sectorInflow[L.home]=accMetrics(sectorInflow[L.home],om);\n        // sectorInflow only reaches a sector while it is a DESTINATION (it lands in baseMetrics).\n        // Keep the rows as well, so the sector still carries these CBs on the day it is the one\n        // being closed — that read goes through mv.matrix, which baseMetrics never touches.\n        const orws=mv.orphanRowsByGroup&&mv.orphanRowsByGroup[L.grupo];\n        if(orws&&orws.length){\n          sectorOrphanRows[L.home]=(sectorOrphanRows[L.home]||[]).concat(orws);\n          // Orphan municípios never reach the muniOverride[m]=dest write above — aggregateMovement\n          // folds them into '__OUTROS__' in mv.munis, which that loop explicitly skips — so a later\n          // GV/mercado closure's resolveUnit() had no override to redirect them and kept resolving\n          // them back to THIS (now-closed) setor, via their still-live CD_SETOR. Recorded here too,\n          // keyed by the real município, so resolveUnit() redirects them the same as any other one.\n          orws.forEach(r=>{ const m=mkey(r.municipio,r.uf); if(!muniOverride[m]) muniOverride[m]=String(L.home); });\n        }\n        const ol=mv.orphanLevelByGroup&&mv.orphanLevelByGroup[L.grupo];\n        if(ol){ sectorInflowLevel[L.home]=sectorInflowLevel[L.home]||{};\n          Object.entries(ol).forEach(([nv,c])=>{ sectorInflowLevel[L.home][nv]=(sectorInflowLevel[L.home][nv]||0)+c; }); }\n      });\n    }\n  }\n  return {closedSectors,muniOverride,muniGeo,muniRow,muniLevel,sectorInflow,sectorOutflow,sectorInflowLevel,sectorOrphanRows,\n          closedGvs,secOverride,gvInflow,closedMercados,gvOverride,mercadoInflow};\n}\n\n// ---------- per-movement compute ----------\nfunction aggregateMovement(mv,world){\n  const level=mv.level||'setor';\n  const munis={},leaders={};\n  const exSet=new Set((mv.excludedMunis||[]).map(x=>x.muni));\n  const acc=(o,r)=>{o.cbs+=+r.cbs;o.receita+=+r.receita||0;o.rec_nat+=+r.rec_nat||0;o.rec_avon+=+r.rec_avon||0;o.pontos+=+r.pontos||0;o.pedidos+=+r.pedidos||0;o.ativas+=+r.ativas||0;};\n  // Orphans = CBs in municipalities outside the sector's dominance base. They are NOT allocated\n  // geographically; they follow their own leader's home sector. Kept per leader group so every\n  // consumer (span, cascade, impact report) can route them the same way. Só existe no nível setor —\n  // GV/mercado não têm spillover (todo setor pertence a exatamente uma GV/mercado na base).\n  mv.orphanByGroup={}; mv.orphanMetricsByGroup={}; mv.orphanRowsByGroup={};\n  // unidade que este nível redistribui: a própria município (setor fecha), o setor da linha (GV\n  // fecha), a GV do setor da linha (mercado fecha).\n  // Same redirect resolveUnit() already applies for geometry (mv.unitOfMuni, in computeMovement):\n  // a setor/GV that already closed EARLIER in this session no longer really owns this row — its\n  // live CD_SETOR just hasn't caught up (the CRM snapshot never learns about a simulated closure).\n  // Without this, the closed unit's own rows kept grouping under ITS OWN code here, in the MATRIX\n  // (mv.munis/mv.leaders — what the UI actually renders as a unit's row), reappearing as if it were\n  // still open, even though the map/resolveUnit already redirected it correctly.\n  const unitOf=r=>{\n    if(level==='setor') return null;\n    let sOf=String(r.setor);\n    const m=mkey(r.municipio,r.uf);\n    if(world&&world.closedSectors.has(sOf)){\n      // Closed AND unresolved (no override was ever recorded for this município — e.g. it had no\n      // shape yet during the setor's OWN closure, so it never entered mv.munis/mv.assign back then)\n      // must NOT fall through as sOf itself: that resurrects the closed setor as a live, assignable\n      // unit here. Empty instead, same as anything else aggregateMovement can't place — it falls to\n      // the '__OUTROS__' bucket below, already excluded from optimizeFor/autoAssignFor everywhere.\n      sOf=world.muniOverride[m]||'';\n    }\n    if(!sOf) return '';\n    if(level==='gv') return sOf;\n    return (world&&world.secOverride&&world.secOverride[sOf])||DOM.gvBySetor[sOf]||'';\n  };\n  mv.matrix.forEach(r=>{\n    const m=mkey(r.municipio,r.uf);\n    r._out=!mv.geoFeat[m];   // assign, never just set: absorbed rows are reused across etapas\n    const mm=level==='setor'?(r._out?'__OUTROS__':m):(unitOf(r)||'__OUTROS__');\n    munis[mm]=munis[mm]||{...blankM(),name:mm}; acc(munis[mm],r);\n    const g=String(r.grupo); leaders[g]=leaders[g]||{grupo:g,lider:r.lider,total:0,byMuni:{}};\n    leaders[g].total+=+r.cbs; leaders[g].byMuni[mm]=(leaders[g].byMuni[mm]||0)+ +r.cbs;\n    if(level==='setor' && r._out && exSet.has(m)){\n      mv.orphanByGroup[g]=(mv.orphanByGroup[g]||0)+ +r.cbs;\n      // The ROW too, not just the totals: when the leader's home sector is itself closed in a later\n      // etapa, that etapa rebuilds its matrix from SQL + absorbed municípios, and these CBs are in\n      // neither (their município never moved). Without the row they silently vanish from the sector\n      // they were just credited to — see sectorOrphanRows in foldWorldState.\n      (mv.orphanRowsByGroup[g]=mv.orphanRowsByGroup[g]||[]).push(r);\n      mv.orphanMetricsByGroup[g]=accMetrics(mv.orphanMetricsByGroup[g],\n        {cbs:+r.cbs,receita:+r.receita||0,rec_nat:+r.rec_nat||0,rec_avon:+r.rec_avon||0,pontos:+r.pontos||0,pedidos:+r.pedidos||0,ativas:+r.ativas||0});\n    }\n  });\n  // Level counts only exist per municipality, never per leader, so an excluded municipality's level\n  // mix is credited to the group holding most of its CBs (sub-threshold by definition, so usually one).\n  mv.orphanLevelByGroup={};\n  (mv.excludedMunis||[]).forEach(({muni})=>{\n    const lv=(mv.levelByMuni&&mv.levelByMuni[muni])||{}; if(!Object.keys(lv).length) return;\n    let bg=null,bc=-1;\n    mv.matrix.forEach(r=>{ if(mkey(r.municipio,r.uf)!==muni) return; const cc=+r.cbs; if(cc>bc){bc=cc;bg=String(r.grupo);} });\n    if(!bg) return;\n    mv.orphanLevelByGroup[bg]=mv.orphanLevelByGroup[bg]||{};\n    Object.entries(lv).forEach(([nv,cc])=>{ mv.orphanLevelByGroup[bg][nv]=(mv.orphanLevelByGroup[bg][nv]||0)+cc; });\n  });\n  mv.munis=munis;\n  // Todo município com geoFeat (inclusive os sem CB injetados acima, em withShape/closingNames) tem\n  // de aparecer em mv.munis — optimizeFor()/autoAssignFor() só enxergam Object.keys(mv.munis) pra\n  // decidir quem recebe destino. Sem isto, um município sem nenhuma linha em mv.matrix nunca entra\n  // em mv.assign, mesmo desenhado no mapa e votando por vizinhança — fica com destino colorido na\n  // legenda mas span/CBs realocadas/desequilíbrio tudo zerado, porque nada foi de fato atribuído.\n  if(level==='setor'){\n    Object.keys(mv.geoFeat||{}).forEach(n=>{ if(!munis[n]) munis[n]={...blankM(),name:n}; });\n  } else {\n    // Same guarantee, one level up: every UNIT (setor closing a GV, GV closing a mercado) that owns\n    // at least one drawn município has to appear in mv.munis even if every one of its municípios is\n    // zero-CB — mv.unitMunis already grouped them by unit; without a blank entry here that unit is\n    // invisible to optimizeFor()/autoAssignFor() and never gets a real mv.assign, exactly like the\n    // setor-level município case above.\n    Object.keys(mv.unitMunis||{}).forEach(u=>{ if(!munis[u]) munis[u]={...blankM(),name:u}; });\n  }\n  mv.leaders=Object.values(leaders).sort((a,b)=>b.total-a.total);\n}\nfunction autoAssignFor(mv){\n  const a={};\n  Object.keys(mv.munis).forEach(m=>{ if(m==='__OUTROS__')return;\n    const v=mv.allowed[m]||{}; const votes=Object.keys(v);\n    if(votes.length){ let best=votes[0],bw=-1; for(const s of votes) if(v[s]>bw){bw=v[s];best=s;} a[m]=best; }\n    else if(mv.dest.length){ const c=mv.muniCentroid[m]; let best=mv.dest[0],bd=Infinity;\n      for(const s of mv.dest){ const sc=mv.secCentroid[s]; if(!sc||!c)continue; const d=(c[0]-sc[0])**2+(c[1]-sc[1])**2; if(d<bd){bd=d;best=s;} }\n      a[m]=best; }\n  });\n  return a;\n}\nfunction deslocFor(mv,assign){\n  let d=0;\n  mv.leaders.forEach(L=>{const byS={};let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=assign[m];if(s){byS[s]=(byS[s]||0)+c;tot+=c;}}\n    const home=mv.dest.reduce((a,s)=>(byS[s]||0)>(byS[a]||0)?s:a,mv.dest[0]);\n    d+=tot-(byS[home]||0);});\n  return d;\n}\nfunction spanMapFor(mv,a){const inc={};mv.dest.forEach(s=>inc[s]=0);\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=a[m];if(s&&inc[s]!=null)inc[s]+=o.cbs;});\n  // Orphan CBs follow their leader's home sector, and that home depends on THIS candidate\n  // assignment — so they have to be counted here, or the optimizer would score and constrain a\n  // span that differs from the one the UI shows. Skipped entirely when there are no orphans.\n  if(mv.orphanByGroup&&Object.keys(mv.orphanByGroup).length){\n    mv.leaders.forEach(L=>{\n      const orphan=mv.orphanByGroup[L.grupo]||0; if(!orphan) return;\n      const byS={}; let tot=0;\n      for(const [m,c] of Object.entries(L.byMuni)){ if(m==='__OUTROS__') continue; const s=a[m]; if(s&&inc[s]!=null){ byS[s]=(byS[s]||0)+c; tot+=c; } }\n      if(!tot) return;   // no home to follow -> stranded, counted nowhere\n      const home=mv.dest.reduce((x,s)=>(byS[s]||0)>(byS[x]||0)?s:x,mv.dest[0]);\n      if(inc[home]!=null) inc[home]+=orphan;\n    });\n  }\n  const sp={};mv.dest.forEach(s=>sp[s]=(+(mv.baseMetrics[s]?.cbs||0))+inc[s]);return sp;}\nfunction maxSpanOfFor(mv,a){return Math.max(...Object.values(spanMapFor(mv,a)),0);}\nfunction mergedSpanAll(){\n  const st=(SESS.settings&&SESS.settings.span)||{};\n  return {\n    setor:{...DEFAULT_SETTINGS.span.setor,...(st.setor||{})},\n    gv:{...DEFAULT_SETTINGS.span.gv,...(st.gv||{})},\n    mercado:{...DEFAULT_SETTINGS.span.mercado,...(st.mercado||{})},\n  };\n}\nfunction spanShortAll(){\n  const sp=mergedSpanAll();\n  const parts=['setor','gv','mercado'].map(lvl=>{ const s=spanShort(sp[lvl]); return s==='livre'?null:(LEVELS[lvl].label+' '+s); }).filter(Boolean);\n  return parts.length?parts.join(' · '):'livre';\n}\n// Sessões salvas antes de existir escala por nível guardavam targetSpan/maxSpan/minSpan soltos no\n// topo de settings, e esses três campos sempre foram só do setor — migra pra span.setor uma vez,\n// preservando null explícito (desligado) em vez de recair no padrão 2700/3500/2200.\nfunction migrateSpanSettings(raw){\n  raw=raw||{};\n  if(raw.span) return {\n    setor:{...DEFAULT_SETTINGS.span.setor,...raw.span.setor},\n    gv:{...DEFAULT_SETTINGS.span.gv,...raw.span.gv},\n    mercado:{...DEFAULT_SETTINGS.span.mercado,...raw.span.mercado},\n  };\n  const legacy=('targetSpan' in raw)||('maxSpan' in raw)||('minSpan' in raw);\n  return {\n    setor: legacy?{targetSpan:raw.targetSpan??null,maxSpan:raw.maxSpan??null,minSpan:raw.minSpan??null}:{...DEFAULT_SETTINGS.span.setor},\n    gv:{...DEFAULT_SETTINGS.span.gv},\n    mercado:{...DEFAULT_SETTINGS.span.mercado},\n  };\n}\nfunction spanLimits(mv){\n  // Span alvo/máximo/mínimo é uma escala POR NÍVEL de fechamento — cada nível olha só a sua.\n  const lvl=(mv&&mv.level)||'setor';\n  const st=mergedSpanAll()[lvl];\n  return {target:numOrNull(st.targetSpan),max:numOrNull(st.maxSpan),min:numOrNull(st.minSpan)};}\nfunction spanLimitsOn(lim){return !!(lim.target||lim.max||lim.min);}\nfunction spanViolationsOf(sp,lim){const over=[],under=[];\n  Object.entries(sp).forEach(([s,v])=>{ if(lim.max&&v>lim.max)over.push(s); if(lim.min&&v<lim.min)under.push(s); });\n  return {over,under};}\nfunction spanViolationsFor(mv,a){return spanViolationsOf(spanMapFor(mv,a),spanLimits(mv));}\n// With a target span the objective becomes RMS distance from the ideal size (punishes too big AND\n// too small). With no target we keep the legacy objective: shave the biggest sector.\nfunction spanCostFor(mv,a){const lim=spanLimits(mv),vals=Object.values(spanMapFor(mv,a));\n  if(!vals.length) return 0;\n  if(lim.target) return Math.sqrt(vals.reduce((s,v)=>s+(v-lim.target)**2,0)/vals.length);\n  return Math.max(...vals);}\n// ---------- ÓTIMO: objetivo multi-critério ----------\n// Three normalised objectives, each 0..1, so the weights below are directly comparable. The old\n// score added a span cost measured in CBs to a desloc measured in CBs, so on a large sector the\n// balance term was numerically ~100x the desloc term and silently decided everything on its own.\n//   balance — RMS spread of the resulting spans (against the target span when one is set,\n//             otherwise against their own mean), over that reference. 0 = every destination\n//             sector ends up the same size.\n//   dist    — how far each município travels, CB-weighted: half from the share of its outer\n//             border that faces the sector it was given to, half from centroid distance. 0 =\n//             every município went to a sector it physically touches. Adjacency used to be a\n//             pure yes/no constraint (contiguity); this is what makes it a preference too.\n//   orphan  — stranded orphan CBs (leader left without a home sector, so they land nowhere at\n//             all) plus CBs split away from their leader's home, over the CBs at stake.\n//             Stranded ones count OPT_STRAND_K times: a stranded CB leaves the model entirely,\n//             a deslocated one merely changes hands.\nconst OPT_W={balance:1, dist:0.85, orphan:1, split:1};\nconst OPT_STRAND_K=3;\nconst OPT_SIMS=24;             // simultaneous simulations per run, scaled down on big stages\nconst OPT_PASSES=14;           // local-search sweeps inside one simulation\nconst OPT_EVAL_BUDGET=120000;  // total candidate evaluations across the whole portfolio\nconst OPT_EXHAUSTIVE_MAX=60000;\nfunction optDistPrep(mv){\n  let mx=0;\n  Object.keys(mv.munis).forEach(m=>{ if(m==='__OUTROS__') return; const c=mv.muniCentroid[m]; if(!c) return;\n    mv.dest.forEach(s=>{ const sc=mv.secCentroid[s]; if(!sc) return;\n      const d=Math.hypot(c[0]-sc[0],c[1]-sc[1]); if(d>mx) mx=d; }); });\n  return mx||1;\n}\nfunction optDistCost(mv,m,s,dmax){\n  const v=mv.allowed[m]||{}; let tot=0; for(const k in v) tot+=v[k];\n  const share=tot?((v[s]||0)/tot):0;\n  const c=mv.muniCentroid[m], sc=mv.secCentroid[s];\n  // Missing centroid on either side is neutral (0.5), never 0 — an unmeasurable move must not\n  // score as the cheapest one on the board.\n  const geo=(c&&sc)?Math.min(1,Math.hypot(c[0]-sc[0],c[1]-sc[1])/dmax):0.5;\n  return 0.5*(1-share)+0.5*geo;\n}\nfunction optEval(mv,a,dmax){\n  const lim=spanLimits(mv);\n  const sp=spanMapFor(mv,a), vals=Object.values(sp);\n  let balance=0;\n  if(vals.length){\n    const ref=lim.target||(vals.reduce((x,y)=>x+y,0)/vals.length);\n    if(ref) balance=Math.min(1,Math.sqrt(vals.reduce((s,v)=>s+(v-ref)**2,0)/vals.length)/ref);\n  }\n  let cbs=0,dist=0;\n  for(const m in a){ const o=mv.munis[m]; if(!o) continue; const w=+o.cbs||0; cbs+=w; dist+=w*optDistCost(mv,m,a[m],dmax); }\n  dist=cbs?(dist/cbs):0;\n  let stranded=0,desloc=0,tot=0;\n  mv.leaders.forEach(L=>{\n    const byS={}; let t=0;\n    for(const [m,c] of Object.entries(L.byMuni)){ if(m==='__OUTROS__') continue; const s=a[m]; if(s){ byS[s]=(byS[s]||0)+c; t+=c; } }\n    const orphan=(mv.orphanByGroup&&mv.orphanByGroup[L.grupo])||0;\n    tot+=t+orphan;\n    if(!t){ stranded+=orphan; return; }   // no allocated CBs -> no home -> these orphans land nowhere\n    const home=mv.dest.reduce((x,s)=>(byS[s]||0)>(byS[x]||0)?s:x,mv.dest[0]);\n    desloc+=t-(byS[home]||0);\n  });\n  const orphanTerm=tot?Math.min(1,(desloc+OPT_STRAND_K*stranded)/tot):0;\n  // split — a município that shares a real sector with ANOTHER live destination candidate (São\n  // Paulo etc.) should consolidate back onto that sibling rather than scatter to an unrelated\n  // sector: leaving it split across multiple sectors after this closure is a structural defect on\n  // its own, independent of balance/adjacência/orphan. Only counts a município where a sibling\n  // destination is actually reachable — nothing to prefer, nothing penalised, when it isn't.\n  let splitBad=0,splitElig=0;\n  for(const m in a){\n    const sib=mv.muniSiblings&&mv.muniSiblings[m]; if(!sib||!sib.length) continue;\n    const avail=sib.filter(s=>mv.dest.includes(s));\n    if(!avail.length) continue;\n    splitElig++;\n    if(!avail.includes(a[m])) splitBad++;\n  }\n  const splitTerm=splitElig?splitBad/splitElig:0;\n  return { cost:OPT_W.balance*balance+OPT_W.dist*dist+OPT_W.orphan*orphanTerm+OPT_W.split*splitTerm,\n           balance, dist, desloc, stranded, sp, viol:spanViolationsOf(sp,lim), splitTerm };\n}\nfunction optScoreFor(mv,a){ return optEval(mv,a,mv.__optDmax||optDistPrep(mv)).cost; }\n// Seeded PRNG on purpose. computeMovement runs the optimizer for EVERY stage and recomputeFrom\n// re-runs every stage downstream of an edit, so a Math.random() portfolio would hand back a\n// different allocation on each pass and a saved timeline would never reproduce.\nfunction optRng(seed){ let a=seed>>>0;\n  return ()=>{ a=(a+0x6D2B79F5)>>>0; let t=Math.imul(a^(a>>>15),1|a);\n    t=(t+Math.imul(t^(t>>>7),61|t))^t; return ((t^(t>>>14))>>>0)/4294967296; }; }\nfunction optSeed(mv,ms){ let h=2166136261>>>0;\n  const s=String(mv.sector)+'|'+ms.join(',')+'|'+mv.dest.join(',');\n  for(let i=0;i<s.length;i++) h=Math.imul(h^s.charCodeAt(i),16777619);\n  return h>>>0; }\n// Cor estável por código de setor — mesmo hash FNV-1a já usado em optSeed, só que aqui memoizado por\n// sessão inteira (não por etapa): o mesmo código sempre cai na mesma cor da PALETTE em QUALQUER\n// etapa do mapa, ao contrário de mv.color (que reindexa por etapa). É o que deixa um município\n// realocado pra um setor que não é candidato agora (ou já foi fechado numa etapa anterior) mostrar\n// uma cor de verdade em vez de cair em cinza \"sem info\".\nconst STABLE_SECTOR_COLOR={};\nfunction colorForSector(code){\n  const k=String(code);\n  if(STABLE_SECTOR_COLOR[k]) return STABLE_SECTOR_COLOR[k];\n  let h=2166136261>>>0;\n  for(let i=0;i<k.length;i++) h=Math.imul(h^k.charCodeAt(i),16777619);\n  const col=PALETTE[(h>>>0)%PALETTE.length];\n  STABLE_SECTOR_COLOR[k]=col;\n  return col;\n}\n// Assigns colors for ONE movement's destination list, guaranteeing no two of them share a color\n// when destList.length<=PALETTE.length. colorForSector alone can't promise that: it's a hash mod\n// PALETTE.length, so two different sector codes routinely land on the same slot (the reported bug —\n// two destinations in the same span/legend/map view painted the same color). First pass keeps each\n// dest's normal stable hash color where it's still free; only actual collisions get bumped to an\n// unused palette slot. Falls back to the stable hash color (repeats become unavoidable) once\n// destList has more entries than PALETTE has colors.\nfunction assignDestColors(destList){\n  const colors={}; const used=new Set();\n  destList.forEach(code=>{\n    const c=colorForSector(code);\n    if(!used.has(c)){ colors[code]=c; used.add(c); }\n  });\n  destList.forEach(code=>{\n    if(colors[code]) return;\n    const free=PALETTE.find(c=>!used.has(c));\n    const chosen=free||colorForSector(code);\n    colors[code]=chosen; used.add(chosen);\n  });\n  return colors;\n}\nfunction contigBadFor(mv,assign){\n  // Connectivity is enforced at every level now: a setor closing redistributes municípios, a GV\n  // closing redistributes setores, a mercado closing redistributes GVs — whichever grain, every\n  // unit sent to the same destination must reach it through mv.closeAdj without leaving the group,\n  // or that destination counts as broken. mv.closeAdj/mv.allowed are already built at unit grain\n  // for every level (see computeMovement), so the same BFS below just works unchanged.\n  const bySec={}; for(const m in assign){(bySec[assign[m]]=bySec[assign[m]]||[]).push(m);}\n  const bad=[];\n  for(const s in bySec){const grp=bySec[s],inGrp=new Set(grp);\n    // A município already split across several real sectors (mv.muniSiblings) is inherently\n    // \"connected\" to any of its own other real owners — sending more of an already-shared\n    // município to a sector that already owns part of it isn't fragmenting new territory, so it\n    // seeds (and never itself breaks) that destination's group even with zero same-sector\n    // closeAdj neighbours of its own. The general chain-connectivity rule below is meant for a\n    // whole, single-sector município moving as one piece; it was never meant to gate this case —\n    // without this, a click on a shared município's own slice (São Paulo etc.) could only ever\n    // land on whichever one destination happened to pass the general geographic-chain check.\n    const seeds=grp.filter(m=>(mv.allowed[m]||{})[s]||(mv.muniSiblings&&mv.muniSiblings[m]&&mv.muniSiblings[m].includes(s)));\n    if(!seeds.length){bad.push(s);continue;}\n    const seen=new Set(seeds),st=[...seeds];\n    while(st.length){const m=st.pop();(mv.closeAdj[m]||[]).forEach(o=>{if(inGrp.has(o)&&!seen.has(o)){seen.add(o);st.push(o);}});}\n    if(seen.size!==grp.length)bad.push(s);}\n  return bad;\n}\n// Usada pelo clique no mapa quando \"forçar conectividade de território\" está ligado: em vez de\n// avançar cegamente pro próximo destino da lista, pula candidatos que introduziriam uma quebra NOVA\n// (compara contra o baseBad de ANTES do clique, então uma quebra pré-existente alheia a este\n// município não trava o ciclo pra sempre). Sem candidato seguro, mantém o destino atual.\nfunction nextContigSafeDest(mv,m){\n  const n=mv.dest.length; if(!n) return mv.assign[m];\n  const cur=mv.assign[m];\n  const baseBad=new Set(contigBadFor(mv,mv.assign));\n  const i0=mv.dest.indexOf(cur);\n  for(let k=1;k<=n;k++){\n    const cand=mv.dest[((i0<0?0:i0)+k)%n];\n    if(cand===cur) continue;\n    const trial={...mv.assign,[m]:cand};\n    if(!contigBadFor(mv,trial).some(s=>!baseBad.has(s))) return cand;\n  }\n  return cur;\n}\nfunction greedyFor(mv,ms,opts){const lim=spanLimits(mv);const a={};\n  ms.forEach((m,i)=>{let best=opts[i][0],bd=Infinity;\n    for(const s of opts[i]){a[m]=s;\n      // over-max only grows as we keep assigning, so it is safe to penalise mid-build;\n      // under-min shrinks, so penalising it here would just chase noise.\n      const over=lim.max?spanViolationsOf(spanMapFor(mv,a),{max:lim.max,min:null}).over.length:0;\n      const d=optScoreFor(mv,a)+(contigBadFor(mv,a).length?1e9:0)+over*1e6;\n      if(d<bd){bd=d;best=s;}}\n    a[m]=best;});\n  return a;}\n// Fase 16 (pedido do usuário — conectividade nunca mais pode quebrar, nem no pior caso: \"é melhor\n// quebrar uma regra de span do que quebrar uma regra de conectividade\"): antes, quando a busca\n// exaustiva/simulada de optimizeFor não achava NENHUMA divisão 100% conectada dentro do espaço que\n// explorou, ela desistia e devolvia greedyFor puro — que só PENALIZA contigBadFor no score, nunca\n// impede o resultado, e foi exatamente essa desistência que deixou o setor 3637 sair do ★ ÓTIMO com\n// \"Território desconectado\". growConnectedFor substitui esse fallback por uma construção que É\n// conectada por definição: cresce cada destino a partir do território que ele JÁ TEM — os mesmos\n// municípios que contigBadFor aceita como semente válida (mv.allowed[m][s], voto de adjacência a\n// setor vizinho já existente, ou mv.muniSiblings) — e só anexa um município novo quando ele encosta\n// (mv.closeAdj) em algo que aquele destino já ganhou. Nada nunca entra desconectado do resto da\n// própria região; o desempate entre destinos concorrentes por um mesmo município usa o mesmo sinal\n// de equilíbrio que o resto do otimizador (span = base já existente + o que for entrando).\nfunction growConnectedFor(mv,ms,opts){\n  const msSet=new Set(ms);\n  const optsOf={}; ms.forEach((m,i)=>{ optsOf[m]=opts[i]||[]; });\n  const a={};\n  const load={}; mv.dest.forEach(s=>{ load[s]=+(mv.baseMetrics[s]&&mv.baseMetrics[s].cbs)||0; });\n  const cbOf=m=>+(mv.munis[m]&&mv.munis[m].cbs)||0;\n  const pick=cands=>{ let best=cands[0],bl=load[best]; for(const s of cands){ if(load[s]<bl){bl=load[s];best=s;} } return best; };\n\n  // Rodada 0 — sementes: todo município que já encosta no território ATUAL de algum destino\n  // (mv.allowed[m][s]) ou é irmão de município já dividido com ele (mv.muniSiblings) começa ligado\n  // direto a esse destino — os mesmos casos que contigBadFor já trata como semente válida. Empate\n  // (município que encosta em mais de um destino já-existente) vai pro que estiver com menos carga.\n  let frontier=[];\n  ms.forEach(m=>{\n    const allow=optsOf[m]; if(!allow.length) return;\n    const votes=Object.keys(mv.allowed[m]||{}).filter(s=>allow.includes(s));\n    const sibs=(mv.muniSiblings&&mv.muniSiblings[m]||[]).filter(s=>allow.includes(s));\n    const cands=[...new Set([...votes,...sibs])];\n    if(!cands.length) return;\n    const s=pick(cands);\n    a[m]=s; load[s]+=cbOf(m); frontier.push(m);\n  });\n\n  // Crescimento em ondas: qualquer município ainda sem destino que seja vizinho (mv.closeAdj) de um\n  // município já atribuído entra na rodada seguinte, ligado a algum destino vizinho que seja opção\n  // legal pra ele (opts) — isto é o que garante, por construção, que a região final de cada destino\n  // é uma peça só: nada é assinalado sem tocar em algo que aquele destino já tem.\n  while(frontier.length){\n    const arrivals={};\n    frontier.forEach(m=>{\n      const s=a[m];\n      (mv.closeAdj[m]||[]).forEach(nb=>{\n        if(!msSet.has(nb)||a[nb]!=null) return;\n        if(optsOf[nb].includes(s)) (arrivals[nb]=arrivals[nb]||new Set()).add(s);\n      });\n    });\n    const next=[];\n    Object.keys(arrivals).forEach(m=>{\n      if(a[m]!=null) return;\n      const s=pick([...arrivals[m]]);\n      a[m]=s; load[s]+=cbOf(m); next.push(m);\n    });\n    frontier=next;\n  }\n\n  // Município isolado do território de todos os destinos por mv.closeAdj, dentro das opções legais\n  // dele (raro — só quando o vizinho já atribuído acabou noutro destino fora do `opts` deste\n  // município): relaxa a restrição de opts e liga a qualquer vizinho já atribuído, de qualquer\n  // destino — ainda uma peça conectada, só não necessariamente na opção preferida.\n  let leftover=ms.filter(m=>a[m]==null);\n  while(leftover.length){\n    let progressed=false; const next=[];\n    leftover.forEach(m=>{\n      const nbDest=(mv.closeAdj[m]||[]).map(nb=>a[nb]).filter(Boolean);\n      if(nbDest.length){ const s=pick(nbDest); a[m]=s; load[s]+=cbOf(m); progressed=true; }\n      else next.push(m);\n    });\n    leftover=next;\n    if(!progressed) break;\n  }\n  // Último recurso genuinamente teórico: município sem nenhum vizinho ligado a nada (ilha total\n  // dentro do próprio fechamento — não deveria existir, já que o setor e seus destinos formam uma\n  // única massa territorial por definição de adjacência). Não há como conectar isto a nada; cai no\n  // destino mais próximo por centróide (optDistCost), a mesma heurística de distância que o resto\n  // do otimizador já usa — a única quebra de conectividade que pode sobrar aqui é real e inevitável,\n  // não fabricada por uma busca que desistiu cedo.\n  if(leftover.length){\n    const dmax=mv.__optDmax||optDistPrep(mv);\n    leftover.forEach(m=>{\n      const allow=optsOf[m].length?optsOf[m]:mv.dest;\n      let best=allow[0],bd=Infinity;\n      for(const s of allow){ const d=optDistCost(mv,m,s,dmax); if(d<bd){bd=d;best=s;} }\n      a[m]=best; load[best]+=cbOf(m);\n    });\n  }\n  return a;\n}\nfunction optimizeFor(mv){\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__');\n  if(!ms.length||!mv.dest.length){ mv.optBadge=mv.dest.length?'':'sem setor destino identificado'; return {}; }\n  const opts=ms.map(m=>{const al=(mv.reachable[m]&&mv.reachable[m].length)?mv.reachable[m]:mv.dest.slice();return al.length?al:mv.dest.slice();});\n  const combos=opts.reduce((a,o)=>a*o.length,1);\n  const lim=spanLimits(mv);\n  const dmax=optDistPrep(mv); mv.__optDmax=dmax;\n\n  // Feasibility ladder — same meaning as before, because renderSpanRule and the saved sessions\n  // both read mv.optTier: 0 = contíguo + max + min span, 1 = contíguo + max span, 2 = contíguo\n  // only. Every candidate any simulation produces is offered to all three tiers; we ship the\n  // strictest tier that got a solution, so an unreachable min/max relaxes loudly instead of\n  // stranding the stage with nothing.\n  const tier=[null,null,null],tSc=[Infinity,Infinity,Infinity],tEv=[null,null,null];\n  let feasible=0,evals=0;\n  let ctgBest=null,ctgCost=Infinity,ctgEv=null,ctgBad=null;\n  const offer=(a,ev)=>{\n    const badSectors=contigBadFor(mv,a);\n    if(badSectors.length){\n      if(ev.cost<ctgCost){ ctgCost=ev.cost; ctgBest={...a}; ctgEv=ev; ctgBad=badSectors; }\n      return false;\n    }\n    feasible++;\n    const put=t=>{ if(ev.cost<tSc[t]){ tSc[t]=ev.cost; tier[t]={...a}; tEv[t]=ev; } };\n    put(2);\n    if(!ev.viol.over.length){ put(1); if(!ev.viol.under.length) put(0); }\n    return true;\n  };\n  const evalOf=a=>{ evals++; return optEval(mv,a,dmax); };\n\n  // Small enough to settle the question outright: enumerate every contiguous allocation and the\n  // winner is provably the best one under this objective. No search needed, so none is run.\n  const exhaustive=combos<=OPT_EXHAUSTIVE_MAX;\n  let sims=0;\n  if(exhaustive){\n    const cur={};\n    const rec=i=>{ if(i===ms.length){ offer(cur,evalOf(cur)); return; }\n      for(const s of opts[i]){ cur[ms[i]]=s; rec(i+1); } };\n    rec(0);\n  } else {\n    // Too big to enumerate, so run a portfolio of independent simulations instead. Each one is a\n    // seed assignment, then hill-climbing on single-município reassignments, then one perturbation\n    // and another climb to escape whatever local optimum it settled into. Different seeds land in\n    // different basins; the ladder above keeps the best across all of them.\n    const improve=a=>{\n      let ev=evalOf(a); offer(a,ev);\n      for(let pass=0;pass<OPT_PASSES;pass++){\n        let moved=false;\n        for(let i=0;i<ms.length;i++){\n          const m=ms[i],cur=a[m]; let best=cur,bev=ev;\n          for(const s of opts[i]){\n            if(s===cur) continue;\n            a[m]=s;\n            const e=evalOf(a);\n            // contiguity is a hard constraint, and the cheaper test runs first on purpose\n            if(e.cost<bev.cost-1e-12 && !contigBadFor(mv,a).length){ best=s; bev=e; }\n          }\n          a[m]=best;\n          if(best!==cur){ ev=bev; moved=true; offer(a,ev); }\n        }\n        if(!moved) break;\n      }\n    };\n    const rng=optRng(optSeed(mv,ms));\n    const perSim=Math.max(1,ms.length*Math.max(1,mv.dest.length)*OPT_PASSES);\n    const budget=Math.max(4,Math.min(OPT_SIMS,Math.floor(OPT_EVAL_BUDGET/perSim)||4));\n    const seeds=[];\n    if(mv.assign&&ms.every(m=>mv.assign[m])) seeds.push({...mv.assign});   // never regress on what is already on screen\n    seeds.push(autoAssignFor(mv));                                        // adjacency-first seed\n    seeds.push(greedyFor(mv,ms,opts));                                    // balance-first seed\n    while(seeds.length<budget){\n      // randomised construction biased toward nearby sectors — the bias keeps each seed sane, the\n      // randomness is what sends the runs into different basins\n      const a={};\n      ms.forEach((m,i)=>{ let bs=opts[i][0],bw=-Infinity;\n        for(const s of opts[i]){ const w=(1-optDistCost(mv,m,s,dmax))+rng()*0.9; if(w>bw){bw=w;bs=s;} }\n        a[m]=bs; });\n      seeds.push(a);\n    }\n    seeds.forEach(seed=>{\n      const a={...seed};\n      // a restored or legacy assign can still name a sector this stage no longer offers\n      ms.forEach((m,i)=>{ if(!opts[i].includes(a[m])) a[m]=opts[i][0]; });\n      improve(a);\n      const kick=Math.max(1,Math.round(ms.length*0.25));\n      for(let k=0;k<kick;k++){ const i=Math.floor(rng()*ms.length); a[ms[i]]=opts[i][Math.floor(rng()*opts[i].length)]; }\n      improve(a);\n    });\n    sims=seeds.length;\n  }\n\n  const t=tier.findIndex(x=>x);\n  if(t<0){\n    // Fase 16: nunca mais cai pro greedyFor puro aqui — growConnectedFor sempre devolve uma\n    // partição conectada por construção. Pior equilíbrio de span que o ★ ÓTIMO quando ele acha\n    // solução, mas conectividade nunca quebra — passa a valer mais que span, por pedido explícito.\n    const grown=growConnectedFor(mv,ms,opts);\n    const ev=evalOf(grown);\n    mv.optTier=-1;\n    mv.optStats={balance:ev.balance,dist:ev.dist,desloc:ev.desloc,stranded:ev.stranded,sims,evals,feasible,combos,exhaustive,split:ev.splitTerm};\n    mv.optBadge=`ÓTIMO por adjacência (conectado) · equilíbrio ±${Math.round(ev.balance*100)}% · adjacência ${Math.round((1-ev.dist)*100)}%`\n      +` · quebra ${fmt(ev.desloc)} CBs · ${fmt(ev.stranded)} fora do setor sem destino · pico ${fmt(maxSpanOfFor(mv,grown))} CBs`;\n    mv.optAlternatives={ tiers:[], contigBroken:null };\n    return grown;\n  }\n  const best=tier[t], ev=tEv[t]; mv.optTier=t;\n  const relax=[];\n  if(t>0 && lim.min) relax.push('span mínimo '+fmt(lim.min));\n  if(t>1 && lim.max) relax.push('span máximo '+fmt(lim.max));\n  mv.optStats={balance:ev.balance,dist:ev.dist,desloc:ev.desloc,stranded:ev.stranded,sims,evals,feasible,combos,exhaustive,split:ev.splitTerm};\n  const hasSplitMunis=Object.keys(mv.muniSiblings||{}).some(m=>(mv.muniSiblings[m]||[]).some(s=>mv.dest.includes(s)));\n  mv.optBadge=`ótimo · ${exhaustive?`espaço completo (${fmt(combos)})`:`${fmt(sims)} simulações`}`\n    +` · equilíbrio ±${Math.round(ev.balance*100)}% · adjacência ${Math.round((1-ev.dist)*100)}%`\n    +` · quebra ${fmt(ev.desloc)} CBs · ${fmt(ev.stranded)} fora do setor sem destino`\n    +(hasSplitMunis?` · consolidação de município compartilhado ${Math.round((1-ev.splitTerm)*100)}%`:'')\n    +` · pico ${fmt(maxSpanOfFor(mv,best))} CBs`\n    +(lim.target?` · alvo ${fmt(lim.target)}`:'')\n    +(relax.length?` · ⚠ inatingível: ${relax.join(' + ')}`:(spanLimitsOn(lim)?' · limites ok':''));\n  mv.optAlternatives={\n    tiers:[0,1,2].filter(i=>i!==t && tier[i]).map(i=>({assign:tier[i], ev:tEv[i], tierIndex:i})),\n    contigBroken: ctgBest?{assign:ctgBest, ev:ctgEv, badSectors:ctgBad}:null,\n  };\n  return best;\n}\nfunction computeDerived(mv){\n  const inc={},gained={},orph={};mv.dest.forEach(s=>{inc[s]=0;gained[s]=0;orph[s]=0;});\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(s&&inc[s]!=null)inc[s]+=o.cbs;});\n  let desloc=0,orphanTotal=0,orphanStranded=0;\n  const L2=mv.leaders.map(L=>{const byS={};mv.dest.forEach(s=>byS[s]=0);let tot=0;\n    for(const [m,c] of Object.entries(L.byMuni)){if(m==='__OUTROS__')continue;const s=mv.assign[m];if(s&&byS[s]!=null){byS[s]+=c;tot+=c;}}\n    // no allocated CBs => no home. Picking dest[0] by default would silently dump this leader's\n    // orphan CBs into an arbitrary sector, so we leave it null and report them as stranded.\n    const home=(tot>0&&mv.dest.length)?mv.dest.reduce((a,s)=>byS[s]>byS[a]?s:a,mv.dest[0]):null;\n    const dl=tot-(home?byS[home]:0);desloc+=dl;if(home)gained[home]++;\n    const orphan=(mv.orphanByGroup&&mv.orphanByGroup[L.grupo])||0;\n    orphanTotal+=orphan;\n    if(orphan){ if(home&&orph[home]!=null) orph[home]+=orphan; else orphanStranded+=orphan; }\n    return {...L,byS,home,desloc:dl,assignedTotal:tot,orphan,orphanHome:(orphan&&home)?home:null};});\n  const span={};mv.dest.forEach(s=>span[s]=(+(mv.baseMetrics[s]?.cbs||0))+inc[s]+orph[s]);\n  return {inc,gained,desloc,leaders:L2,span,orph,orphanTotal,orphanStranded};\n}\n\nasync function computeMovement(code,level,world){\n  level=level||'setor';\n  code=String(code);\n  const mv={sector:code,level,policy:'fora',view:'tobe',selMuni:null,optBadge:''};\n  const SNAP=SESS.snapLiteral;\n  // GV/mercado precisam da base ANTES de montar a query (pra saber quais CD_SETOR entram no IN);\n  // setor não precisa — mantém o timing original (a query dispara sem esperar o CSV).\n  let domBase=level==='setor'?null:await ensureDominantes();\n  // GV/mercado destinations are ORGANIZATIONAL siblings (same mercado / any mercado at all), never\n  // discovered by geographic adjacency the way setor destinations are — a sibling can sit UFs away\n  // from the closing unit's own footprint, in territory the geo-fetch below (scoped to the CLOSING\n  // unit's own UFs, see the comment further down) never requests a shape for. Resolved here, early,\n  // because everything it needs (DOM.*, world) is already available before any SQL/geo fetch runs —\n  // folded into ufSet alongside the closing unit's own UFs so a valid sibling's territory always has\n  // a shape to draw instead of leaving a hole exactly where it sits.\n  const extraDestUFs=new Set();\n  if(level!=='setor' && domBase){\n    const ownParentEarly=parentOf(level,code);\n    const siblingsEarly = level==='gv' ? [...(DOM.gvsByMercado[ownParentEarly]||[])] : Object.keys(DOM.sectorsByMercado||{});\n    siblingsEarly.filter(s=>s!==code).forEach(destCode=>{\n      memberSectorsOf(level,destCode,world).forEach(s=>{\n        (domBase[String(s)]||new Set()).forEach(entry=>{ const u=mUf(entry); if(u) extraDestUFs.add(u); });\n      });\n    });\n  }\n  const memberSectors=level==='setor'?[code]:memberSectorsOf(level,code,world);\n  if(!memberSectors.length) throw new Error(`${LEVELS[level].label} ${code} não existe na base de municípios dominantes (CSV) — atualize a base antes de fechar`);\n  const secList=memberSectors.join(',');\n\n  // PERIODO (18/09, medido no 3225): receita/pontos/pedidos vinham dos campos *_CICLO do CRM, que sao\n  // ACUMULADORES DO CICLO ABERTO, enquanto cbs/ativas ja vinham do ELO no ciclo FECHADO. A mesma linha\n  // da matriz misturava dois periodos. Medido: no snapshot de hoje (ciclo atual 202614, recem-comecado)\n  // o CRM do 3225 da R$ 66.742 e 73 pedidos, onde o ciclo fechado 202613 tem R$ 914.761 e 1.352 pedidos\n  // - 13,7x menos. Era isso que produzia \"1.005 ativas com 73 pedidos\" (contradicao por definicao) e\n  // dois setores de mesmo numero de CBs com receitas muito diferentes: um a 9 dias do ciclo, o outro a 2.\n  // O comentario antigo (ver bSQL) dizia que os campos do ELO nao batiam, \"~2-2,4x, outra definicao de\n  // metrica\". Nao e definicao, e o ciclo parcial: medindo o MESMO ciclo eles batem.\n  // MARCA: revenue_sd_ss_amt e o global (mesmo campo do trendSQL, indicado pelo time de dados da\n  // Natura); revenue_nat_ss_amt / revenue_avon_ss_amt preservam a separacao por marca, que e pedida.\n  // Conferido que (person_code, operational_cycle, country_code) e unico no ELO - 2.002 linhas para\n  // 2.002 pessoas no 3225/202613 - entao o LEFT JOIN soma sem duplicar. CB sem linha no ELO daquele\n  // ciclo entra como NULL e soma zero, que e o correto: nao teve atividade no ciclo fechado.\n  // Reconciliacao a confirmar com a Natura: no 202613, nat+avon = 918.062 e revenue_sd_ss_amt = 914.761\n  // (-0,36%); revenue_ss_amt daria 939.786 (+2,4%, inclui canal online). Mantido o sd_ss por coerencia\n  // com o grafico de tendencia; a diferenca de 0,36% entre o global e a soma das marcas segue aberta.\n  const mSQL=`SELECT t.DS_MUNICIPIO municipio, t.CD_SETOR setor, t.CD_GRUPO grupo, MAX(t.DS_LEADER_NAME) lider,\n     COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs, ROUND(SUM(e.revenue_sd_ss_amt),0) receita,\n     ROUND(SUM(e.revenue_nat_ss_amt),0) rec_nat, ROUND(SUM(e.revenue_avon_ss_amt),0) rec_avon,\n     ROUND(SUM(e.points_ss_amt),0) pontos, ROUND(SUM(e.orders_cnt),0) pedidos,\n     SUM(e.active_bc_ind) ativas\n    FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n    WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR IN (${secList}) AND t.DT_HISTORICO=${SNAP}\n    GROUP BY t.DS_MUNICIPIO, t.CD_SETOR, t.CD_GRUPO`;\n  const ufSQL=`SELECT DS_ESTADO uf, COUNT(*) n FROM ${T} WHERE CD_PAIS=1 AND CD_GRUPO>0 AND CD_SETOR IN (${secList}) AND DT_HISTORICO=${SNAP} GROUP BY DS_ESTADO ORDER BY n DESC`;\n  // MARCA (19/09, medido no 3225 ciclo 202613): revenue_nat_ss_amt e points_nat_ss_amt são Natura\n  // PURA. A tendência mostrava 795.098 de receita onde o setor fez 914.761 — perdia os 122.964 da\n  // Avon inteira, 13% do setor; em pontos, 131.661 contra 161.153, 22% a menos. Pior que o erro em\n  // si: a tabela \"antes → depois\" logo acima usa VL_PEDIDOS_CICLO do CRM, que SOMA as marcas, então\n  // as duas leituras do mesmo setor conviviam na mesma tela com definições diferentes e nada dizendo.\n  // revenue_sd_ss_amt e points_ss_amt são os campos globais indicados pelo time de dados da Natura.\n  // active_bc_ind JÁ é global e fica como está: medido, nat_active(852) + avon_active(543)\n  // − mista_active(390) = 1.005 = active_bc_ind. NÃO trocar por combined_active (315), que conta\n  // outra coisa — quem é ativa nas duas marcas no mesmo ciclo.\n  // A separação por marca de rec_nat/rec_avon nas outras queries é deliberada e permanece.\n  const trendSQL=`SELECT operational_cycle ciclo, COUNT(DISTINCT CASE WHEN business_status_code=3 THEN person_code END) cbs,\n     SUM(active_bc_ind) ativas,\n     ROUND(SUM(revenue_sd_ss_amt),0) receita, ROUND(SUM(points_ss_amt),0) pontos\n    FROM ${ELO} WHERE country_code=1 AND sector_code IN (${secList}) AND operational_cycle<=${SESS.eloMaxCycle||999999} GROUP BY operational_cycle ORDER BY operational_cycle DESC LIMIT 8`;\n  const lvlSQL=`SELECT t.DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs,\n     ROUND(SUM(e.revenue_nat_ss_amt),0) rec_nat, ROUND(SUM(e.revenue_avon_ss_amt),0) rec_avon\n    FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n    WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR IN (${secList}) AND t.DS_NVL_PLAN_CRESC IS NOT NULL AND t.DT_HISTORICO=${SNAP} GROUP BY t.DS_NVL_PLAN_CRESC ORDER BY cbs DESC`;\n  const lvlMuniSQL=`SELECT t.DS_MUNICIPIO municipio, t.CD_SETOR setor, t.DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs\n    FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n    WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR IN (${secList}) AND t.DS_NVL_PLAN_CRESC IS NOT NULL AND t.DT_HISTORICO=${SNAP} GROUP BY t.DS_MUNICIPIO, t.CD_SETOR, t.DS_NVL_PLAN_CRESC`;\n  const [mR,ufR,tR,lR,lmR]=await Promise.all([q(mSQL),q(ufSQL),q(trendSQL),q(lvlSQL),q(lvlMuniSQL)]);\n\n  const mRows=rows(mR);\n  const ownMatrix=mRows.filter(r=>+r.cbs>0);\n  mv.pureOwnMetrics=blankM();\n  ownMatrix.forEach(r=>{ mv.pureOwnMetrics.cbs+=+r.cbs; mv.pureOwnMetrics.receita+=+r.receita||0; mv.pureOwnMetrics.rec_nat+=+r.rec_nat||0; mv.pureOwnMetrics.rec_avon+=+r.rec_avon||0; mv.pureOwnMetrics.pontos+=+r.pontos||0; mv.pureOwnMetrics.pedidos+=+r.pedidos||0; mv.pureOwnMetrics.ativas+=+r.ativas||0; });\n  // Fase 17 (pedido do usuário — setor 3836 via ?apply= saiu com \"0 CBs realocadas\" sem nada\n  // sinalizando a causa): quando a unidade TEM presença real no CRM (linhas de município/grupo\n  // vieram) mas a junção com o ELO no ciclo escolhido não bateu com NENHUMA delas, o fechamento\n  // saía com 0 CBs indistinguível de \"esta unidade realmente não tem CB nenhum\". Não corrige a\n  // causa (pode ser ciclo ELO desatualizado pra esta unidade especificamente, ou snapshot que\n  // mudou de dia entre duas tentativas) — só torna VISÍVEL, no mesmo estilo aviso que\n  // missingMunis/excludedCbs já usam em renderKpis.\n  mv.zeroCbsWarning=(mRows.length>0 && !ownMatrix.length)\n    ? `${LEVELS[level].label} ${code} tem ${fmt(mRows.length)} município/grupo no CRM mas 0 CBs no ciclo ELO ${SESS.eloMaxCycle} — confira se este é o ciclo certo antes de confiar neste resultado.`\n    : null;\n\n  mv.levelByMuni={};\n  if(level==='setor') Object.entries(world.muniLevel||{}).forEach(([m,lv])=>{ if(world.muniOverride[m]===code) mv.levelByMuni[m]={...lv}; });\n\n  mv.trend=rows(tR).map(r=>({...r,cbs:+r.cbs,ativas:+r.ativas,receita:+r.receita,pontos:+r.pontos})).filter(r=>r.cbs>10).reverse();\n  mv.levels=rows(lR);\n\n  let absorbedRows=[],inheritedOrphanRows=[];\n  if(level==='setor'){\n    Object.entries(world.muniOverride).forEach(([m,destSec])=>{ if(destSec===code && world.muniRow[m]) absorbedRows.push(...world.muniRow[m]); });\n    inheritedOrphanRows=(world.sectorOrphanRows&&world.sectorOrphanRows[code])||[];\n  }\n  mv.matrix=ownMatrix.concat(absorbedRows,inheritedOrphanRows);\n\n  const ufList=rows(ufR).map(r=>r.uf).filter(Boolean);\n  const domUF=ufList[0]||'PR';\n  mv.domUF=domUF;\n  domBase=domBase||await ensureDominantes();\n  // Uma GV ou mercado pode espalhar por mais UFs do que \"a UF dominante + suas vizinhas\" cobre —\n  // toda UF que aparece na base de dominantes de QUALQUER setor-membro entra na janela de shapes.\n  const memberUFs=new Set([domUF]);\n  memberSectors.forEach(s=>{ (domBase[String(s)]||new Set()).forEach(entry=>{ const u=mUf(entry); if(u) memberUFs.add(u); }); });\n  const ufSet=new Set(memberUFs);\n  memberUFs.forEach(u=>(UF_BORDERS[u]||[]).forEach(b=>ufSet.add(b)));\n  extraDestUFs.forEach(u=>ufSet.add(u));\n  mv.ufList=[...ufSet];\n  const geos=await Promise.all([...ufSet].map(u=>geoUF(u).catch(()=>null)));\n  const allFeat=geos.filter(Boolean).flatMap(g=>g.features);\n  const adj=buildAdj(allFeat);\n\n  // A UF só vem da base de dominantes (setor real da linha × município), nunca de \"esse nome só\n  // apareceu numa UF carregada\" ou de um chute pra UF dominante do fechamento inteiro. Os dois\n  // eram seguros quando só 1-3 UFs entravam na janela (fechamento de setor); GV e principalmente\n  // mercado carregam dezenas de UFs de uma vez, então \"único entre as UFs carregadas\" deixa de\n  // significar \"único no Brasil\" — era exatamente esse chute que colocava um homônimo de outra UF\n  // espalhado longe do resto do mapa. Sem entrada na base, o município fica sem UF (mesmo\n  // tratamento de \"sem shape\" que já existe) em vez de arriscar aparecer no lugar errado.\n  const ufGuessed={};\n  const ufFor=(setorOf,muni)=>{const nm=norm(muni);\n    return (DOM.ufBySetorMuni&&DOM.ufBySetorMuni[String(setorOf)+'|'+nm])||'';};\n  mv.matrix.forEach(r=>{ if(!r.uf) r.uf=ufFor(r.setor,r.municipio); });\n  rows(lmR).forEach(r=>{ const k=mkey(r.municipio,ufFor(r.setor,r.municipio)); (mv.levelByMuni[k]=mv.levelByMuni[k]||{})[r.nivel]=+r.cbs; });\n\n  const totBy={}; mv.matrix.forEach(r=>{const n=mkey(r.municipio,r.uf); totBy[n]=(totBy[n]||0)+ +r.cbs;});\n  const footprint=new Set(Object.entries(totBy).filter(([,c])=>c>0).map(([m])=>m));\n  const withShape=[...footprint].filter(n=>adj.feat[n]||world.muniGeo[n]);\n  mv.cbsByMuni={...totBy}; mv.shapeMunis=withShape.slice();\n  mv.noShapeMunis=[...footprint].filter(n=>!(adj.feat[n]||world.muniGeo[n])).map(n=>({muni:n,cbs:totBy[n]||0})).sort((a,b)=>b.cbs-a.cbs);\n  mv.noShapeCbs=mv.noShapeMunis.reduce((a,x)=>a+x.cbs,0);\n\n  // ---- RULE: quais municípios pertencem a este fechamento ----\n  // setor: exatamente a regra de hoje. GV/mercado: união da base de TODOS os setores-membro — a\n  // base já garante que um setor pertence a uma única GV/mercado, então não existe aqui o spillover\n  // fuzzy que município tem contra setor (por isso o orphan-handling abaixo é pulado nesses níveis).\n  let domOwn;\n  if(level==='setor'){\n    domOwn=domBase[code]||new Set();\n  } else {\n    domOwn=new Set();\n    memberSectors.forEach(s=>(domBase[String(s)]||new Set()).forEach(n=>domOwn.add(n)));\n  }\n  const inherited=level==='setor'?new Set(Object.keys(world.muniOverride||{}).filter(m=>world.muniOverride[m]===code)):new Set();\n  if(!domOwn.size && !inherited.size) throw new Error(`${LEVELS[level].label} ${code} não existe na base de municípios dominantes (CSV) — atualize a base antes de fechar`);\n  const belongs=n=>domOwn.has(n)||(!DOM.hasUf&&domOwn.has(mName(n)))||inherited.has(n);\n  mv.domCsvMunis=domOwn.size;\n  // KPI \"Municípios fora do setor\" (matriz ASIS/TOBE): CBs por grupo cujo município — direto da\n  // linha crua do CRM em mv.matrix, sem passar por nenhuma camada intermediária — não está listado\n  // na base de dominantes (dominantesCsvUrl) para ESTE setor. Checagem direta contra domOwn/\n  // inherited, independente de já ter (ou não) shape IBGE desenhado — deliberadamente NÃO reaproveita\n  // a marcação `_out`/geoFeat de mv.orphanByGroup (aggregateMovement), que existe pra decidir pra\n  // onde a CB vai na redistribuição (mecânica de simulação, restrita a quem tem shape), não pra\n  // reportar \"está fora da base ou não\" — um município sem shape na UF carregada também conta aqui.\n  mv.foraDoSetorByGrupo={};\n  if(level==='setor'){\n    mv.matrix.forEach(r=>{\n      const m=mkey(r.municipio,r.uf);\n      if(belongs(m)) return;\n      const g=String(r.grupo);\n      mv.foraDoSetorByGrupo[g]=(mv.foraDoSetorByGrupo[g]||0)+ +r.cbs;\n    });\n  }\n  // Município da base de dominantes sem NENHUM CB neste snapshot ainda pertence ao setor — a base é\n  // a fonte de verdade de posse, não o CRM do ciclo. Sem isto ele nunca entra em closingNames, nunca\n  // recebe destino em mv.assign (foldWorldState pula município sem mv.assign[m] ao dobrar o mundo) e\n  // continua aparecendo pra sempre como se o setor dissolvido ainda existisse (.closed-ctx) em\n  // qualquer etapa futura da timeline, mesmo depois desta etapa já ter decidido pra onde ele vai.\n  // zeroCbNames marca esses municípios pra excluir do VOTO de adjacência mais abaixo — sem CB pra\n  // realocar, um município isolado (ou geograficamente distante do miolo real do setor, um problema\n  // comum de qualidade da base CSV que ficava invisível enquanto ele tinha 0 CBs e nunca era\n  // desenhado) não pode sozinho puxar um destino novo e desconexo pro mv.dest; ele ainda é desenhado\n  // e ainda recebe um destino real, via o fallback de optimizeFor pro mv.dest já estabelecido pelos\n  // votos reais.\n  // Belongs to EVERY level now, not just setor: a GV/mercado closure needs the exact same treatment\n  // for a município whose owning setor has zero live CBs right now — the base (CSV) is still the\n  // source of truth for territory, not the current cycle's CRM snapshot. Left setor-gated, this\n  // município (and everything drawn from it — mv.geoFeat, mv.unitMunis, the whole coloured area) simply\n  // never entered the map at GV/mercado grain: a real hole exactly where real territory is.\n  const zeroCbNames=new Set();\n  domOwn.forEach(n=>{\n    if(footprint.has(n)) return;\n    const feat=adj.feat[n]||world.muniGeo[n]; if(!feat) return;\n    totBy[n]=0; mv.cbsByMuni[n]=0; footprint.add(n); withShape.push(n); zeroCbNames.add(n);\n  });\n  const dropped=level==='setor'?withShape.filter(n=>!belongs(n)):[];\n  if(withShape.length && dropped.length===withShape.length) throw new Error(`${LEVELS[level].label} ${code}: nenhum dos municípios com CBs está na base de dominantes — nada pra realocar`);\n  const droppedSet=new Set(dropped);\n  mv.excludedMunis=dropped.map(n=>({muni:n,cbs:totBy[n]||0})).sort((a,b)=>b.cbs-a.cbs);\n  mv.excludedCbs=dropped.reduce((a,n)=>a+(totBy[n]||0),0);\n\n  const closingNames=withShape.filter(n=>!droppedSet.has(n));\n  const closingSet=new Set(closingNames);\n  mv.geoFeat={}; closingNames.forEach(n=>{ mv.geoFeat[n]=(adj.feat[n]&&adj.feat[n].f)||world.muniGeo[n]; });\n\n  // ---- unidade de realocação: a geometria é sempre município; a UNIDADE que troca de dono é o\n  // próprio município (setor fecha), o setor inteiro (GV fecha) ou a GV inteira (mercado fecha).\n  const municipioSetor={}; mv.matrix.forEach(r=>{ const n=mkey(r.municipio,r.uf); if(!municipioSetor[n]) municipioSetor[n]=String(r.setor); });\n  const resolveUnit=n=>{\n    if(level==='setor') return n;\n    // A zero-CB município (injected above from the CSV) has no mv.matrix row at all, so\n    // municipioSetor never learns its setor from the live snapshot — fall back to the same CSV base\n    // everything else here already trusts (DOM.setorByMuni), instead of losing it from its unit.\n    let sOf=municipioSetor[n]||DOM.setorByMuni[n]; if(!sOf) return '';\n    // A setor that already closed EARLIER in this session (a setor-level etapa before this GV/mercado\n    // one) no longer really owns n — n's live CD_SETOR still says otherwise (the CRM snapshot never\n    // learns about a simulated closure), so without this n kept showing up as belonging to a setor\n    // that's supposed to be gone, as if that etapa never happened. world.muniOverride already carries\n    // the fully-resolved (chain-collapsed through any number of prior setor closures) current owner.\n    if(world.closedSectors.has(sOf) && world.muniOverride[n]) sOf=world.muniOverride[n];\n    if(level==='gv') return sOf;\n    // mercado: same idea one level up — this setor's GV may have already closed (a gv-level etapa\n    // earlier in this session) and sent it to a sibling GV. world.secOverride carries that redirect\n    // (built the same way foldWorldState builds muniOverride, just one level up).\n    return (world.secOverride && world.secOverride[sOf]) || DOM.gvBySetor[sOf] || '';\n  };\n  mv.unitOfMuni={}; closingNames.forEach(n=>{ mv.unitOfMuni[n]=resolveUnit(n); });\n  mv.unitMunis={}; closingNames.forEach(n=>{ const u=mv.unitOfMuni[n]; if(!u) return; (mv.unitMunis[u]=mv.unitMunis[u]||[]).push(n); });\n  const units=level==='setor'?closingNames:Object.keys(mv.unitMunis);\n\n  const drawnCsv=new Set(closingNames.map(n=>DOM.hasUf?n:mName(n)));\n  const footKeyOf=k=>DOM.hasUf?(footprint.has(k)?k:null):([...footprint].find(x=>mName(x)===k)||null);\n  mv.csvMunisTotal=domOwn.size;\n  mv.ufGuessed={...ufGuessed};\n  if(level==='setor'){\n    mv.missingMunis=[...domOwn].filter(k=>!drawnCsv.has(k)).map(k=>{\n      const fk=footKeyOf(k);\n      const hasShape=!!(adj.feat[fk||k]||world.muniGeo[fk||k]);\n      const alias=[...footprint].filter(x=>x!==k&&mName(x)===mName(k));\n      let reason;\n      if(!fk&&alias.length) reason=`UF divergente — as CBs entraram na chave ${alias.join(' / ')} e a base diz ${k}`;\n      else if(!fk) reason=hasShape?'sem CBs neste setor no snapshot — desenhado como vizinho':'sem CBs neste setor no snapshot';\n      else if(!hasShape) reason='sem shape nas UFs carregadas';\n      else reason='chave não casou com a base de dominantes';\n      return {muni:k,cbs:(fk?totBy[fk]:alias.reduce((a,x)=>a+(totBy[x]||0),0))||0,reason:reason};\n    });\n  } else {\n    mv.missingMunis=[];\n  }\n\n  // ---- HARD RULE: destino tem de compartilhar a fronteira certa acima do que está fechando ----\n  // setor → mesma GV; GV → mesmo mercado; mercado → nada acima, qualquer vizinho vale.\n  const ownParent=parentOf(level,code);\n  if(level!=='mercado' && !ownParent) throw new Error(`${LEVELS[level].label} ${code} não tem ${level==='setor'?'CD_GV':'MERCADO'} na base de municípios dominantes — sem essa fronteira não há como garantir que os destinos ficam do lado certo da hierarquia`);\n  const sameParent=destCode=>!ownParent||parentOf(level,destCode)===ownParent;\n  mv.gv=ownParent;\n  const alreadyClosed=destCode=>level==='setor'?world.closedSectors.has(destCode):(level==='gv'?(world.closedGvs||new Set()).has(destCode):(world.closedMercados||new Set()).has(destCode));\n\n  // ---- município compartilhado por vários setores (São Paulo etc.): o setor fechando é dono só de\n  // UMA fatia dele, não da cidade inteira — sem isto a camada `.muni` interativa pintava (e deixava\n  // clicar) o município INTEIRO com a cor do destino escolhido, mesmo a maior parte dele pertencendo\n  // a outros setores sem nenhuma relação com este fechamento. Movido pra DEPOIS de mv.gv/sameParent/\n  // alreadyClosed existirem (não era mais logo após mv.geoFeat) porque a fatia de cada sibling agora\n  // depende de quem é candidato válido — só dá pra saber isso com a GV do fechamento já resolvida.\n  // O pedaço que fecha mantém o tamanho de sempre (1 de N reais, N = todos os siblings de verdade);\n  // só o RESTANTE é refatiado, e agora só entre quem qualifica (mesma GV, ainda não fechado, setor\n  // real) — sem isso, cada sibling inválido continuava ocupando sozinho a largura original da sua\n  // fatia em cinza, em vez dessa largura ir pros siblings válidos de verdade (mesma regra \"só corta\n  // pelos válidos\" que a camada de vizinhos, em drawMap, já aplica). mv.sharedSiblingCodes guarda\n  // TODO sibling real visto aqui (válido ou não) — o bookkeeping de \"rejeitado por GV errada\" mais\n  // abaixo lê daqui, não mais de mv.sharedMuniSlices, porque esse array agora só carrega os já-\n  // válidos (ou uma fatia única com sector:null, quando nenhum sibling qualifica).\n  mv.sharedMuniSlices=[];\n  mv.sharedSiblingCodes=new Set();\n  // Real sector-sharing per município (independent of geometry/geoFeat) — feeds the ★ ÓTIMO \"split\"\n  // objective below: a município left split across multiple real sectors after this closure is a\n  // structural defect on its own, so the optimizer is biased toward routing a shared município back\n  // to whichever OTHER real sector already owns a piece of it, consolidating instead of scattering it\n  // to an unrelated destination.\n  mv.muniSiblings={};\n  if(level==='setor'){\n    const isValidSibling=s=>SESS.sectorsList.some(x=>x.setor===s)&&!alreadyClosed(s)&&sameParent(s);\n    closingNames.forEach(n=>{\n      const realOwners=DOM.setoresByMuni&&DOM.setoresByMuni[n];\n      // A município that's only here via `inherited` (this sector received it whole, via muniOverride,\n      // from an earlier etapa's closure) never had `code` as one of its REAL (CSV) owners — the raw\n      // base still lists whatever sector really owned it. Without this guard, that static list (minus\n      // `code`, which was never in it to begin with) reads as \"other real siblings\" and this município\n      // gets sliced/hatched as if genuinely shared, even though it belongs to `code` wholly and only.\n      if(!realOwners||!realOwners.has(code)) return;\n      const siblings=[...realOwners].filter(s=>s!==code);\n      if(siblings.length) mv.muniSiblings[n]=siblings;\n      if(!siblings.length||!mv.geoFeat[n]) return;\n      siblings.forEach(s=>mv.sharedSiblingCodes.add(s));\n      const orig=mv.geoFeat[n];\n      // Stashed before mv.geoFeat[n] below gets overwritten with just this sector's own slice —\n      // drawMap's multi-owner outline (multiBorderFeats) wants the TRUE, single, un-cut polygon for\n      // a município shared with the closing sector itself, same as it already gets for a shared\n      // município that's merely a bordering neighbour (mv.nbrFeat, never sliced to begin with).\n      mv.muniFullShape=mv.muniFullShape||{}; mv.muniFullShape[n]=orig;\n      const owners=[code,...siblings].sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      const slices=sliceFeatureEqual(orig,owners.length);\n      const mine=slices[owners.indexOf(code)];\n      if(mine&&!mine.__empty) mv.geoFeat[n]=mine;\n      const remParts=slices.filter((sl,i)=>owners[i]!==code&&sl&&!sl.__empty).flatMap(sl=>sl.geometry.coordinates);\n      if(!remParts.length) return;\n      const remainder={type:'Feature',properties:orig.properties,__uf:orig.__uf,geometry:{type:'MultiPolygon',coordinates:remParts}};\n      const valid=siblings.filter(isValidSibling).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      sliceFeatureEqual(remainder,valid.length||1).forEach((sl,i)=>{\n        if(sl.__empty) return;\n        mv.sharedMuniSlices.push({feature:sl,sector:valid.length?valid[i]:null,muni:n});\n      });\n    });\n  }\n\n  mv.allowed={}; mv.allowedRaw={};\n  units.forEach(u=>{ mv.allowed[u]={}; mv.allowedRaw[u]={}; });\n  mv.rejectedCodes=new Set(); mv.rejectedInfo={};\n\n  const extVotes={}; const nbrNames=new Set(); let domSecOf={};\n  if(level==='setor'){\n    // setor: destino descoberto por ADJACÊNCIA GEOGRÁFICA de município — uma query ao vivo por\n    // vizinho, mas o perímetro de UM setor é pequeno o bastante pra valer a pena.\n    closingNames.forEach(n=>{\n      if(!adj.feat[n]) return;\n      // Previously skipped here: a município with 0 live CBs under the closing sector's own code\n      // could never vote for a neighbouring destination, even when geographically real and\n      // genuinely adjacent (e.g. its CBs are currently tagged under a sibling sector, or simply a\n      // live/reference-data mismatch) — so it could never reach an otherwise-valid, visibly\n      // bordering destination, either as a mv.dest candidate or as its own contiguity seed. It is\n      // still drawn/assigned via the fallback either way, so letting it vote costs nothing and\n      // only ever adds a real, geographically-grounded option.\n      adj.nbrs(n).forEach(({o,w})=>{ if(!closingSet.has(o) && !droppedSet.has(o)){ nbrNames.add(o); (extVotes[n]=extVotes[n]||[]).push({o,w}); } });\n    });\n    // Setor dominante de cada vizinho vem da base ESTÁTICA (DOM.setorByMuni), não de uma consulta ao\n    // vivo por contagem de CBs: a consulta antiga só devolvia linha pro município com CB ativo NESTE\n    // snapshot — um vizinho geograficamente real mas sem CB agora simplesmente não aparecia, então\n    // nunca virava rejeitado nem contexto (sumia do mapa em vez de aparecer cinza). A base de\n    // dominantes cobre todo município, com ou sem CB no momento, então nenhum vizinho fica de fora.\n    nbrNames.forEach(n=>{ domSecOf[n]=DOM.setorByMuni[n]; });\n    nbrNames.forEach(n=>{ if(world.muniOverride && world.muniOverride[n]) domSecOf[n]=world.muniOverride[n]; });\n\n    mv.nbrCurrentSector={};\n    mv.gvRejected=[];\n    const gvHidden=new Set();\n    nbrNames.forEach(n=>{\n      const destCode=destUnitOfSector(level,domSecOf[n],world);\n      if(destCode && !sameParent(destCode)){\n        gvHidden.add(n);\n        mv.nbrCurrentSector[n]=destCode;\n        mv.rejectedCodes.add(destCode);\n        if(!mv.rejectedInfo[destCode]) mv.rejectedInfo[destCode]={code:destCode,parent:parentOf('setor',destCode)};\n        if(mv.gvRejected.indexOf(destCode)<0) mv.gvRejected.push(destCode);\n        return;\n      }\n      if(domSecOf[n]) mv.nbrCurrentSector[n]=destCode;\n    });\n    // Rejected neighbours KEEP their nbrNames/nbrCurrentSector entry — only their votes are stripped\n    // below, never their geometry — so the map can gray them in with a hover explaining the mismatch\n    // instead of just erasing them from the world.\n    Object.keys(extVotes).forEach(n=>{ extVotes[n]=extVotes[n].filter(v=>!gvHidden.has(v.o)); });\n\n    // Shared-município siblings get the SAME hard GV rule as any other destination candidate — a\n    // São Paulo sibling owned by a different-GV setor is rejected context, never a silently-available\n    // destination. Reads mv.sharedSiblingCodes (every real sibling seen, valid or not), not\n    // mv.sharedMuniSlices — that array now only carries already-valid siblings (or a null-sector flat\n    // piece), so a cross-GV sibling would never surface here reading straight from it.\n    (mv.sharedSiblingCodes||new Set()).forEach(s=>{\n      if(!sameParent(s)){\n        mv.rejectedCodes.add(s);\n        if(!mv.rejectedInfo[s]) mv.rejectedInfo[s]={code:s,parent:parentOf('setor',s)};\n        if(mv.gvRejected.indexOf(s)<0) mv.gvRejected.push(s);\n      }\n    });\n\n    mv.bridgeMunis=closingNames.slice();\n    mv.ignoredBridges=[];\n\n    // Setor referenciado pela base de dominantes mas que não existe MAIS no CRM (código renumerado/\n    // retirado, erro de digitação na base) não pode virar destino de verdade — sem isto ele entra\n    // como destino \"válido\" com span sempre 0 (a query real nunca devolve linha nenhuma pra ele),\n    // indistinguível na tela de um setor genuinamente vazio. SESS.sectorsList é a MESMA checagem de\n    // existência que addMovement já usa pra recusar fechar um código que não existe no snapshot.\n    const validSectorCodes=new Set((SESS.sectorsList||[]).map(s=>s.setor));\n    const wAny={};\n    closingNames.forEach(n=>{\n      const u=n;\n      if(!mv.allowed[u]) return;\n      (extVotes[n]||[]).forEach(({o,w})=>{\n        const destCode=mv.nbrCurrentSector[o];\n        if(!destCode || destCode===code || alreadyClosed(destCode) || !validSectorCodes.has(destCode)) return;\n        mv.allowed[u][destCode]=(mv.allowed[u][destCode]||0)+w;\n        mv.allowedRaw[u][destCode]=(mv.allowedRaw[u][destCode]||0)+w;\n        wAny[destCode]=(wAny[destCode]||0)+w;\n      });\n    });\n    // A shared-território SIBLING (this closure literally owns a slice of the same município as\n    // destCode — mv.sharedSiblingCodes, populated above) can never earn a vote in the scan just\n    // above: it explicitly excludes the closing sector's OWN municípios as voters\n    // (`!closingSet.has(o)`, back where extVotes is built), and a shared município IS one of them —\n    // correctly, it must not vote for itself. But that left the closing sector's single most\n    // locally-relevant destination (literally the same município, split two ways) structurally\n    // unable to ever reach mv.dest, while muniTipHTML's isValidSiblingTarget tells the map hover\n    // it's \"válido\" on organisational grounds alone (same GV, exists, not closed — no geography\n    // required). Given a guaranteed slot here instead of merely entering a vote race it cannot win —\n    // ranked first, so it only bumps a weaker normal candidate out of the top-6 when genuinely out\n    // of room, never the other way around.\n    const validSiblings=[...(mv.sharedSiblingCodes||new Set())].filter(s=>validSectorCodes.has(s)&&!alreadyClosed(s)&&sameParent(s));\n    const votedRanked=Object.keys(wAny).sort((a,b)=>wAny[b]-wAny[a]);\n    mv.dest=[...new Set([...validSiblings,...votedRanked])].slice(0,6);\n    if(!mv.dest.length){\n      mv.dest=[...new Set(Object.values(mv.nbrCurrentSector))].filter(destCode=>destCode&&destCode!==code&&!alreadyClosed(destCode)&&sameParent(destCode)&&validSectorCodes.has(destCode)).slice(0,6);\n      units.forEach(u=>{ if(mv.allowed[u]&&!Object.keys(mv.allowed[u]).length){ mv.dest.forEach(destCode=>mv.allowed[u][destCode]=1); } });\n    }\n  } else {\n    // GV/mercado: destino é SEMPRE todo irmão válido na hierarquia — NUNCA descoberto por\n    // adjacência geográfica de município. Uma GV/mercado pode ter território espalhado por\n    // dezenas de UFs sem qualquer conexão física entre as partes (a hierarquia aqui é\n    // organizacional, não geográfica) — \"vizinho por fronteira de município\" não é só caro (a\n    // query ao vivo vira um IN-list de centenas de municípios e estoura timeout: foi exatamente\n    // isso que quebrou o fechamento de um mercado espalhado) — é a pergunta errada. A fronteira que\n    // já importa (mesma GV / mesmo mercado) é imposta pela regra de parentesco logo acima; dentro\n    // dela QUALQUER irmão é candidato, e o otimizador decide pelo equilíbrio de span e continuidade\n    // de líder, nunca por proximidade física — geografia não pesa aqui.\n    mv.nbrCurrentSector={};\n    mv.gvRejected=[];\n    mv.bridgeMunis=closingNames.slice();\n    mv.ignoredBridges=[];\n    const siblings=level==='gv'\n      ? [...(DOM.gvsByMercado[ownParent]||[])]\n      : Object.keys(DOM.sectorsByMercado||{});\n    mv.dest=siblings.filter(destCode=>destCode!==code && !alreadyClosed(destCode) && sameParent(destCode));\n    units.forEach(u=>{ mv.dest.forEach(destCode=>{ mv.allowed[u][destCode]=1; mv.allowedRaw[u][destCode]=1; }); });\n\n    // Geographically-touching GVs that fail the hierarchy rule (different mercado) — grayed on the\n    // map with a hover explaining why, using ONLY the static dominantes CSV (DOM.setorByMuni /\n    // DOM.gvBySetor / DOM.mercadoByGv, already in memory) — no live query, so the timeout that broke\n    // geographic discovery for a spread-out mercado closing (comment above) never re-enters here.\n    // nGv MUST resolve through destUnitOfSector (world-aware), not a raw DOM.gvBySetor lookup: a\n    // previous GV closure THIS SESSION may already have cascaded sOf to a new GV via secOverride,\n    // and DOM.gvBySetor only ever knows the static/original one. Without this, a neighbour município\n    // whose setor was reassigned earlier in the session rendered under the GV that closure CLOSED\n    // (still \"rejected\"/wrong colour) instead of its real current GV — and since nbrNames already\n    // held it from here, the correct territoryOf() pass for the real GV never got a chance to fix it.\n    if(level==='gv'){\n      closingNames.forEach(n=>{\n        if(!adj.feat[n]) return;\n        adj.nbrs(n).forEach(({o})=>{\n          if(closingSet.has(o) || nbrNames.has(o)) return;\n          const sOf=DOM.setorByMuni[o]; if(!sOf) return;\n          const nGv=destUnitOfSector(level,sOf,world); if(!nGv || nGv===code || sameParent(nGv)) return;\n          nbrNames.add(o);\n          mv.nbrCurrentSector[o]=nGv;\n          mv.rejectedCodes.add(nGv);\n          if(!mv.rejectedInfo[nGv]) mv.rejectedInfo[nGv]={code:nGv,parent:DOM.mercadoByGv[nGv]||''};\n          if(mv.gvRejected.indexOf(nGv)<0) mv.gvRejected.push(nGv);\n        });\n      });\n    }\n  }\n  mv.dest=mv.dest.filter(sameParent);\n\n  // ---- O QUE É DESENHADO: território COMPLETO de cada destino (não só a borda), sempre lido da\n  // mesma base de dominantes já usada pro lado que fecha.\n  const territoryOf=destCode=>{\n    const secs=memberSectorsOf(level,destCode,world);\n    const out=new Set();\n    secs.forEach(s=>(domBase[String(s)]||new Set()).forEach(n=>out.add(n)));\n    return out;\n  };\n  mv.muniOwners={};\n  mv.dest.forEach(destCode=>{\n    territoryOf(destCode).forEach(n=>{\n      // A município that's part of the CLOSING sector itself is already drawn (and clicked) via\n      // mv.geoFeat/unitAssign, further down — it isn't up-for-grabs context. Without this guard, a\n      // multi-sector city (São Paulo etc.) that also happens to be one of the closing sector's OWN\n      // municípios got its single \"current assignment\" fill painted right over the sliced context\n      // layer underneath — same IBGE polygon, and the closing layer draws on top.\n      if(closingSet.has(n)) return;\n      (mv.muniOwners[n]=mv.muniOwners[n]||[]).push(destCode);\n      if(nbrNames.has(n)) return;\n      nbrNames.add(n);\n      mv.nbrCurrentSector[n]=(level==='setor'&&world.muniOverride&&world.muniOverride[n])?world.muniOverride[n]:destCode;\n    });\n  });\n  // Draw every município we actually resolved a current sector for — not just the top-voted\n  // mv.dest candidates + rejectedCodes. A real same-GV neighbour sector that lost the vote race\n  // (more than 6 distinct neighbours, or simply outvoted) still gets classified further down as\n  // 'plain' context; filtering it out HERE instead left it entirely undrawn — a blank gap on the\n  // map exactly where a real, valid neighbouring sector is, instead of the grey context it should be.\n  // A neighbour whose current sector never resolved (no CRM row for it in this snapshot, a name\n  // mismatch, whatever) used to be dropped here entirely — invisible, no way to tell it apart from\n  // \"there's genuinely nothing there\". It still keeps its real shape; the render step below tags it\n  // 'unresolved' and shows the actual município name on hover instead, which is what you want when\n  // debugging why something didn't resolve.\n  // Um setor/GV rejeitado só entrava no mapa pelos municípios que sobreviveram à varredura de\n  // adjacência (os poucos que realmente encostam no que está fechando) — o resto do território dele,\n  // segundo a base de dominantes, nunca aparecia. Mesma expansão que mv.dest já recebe logo acima:\n  // território COMPLETO do setor/GV rejeitado, não só a fatia que faz fronteira.\n  mv.rejectedCodes.forEach(destCode=>{\n    territoryOf(destCode).forEach(n=>{\n      if(closingSet.has(n)) return;\n      if(nbrNames.has(n)) return;\n      nbrNames.add(n);\n      mv.nbrCurrentSector[n]=(level==='setor'&&world.muniOverride&&world.muniOverride[n])?world.muniOverride[n]:destCode;\n    });\n  });\n  // Setor fechando: um vizinho da MESMA GV que perdeu a votação (mais de 6 candidatos, ou simplesmente\n  // menos votado) nunca vira mv.dest nem mv.rejectedCodes — fica só como o único município que de fato\n  // encosta no fechamento, sem a expansão de território, e o resto dele sobra como buraco no mapa\n  // (nem colorido, nem cinza, nem contexto — simplesmente ausente). mv.allowedRaw carrega TODO\n  // candidato válido já descoberto, não só o top-6, então dá pra expandir os que sobraram também.\n  const plainCandidates=new Set();\n  units.forEach(u=>Object.keys(mv.allowedRaw[u]||{}).forEach(dc=>plainCandidates.add(dc)));\n  // Persisted so drawMap can test \"is this real sector a valid destination candidate anywhere in\n  // this closure\" without the top-6 mv.dest cap — adjacent + same GV/mercado + not already closed is\n  // enough to count as valid for the multi-sector-município striping/hover, even if it lost the vote.\n  mv.validCandidates=plainCandidates;\n  plainCandidates.forEach(destCode=>{\n    if(mv.dest.includes(destCode)) return;\n    territoryOf(destCode).forEach(n=>{\n      if(closingSet.has(n)) return;\n      if(nbrNames.has(n)) return;\n      nbrNames.add(n);\n      mv.nbrCurrentSector[n]=(level==='setor'&&world.muniOverride&&world.muniOverride[n])?world.muniOverride[n]:destCode;\n    });\n  });\n  mv.nbrFeat=[...nbrNames].map(n=>adj.feat[n]&&adj.feat[n].f).filter(Boolean);\n  units.forEach(u=>{ Object.keys(mv.allowed[u]||{}).forEach(destCode=>{ if(!mv.dest.includes(destCode)) delete mv.allowed[u][destCode]; }); });\n  mv.color=assignDestColors(mv.dest);\n\n  const accC={}; Object.entries(mv.nbrCurrentSector).forEach(([nb,destCode])=>{ if(!mv.dest.includes(destCode))return; const f=(adj.feat[nb]&&adj.feat[nb].f)||world.muniGeo[nb]; if(!f)return; const c=centroidLL(f); accC[destCode]=accC[destCode]||[0,0,0]; accC[destCode][0]+=c[0]; accC[destCode][1]+=c[1]; accC[destCode][2]++; });\n  mv.secCentroid={}; mv.dest.forEach(destCode=>{ if(accC[destCode]) mv.secCentroid[destCode]=[accC[destCode][0]/accC[destCode][2],accC[destCode][1]/accC[destCode][2]]; });\n\n  if(level==='setor'){\n    mv.muniCentroid={}; closingNames.forEach(n=>{ if(mv.geoFeat[n]) mv.muniCentroid[n]=centroidLL(mv.geoFeat[n]); });\n    mv.closeAdj={}; closingNames.forEach(n=>{ mv.closeAdj[n]=adj.feat[n]?adj.nbrs(n).filter(({o})=>closingSet.has(o)).map(({o})=>o):[]; });\n  } else {\n    mv.muniCentroid={};\n    Object.entries(mv.unitMunis).forEach(([u,ms])=>{\n      const cs=ms.map(n=>mv.geoFeat[n]&&centroidLL(mv.geoFeat[n])).filter(Boolean);\n      if(cs.length) mv.muniCentroid[u]=[cs.reduce((a,c)=>a+c[0],0)/cs.length,cs.reduce((a,c)=>a+c[1],0)/cs.length];\n    });\n    mv.closeAdj={};\n    const unitSet=new Set(units);\n    Object.entries(mv.unitMunis).forEach(([u,ms])=>{\n      const touch=new Set();\n      ms.forEach(n=>{ if(!adj.feat[n]) return; adj.nbrs(n).forEach(({o})=>{ const ou=mv.unitOfMuni[o]; if(ou&&ou!==u&&unitSet.has(ou)) touch.add(ou); }); });\n      mv.closeAdj[u]=[...touch];\n    });\n  }\n  const comp={}; let ci=0; units.forEach(n=>{ if(comp[n]!=null)return; const st=[n]; comp[n]=ci; while(st.length){ const x=st.pop(); (mv.closeAdj[x]||[]).forEach(o=>{ if(comp[o]==null){ comp[o]=ci; st.push(o); } }); } ci++; });\n  const compAllowed={}; units.forEach(n=>{ const cid=comp[n]; (compAllowed[cid]=compAllowed[cid]||new Set()); Object.keys(mv.allowed[n]||{}).forEach(s=>compAllowed[cid].add(s)); });\n  mv.reachable={}; units.forEach(n=>{ mv.reachable[n]=[...(compAllowed[comp[n]]||[])].filter(s=>mv.dest.includes(s)); });\n\n  if(mv.dest.length){\n    const destSectorMap={}; mv.dest.forEach(destCode=>{ destSectorMap[destCode]=memberSectorsOf(level,destCode,world); });\n    const allDestSecs=[...new Set(Object.values(destSectorMap).flat())];\n    const rList=allDestSecs.join(',');\n    const bSQL=`SELECT t.CD_SETOR setor, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs,\n       SUM(e.active_bc_ind) ativas,\n       ROUND(SUM(e.revenue_sd_ss_amt),0) receita, ROUND(SUM(e.revenue_nat_ss_amt),0) rec_nat,\n       ROUND(SUM(e.revenue_avon_ss_amt),0) rec_avon, ROUND(SUM(e.points_ss_amt),0) pontos, ROUND(SUM(e.orders_cnt),0) pedidos\n      FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n      WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR IN (${rList}) AND t.DT_HISTORICO=${SNAP} GROUP BY t.CD_SETOR`;\n    const blSQL=`SELECT t.CD_SETOR setor, t.DS_NVL_PLAN_CRESC nivel, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs\n      FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n      WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR IN (${rList}) AND t.DS_NVL_PLAN_CRESC IS NOT NULL AND t.DT_HISTORICO=${SNAP} GROUP BY t.CD_SETOR, t.DS_NVL_PLAN_CRESC`;\n    // CBs count comes ONLY from ELO (commercial_consultant_index), never from a T-driven join: T's\n    // daily CRM extract is missing entire cohorts of registered CBs for dozens of setores (e.g. setor\n    // 3746 — 1806 registered in ELO's closed cycle vs 7 visible through the old T-driven count), so\n    // any count gated on a matching T row silently drops most of a setor's real CBs. ELO only groups\n    // by sector_code (no município column), so this fixes the setor-level base count; the per-município\n    // matrix below still has to come from T — there is no other source with that granularity.\n    // ativas is sourced the same way — matches T-driven ativas closely on healthy setores (1636 vs\n    // 1650 for setor 3627) and, like cbs, must not depend on a T row existing.\n    // 18/09: receita/pontos/pedidos SAIRAM do T e passaram pro ELO, junto com mSQL, lvlSQL e\n    // computeTransfer - ver o comentario grande em cima do mSQL. A afirmacao que estava aqui (\"os\n    // campos do ELO nao batem, ~2-2,4x, outra definicao de metrica\") estava errada: a razao instavel\n    // vinha de comparar o acumulador do ciclo ABERTO do CRM com um ciclo FECHADO inteiro do ELO.\n    // Medido no mesmo ciclo, batem. Manter no T era o que fazia a matriz \"antes -> depois\" discordar\n    // do grafico de tendencia (que ja le do ELO) na mesma tela, sem nada sinalizando.\n    const cSQL=`SELECT sector_code setor, COUNT(DISTINCT person_code) cbs, SUM(active_bc_ind) ativas\n      FROM ${ELO} WHERE country_code=1 AND operational_cycle=${SESS.eloMaxCycle} AND business_status_code=3 AND sector_code IN (${rList}) GROUP BY sector_code`;\n    const [bR,blR,cR]=await Promise.all([q(bSQL),q(blSQL),q(cSQL)]);\n    const secMetrics={}; rows(bR).forEach(r=>{ secMetrics[String(r.setor)]=r; if(!SESS.sectorBaseCache[String(r.setor)]) SESS.sectorBaseCache[String(r.setor)]=r; });\n    rows(cR).forEach(r=>{\n      const s=String(r.setor);\n      if(!secMetrics[s]) secMetrics[s]={setor:s,cbs:0,ativas:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0};\n      secMetrics[s].cbs=+r.cbs||0;\n      secMetrics[s].ativas=+r.ativas||0;\n    });\n    const secLevel={}; rows(blR).forEach(r=>{ const s=String(r.setor); (secLevel[s]=secLevel[s]||{})[r.nivel]=+r.cbs; });\n    mv.baseMetrics={}; mv.baseLevel={};\n    mv.dest.forEach(destCode=>{\n      const secs=destSectorMap[destCode];\n      const acc=blankM(); acc.setor=destCode;\n      secs.forEach(s=>{ const r=secMetrics[String(s)]; if(!r) return; Object.keys(blankM()).forEach(k=>{ acc[k]=(+acc[k]||0)+(+r[k]||0); }); });\n      mv.baseMetrics[destCode]=acc;\n      const lv={}; secs.forEach(s=>{ const l=secLevel[String(s)]; if(!l) return; Object.entries(l).forEach(([nv,c])=>{ lv[nv]=(lv[nv]||0)+c; }); });\n      mv.baseLevel[destCode]=lv;\n    });\n  } else { mv.baseMetrics={}; mv.baseLevel={}; }\n\n  mv.dest.forEach(destCode=>{\n    if(!mv.baseMetrics[destCode]) mv.baseMetrics[destCode]={setor:destCode,cbs:0,ativas:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0};\n    const inflowSrc=level==='setor'?world.sectorInflow:(level==='gv'?world.gvInflow:world.mercadoInflow);\n    const infl=inflowSrc&&inflowSrc[destCode];\n    if(infl) Object.keys(blankM()).forEach(k=>{ mv.baseMetrics[destCode][k]=(+mv.baseMetrics[destCode][k]||0)+(infl[k]||0); });\n    if(level==='setor'){\n      const inflLv=world.sectorInflowLevel&&world.sectorInflowLevel[destCode];\n      if(inflLv){ mv.baseLevel[destCode]=mv.baseLevel[destCode]||{}; Object.entries(inflLv).forEach(([nv,c])=>{ mv.baseLevel[destCode][nv]=(mv.baseLevel[destCode][nv]||0)+c; }); }\n      // Manual município transfers (see foldWorldState) subtract from whichever sector LOST the\n      // município — the only outflow this engine has, since a closed sector never needs it (its\n      // whole base is moot once dissolved).\n      const outfl=world.sectorOutflow&&world.sectorOutflow[destCode];\n      if(outfl) Object.keys(blankM()).forEach(k=>{ mv.baseMetrics[destCode][k]=Math.max(0,(+mv.baseMetrics[destCode][k]||0)-(outfl[k]||0)); });\n    }\n  });\n\n  aggregateMovement(mv,world);\n  mv.assign=optimizeFor(mv);\n  return mv;\n}\n\n// Reconstructs a timeline entry from a FROZEN saved etapa (saveSession's trimmed schema) instead of\n// recomputing it against Databricks — the whole point of the new session format. Every business-data\n// field is copied verbatim (this IS the point: no recompute, no drift from what was on screen when\n// saved); only map geometry (geoFeat/nbrFeat/muniCentroid/secCentroid/sharedMuniSlices) is rebuilt,\n// always live, from ufList/closingKeys/nbrKeys against whatever shapes are current — per the rule that\n// geometry and Overpass/OSM data are never frozen.\nasync function hydrateFromFrozen(i,saved){\n  const isSetor=(saved.level||'setor')==='setor';\n  const mv={\n    sector:String(saved.sector), level:saved.level||'setor', policy:saved.policy||'fora', view:'tobe', selMuni:null, optBadge:saved.optBadge||'',\n    assign:{...(saved.assign||{})}, dest:[...(saved.dest||[])],\n    munis:saved.munis||{}, leaders:saved.leaders||[],\n    baseMetrics:saved.baseMetrics||{}, baseLevel:saved.baseLevel||{},\n    trend:saved.trend||[], pureOwnMetrics:saved.pureOwnMetrics||blankM(),\n    muniOwners:saved.muniOwners||{}, rejectedCodes:new Set(saved.rejectedCodes||[]), rejectedInfo:saved.rejectedInfo||{},\n    nbrCurrentSector:saved.nbrCurrentSector||{}, validCandidates:new Set(saved.validCandidates||[]),\n    gvRejected:saved.gvRejected||[], unitOfMuni:saved.unitOfMuni||{}, unitMunis:saved.unitMunis||{},\n    closeAdj:saved.closeAdj||{}, csvMunisTotal:saved.csvMunisTotal||0, domCsvMunis:saved.domCsvMunis||0,\n    excludedMunis:saved.excludedMunis||[], excludedCbs:saved.excludedCbs||0,\n    noShapeMunis:saved.noShapeMunis||[], noShapeCbs:saved.noShapeCbs||0,\n    cbsByMuni:saved.cbsByMuni||{}, gv:saved.gv||'', domUF:saved.domUF||'', optTier:saved.optTier==null?0:saved.optTier,\n    matrix:isSetor?(saved.matrix||[]).map(r=>({...r})):[], levelByMuni:isSetor?(saved.levelByMuni||{}):{},\n    allowed:isSetor?(saved.allowed||{}):{}, muniSiblings:isSetor?(saved.muniSiblings||{}):{},\n    sharedSiblingCodes:new Set(isSetor?(saved.sharedSiblingCodes||[]):[]),\n    missingMunis:isSetor?(saved.missingMunis||[]):[],\n    orphanByGroup:isSetor?(saved.orphanByGroup||{}):{}, orphanMetricsByGroup:isSetor?(saved.orphanMetricsByGroup||{}):{},\n    orphanLevelByGroup:isSetor?(saved.orphanLevelByGroup||{}):{},\n    foraDoSetorByGrupo:isSetor?(saved.foraDoSetorByGrupo||{}):{},\n  };\n  mv.reachable=saved.reachable||{};\n  if(saved.kind==='transfer'){\n    mv.kind='transfer'; mv.xferFrom=String(saved.xferFrom); mv.xferTo=String(saved.xferTo);\n    mv.xferMuni=saved.xferMuni; mv.xferMetrics=saved.xferMetrics||blankM(); mv.xferRows=saved.xferRows||[];\n  }\n  mv.color=assignDestColors(mv.dest);\n\n  const ufList=(saved.ufList&&saved.ufList.length)?saved.ufList:[mv.domUF].filter(Boolean);\n  const geos=await Promise.all(ufList.map(u=>geoUF(u).catch(()=>null)));\n  const adj=buildAdj(geos.filter(Boolean).flatMap(g=>g.features));\n  mv.geoFeat={}; (saved.closingKeys||[]).forEach(n=>{ mv.geoFeat[n]=adj.feat[n]&&adj.feat[n].f; });\n  mv.nbrFeat=(saved.nbrKeys||[]).map(n=>adj.feat[n]&&adj.feat[n].f).filter(Boolean);\n\n  const accC={}; Object.entries(mv.nbrCurrentSector).forEach(([nb,destCode])=>{ if(!mv.dest.includes(destCode)) return; const f=adj.feat[nb]&&adj.feat[nb].f; if(!f) return; const c=centroidLL(f); accC[destCode]=accC[destCode]||[0,0,0]; accC[destCode][0]+=c[0]; accC[destCode][1]+=c[1]; accC[destCode][2]++; });\n  mv.secCentroid={}; mv.dest.forEach(destCode=>{ if(accC[destCode]) mv.secCentroid[destCode]=[accC[destCode][0]/accC[destCode][2],accC[destCode][1]/accC[destCode][2]]; });\n\n  if(isSetor){\n    mv.muniCentroid={}; Object.keys(mv.geoFeat).forEach(n=>{ if(mv.geoFeat[n]) mv.muniCentroid[n]=centroidLL(mv.geoFeat[n]); });\n  } else {\n    mv.muniCentroid={};\n    Object.entries(mv.unitMunis).forEach(([u,ms])=>{\n      const cs=ms.map(n=>mv.geoFeat[n]&&centroidLL(mv.geoFeat[n])).filter(Boolean);\n      if(cs.length) mv.muniCentroid[u]=[cs.reduce((a,c)=>a+c[0],0)/cs.length,cs.reduce((a,c)=>a+c[1],0)/cs.length];\n    });\n  }\n\n  mv.sharedMuniSlices=[];\n  if(isSetor){\n    Object.entries(mv.muniSiblings).forEach(([n,siblings])=>{\n      const orig=mv.geoFeat[n]; if(!orig||!siblings.length) return;\n      // Stashed before mv.geoFeat[n] below gets overwritten with just this sector's own slice —\n      // drawMap's multi-owner outline (multiBorderFeats) wants the TRUE, single, un-cut polygon,\n      // exactly like computeMovement already stashes on a fresh compute. Missing here meant a\n      // município shared with the closing sector always got its hatch back (sharedMuniSlices,\n      // rebuilt above) on a LOADED session, but never its border — multiBorderFeats had nothing\n      // to draw from since mv.muniFullShape simply didn't exist on a hydrated mv.\n      mv.muniFullShape=mv.muniFullShape||{}; mv.muniFullShape[n]=orig;\n      const owners=[mv.sector,...siblings].sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      const slices=sliceFeatureEqual(orig,owners.length);\n      const mine=slices[owners.indexOf(mv.sector)];\n      if(mine&&!mine.__empty) mv.geoFeat[n]=mine;\n      const remParts=slices.filter((sl,idx)=>owners[idx]!==mv.sector&&sl&&!sl.__empty).flatMap(sl=>sl.geometry.coordinates);\n      if(!remParts.length) return;\n      const remainder={type:'Feature',properties:orig.properties,__uf:orig.__uf,geometry:{type:'MultiPolygon',coordinates:remParts}};\n      const valid=siblings.filter(s=>mv.sharedSiblingCodes.has(s)).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      sliceFeatureEqual(remainder,valid.length||1).forEach((sl,idx)=>{ if(sl.__empty) return; mv.sharedMuniSlices.push({feature:sl,sector:valid.length?valid[idx]:null,muni:n}); });\n    });\n  }\n\n  SESS.timeline[i]=mv;\n}\n\n// ---------- manual município transfer (rule-violation \"exception\" fix) ----------\n// A lightweight sibling of computeMovement: moves ONE município's CBs from an open sector straight\n// to another open sector, with neither dissolving — the one case ★ ÓTIMO's own redistribution can\n// never reach, because it only ever touches municípios that belong to whichever sector is CLOSING.\n// Metrics are snapshotted here and stored verbatim on the transfer record (recipe-replay style, same\n// as everything else a session persists) — no geometry captured: the map keeps drawing this\n// município from whatever context layer already loaded it, which degrades gracefully rather than\n// needing its own fetch.\nasync function computeTransfer(from,to,muniKey){\n  await ensurePinned();\n  const SNAP=SESS.snapLiteral;\n  // Mesma troca do mSQL (18/09): receita/pontos/pedidos saem do ELO no ciclo fechado, nao mais dos\n  // acumuladores *_CICLO do CRM. Esta query alimenta xferMetrics e corrige baseMetrics de origem e\n  // destino na etapa de transferencia - se ficasse no CRM, a transferencia somaria e subtrairia\n  // numeros de um periodo diferente do resto da matriz.\n  const sql=`SELECT t.DS_MUNICIPIO municipio, t.CD_SETOR setor, t.CD_GRUPO grupo, MAX(t.DS_LEADER_NAME) lider,\n     COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs, ROUND(SUM(e.revenue_sd_ss_amt),0) receita,\n     ROUND(SUM(e.revenue_nat_ss_amt),0) rec_nat, ROUND(SUM(e.revenue_avon_ss_amt),0) rec_avon,\n     ROUND(SUM(e.points_ss_amt),0) pontos, ROUND(SUM(e.orders_cnt),0) pedidos,\n     SUM(e.active_bc_ind) ativas\n    FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n    WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR=${from} AND t.DT_HISTORICO=${SNAP}\n    GROUP BY t.DS_MUNICIPIO, t.CD_SETOR, t.CD_GRUPO`;\n  const recs=rows(await q(sql));\n  const uf=mUf(muniKey);\n  const metrics=blankM();\n  const byGroup={};\n  recs.forEach(r=>{\n    const ru=uf||DOM.ufBySetorMuni[String(from)+'|'+muniN(r.municipio)]||'';\n    if(mkey(r.municipio,ru)!==muniKey) return;\n    metrics.cbs+=+r.cbs; metrics.receita+=+r.receita||0; metrics.rec_nat+=+r.rec_nat||0; metrics.rec_avon+=+r.rec_avon||0; metrics.pontos+=+r.pontos||0; metrics.pedidos+=+r.pedidos||0; metrics.ativas+=+r.ativas||0;\n    const g=String(r.grupo);\n    const row=byGroup[g]=byGroup[g]||{grupo:g,lider:r.lider,...blankM()};\n    row.cbs+=+r.cbs; row.receita+=+r.receita||0; row.rec_nat+=+r.rec_nat||0; row.rec_avon+=+r.rec_avon||0; row.pontos+=+r.pontos||0; row.pedidos+=+r.pedidos||0; row.ativas+=+r.ativas||0;\n  });\n  return {metrics, xferRows:Object.values(byGroup)};\n}\n// A transfer-movement stage never changes mv.munis/mv.assign structurally — the reassigned município\n// belongs to one of the PARENT stage's own destination sectors (mv.dest), never to mv.munis (only the\n// dissolving sector's own children live there — see suggestMuniMoves, which sources donor municípios\n// from DOM.bySetor[donor], donor∈mv.dest). What genuinely changes is baseMetrics[from]/baseMetrics[to]\n// — the same two fields computeMovement's own tail already patches from world.sectorInflow/\n// sectorOutflow for a LATER stage. computeDerived(mv) picks the new spans up automatically at render\n// time from baseMetrics, exactly like every other render already re-derives everything from mv fresh.\nasync function computeTransferMovement(i){\n  const t=SESS.timeline[i];\n  const prev=SESS.timeline[i-1]; // i is never 0 — applyTransfer always inserts after an existing stage\n  const mv={...prev};\n  mv.kind='transfer'; mv.xferFrom=t.xferFrom; mv.xferTo=t.xferTo; mv.xferMuni=t.xferMuni;\n  // Own copy — NOT because this function writes to it, but because editSelected's map-click/segmented-\n  // button mutators write into mv.assign IN PLACE (m2.assign[m]=s), and sharing the object with prev\n  // would let an edit made while viewing THIS stage silently corrupt the parent stage's own assignment.\n  mv.assign={...prev.assign};\n  mv.baseMetrics={...prev.baseMetrics};\n  const {metrics,xferRows}=await computeTransfer(t.xferFrom,t.xferTo,t.xferMuni); // live re-query — from's numbers\n  mv.xferMetrics=metrics; mv.xferRows=xferRows;                         // may have shifted upstream\n  const patchBase=(code,sign)=>{\n    const cur=mv.baseMetrics[code]||{setor:code,cbs:0,ativas:0,receita:0,rec_nat:0,rec_avon:0,pontos:0,pedidos:0};\n    const next={...cur};\n    Object.keys(blankM()).forEach(k=>{ next[k]=Math.max(0,(+next[k]||0)+sign*(metrics[k]||0)); });\n    mv.baseMetrics[code]=next;\n  };\n  patchBase(t.xferTo,+1);\n  patchBase(t.xferFrom,-1);\n  if(mv.nbrCurrentSector && (t.xferMuni in mv.nbrCurrentSector)){\n    mv.nbrCurrentSector={...mv.nbrCurrentSector, [t.xferMuni]:t.xferTo};\n  }\n  SESS.timeline[i]=mv;\n}\n// Pins a new transfer-movement stage right after the etapa it was applied against — see\n// computeTransferMovement, which does the actual clone+patch work through the normal computeAt\n// dispatch/dirty-cascade, the same path any other edit already uses.\nasync function applyTransfer(from,to,muniKey){\n  if(guardLocked()) return;\n  const parent=SESS.timeline[SESS.selected]; if(!parent) return;\n  const insertAt=SESS.selected+1;\n  SESS.timeline.splice(insertAt,0,{sector:parent.sector, level:parent.level||'setor', kind:'transfer', xferFrom:String(from), xferTo:String(to), xferMuni:muniKey});\n  SESS.dirtyFrom = SESS.dirtyFrom==null ? insertAt : Math.min(SESS.dirtyFrom, insertAt);\n  SESS.selected=insertAt;\n}\n// Retargets an ALREADY-inserted transfer stage in place (never inserts a new one — that's\n// applyTransfer's job). Dirty-marked at the stage's OWN index, not +1 like editSelected: a\n// transfer stage's baseMetrics/xferMetrics/xferRows/nbrCurrentSector are precomputed snapshots\n// (unlike mv.assign, which every render re-derives live), so the stage itself must be recomputed —\n// computeAt's existing kind==='transfer' branch already does that correctly via computeTransferMovement.\nasync function setTransferTarget(code){\n  if(guardLocked()) return;\n  const mv=SESS.timeline[SESS.selected];\n  if(!mv||mv.kind!=='transfer'||!mv.dest||!mv.dest.includes(code)||mv.xferTo===code) return;\n  mv.xferTo=code;\n  SESS.dirtyFrom = SESS.dirtyFrom==null ? SESS.selected : Math.min(SESS.dirtyFrom, SESS.selected);\n  await flushDirty();\n  renderAll();\n}\n// Plain wraparound through mv.dest, deliberately including xferFrom as a natural \"revert\" stop —\n// mirrors how the normal município click-cycle (drawMap) never excludes the current assignment\n// either. No contiguity guard: nextContigSafeDest reasons about the CLOSING sector's own dissolving\n// territory staying connected, which has no meaning for a transfer between two already-open sectors.\nasync function cycleTransferTarget(){\n  const mv=SESS.timeline[SESS.selected];\n  if(!mv||mv.kind!=='transfer'||!mv.dest||!mv.dest.length) return;\n  await setTransferTarget(mv.dest[(mv.dest.indexOf(mv.xferTo)+1)%mv.dest.length]);\n}\n// Geo adjacency for an OPEN sector's own territory — cached by UF-set key, since the same pair of\n// sectors tends to get re-checked across several fix cards in one session.\nconst OPEN_SECTOR_ADJ_CACHE={};\nasync function openSectorAdj(ufSet){\n  const key=[...ufSet].sort().join(',');\n  if(OPEN_SECTOR_ADJ_CACHE[key]) return OPEN_SECTOR_ADJ_CACHE[key];\n  const geos=await Promise.all([...ufSet].map(u=>geoUF(u).catch(()=>null)));\n  const adj=buildAdj(geos.filter(Boolean).flatMap(g=>g.features));\n  OPEN_SECTOR_ADJ_CACHE[key]=adj;\n  return adj;\n}\n// Candidate município moves for the span-rule fix card — scoped to the ONE violation the clicked\n// button represents (`code`), tested against mv's own REAL, current span map (sp), never a\n// hypothetical \"what if a different sector closes\" preview. Only ever offered as a MINIMAL plan —\n// at most 2 municípios — because reallocating an established sector's territory is a manual\n// calibration a person has to eyeball on the map; a plan that shuffles four+ municípios around to\n// plug one gap reads as arbitrary busywork, not as something anyone would actually click. If\n// nothing that small covers the whole gap while staying geometrically safe, no plan is offered at\n// all — the \"fechar\" card right next to it (closeWork) is the fallback for that case.\n// Two geometric guards, not one: a candidate must not be the only thing holding the DONOR's\n// remaining territory together (as before), AND it must not leave the RECEIVER's resulting\n// territory in disconnected pieces either — a município that borders neither the donor's rest nor\n// the receiver's existing footprint would dangle as its own island wherever it lands.\nasync function suggestMuniMoves(mv,sp,lim,code,isOver){\n  if((mv.level||'setor')!=='setor') return null;\n  const others=mv.dest.filter(d=>d!==code);\n  if(!others.length) return null;\n  const gapNeeded=isOver?(lim.max?Math.max(0,sp[code]-lim.max):0):(lim.min?Math.max(0,lim.min-sp[code]):0);\n  if(!gapNeeded) return null;\n  const donorPool=isOver?[code]:others;\n  const receiverPool=isOver?others:[code];\n\n  // Single connected-component test, shared by both guards below: a donor's REMAINING territory\n  // after losing its combo members, and a receiver's RESULTING territory after gaining them. One\n  // node is trivially connected — nothing left to disconnect it from.\n  const connected=(nodes,adj)=>{\n    const arr=[...nodes]; if(arr.length<=1) return true;\n    const set=new Set(arr);\n    const seen=new Set([arr[0]]),st=[arr[0]];\n    while(st.length){ const cur=st.pop(); adj.nbrs(cur).forEach(({o})=>{ if(set.has(o)&&!seen.has(o)){ seen.add(o); st.push(o); } }); }\n    return seen.size===arr.length;\n  };\n\n  const ownByDonor={},adjByDonor={};\n  let pool=[];\n  for(const dn of donorPool){\n    const ownMunis=DOM.bySetor[dn]||new Set();\n    if(ownMunis.size<2) continue;   // nothing left to give away without emptying the sector\n    const sql=`SELECT t.DS_MUNICIPIO municipio, t.DS_ESTADO uf, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1 WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR=${dn} AND t.DT_HISTORICO=${SESS.snapLiteral} GROUP BY t.DS_MUNICIPIO, t.DS_ESTADO ORDER BY cbs ASC`;\n    let recs; try{ recs=rows(await q(sql)); }catch(e){ continue; }\n    const cands=[];\n    recs.forEach(r=>{\n      const ru=r.uf||DOM.ufBySetorMuni[String(dn)+'|'+muniN(r.municipio)]||'';\n      const k=mkey(r.municipio,ru);\n      if(ownMunis.has(k)&&+r.cbs>0) cands.push({muni:k,cbs:+r.cbs,donor:dn});\n    });\n    if(!cands.length) continue;\n    const ufSet=new Set();\n    ownMunis.forEach(m=>{ const u=mUf(m); if(u) ufSet.add(u); });\n    mv.dest.forEach(d=>(DOM.bySetor[d]||new Set()).forEach(m=>{ const u=mUf(m); if(u) ufSet.add(u); }));\n    ufSet.forEach(u=>(UF_BORDERS[u]||[]).forEach(b=>ufSet.add(b)));\n    let adj; try{ adj=await openSectorAdj(ufSet); }catch(e){ continue; }\n    ownByDonor[dn]=ownMunis; adjByDonor[dn]=adj;\n    pool=pool.concat(cands.filter(c=>adj.feat[c.muni]));\n  }\n  if(!pool.length) return null;\n\n  // Receivers' CURRENT (pre-plan) territory — the baseline every geometry check below grows on top\n  // of, same for a 1- or a 2-município combo.\n  const recvBase={}; receiverPool.forEach(rc=>{ recvBase[rc]=DOM.bySetor[rc]||new Set(); });\n\n  // Does this combo (1 or 2 candidates, each already carrying its chosen receiver in `.rc`) hold up\n  // geometrically? Donor side: group by donor, remove the combo's members, whatever is left must\n  // stay one region. Receiver side: group by receiver, add the combo's members to its existing\n  // territory, old + new must stay one region too.\n  // Receiver-side guard is deliberately NOT a full re-verification that the WHOLE existing\n  // territory (often dozens of towns) is itself one connected blob in our loaded shapes — that's a\n  // pre-existing fact this fix doesn't own, and a real sector routinely has a stray missing shape or\n  // CSV alias gap somewhere far from anything being added here (see the UF/alias handling elsewhere\n  // in this file). It only asks the LOCAL question that actually matters: does the incoming piece\n  // touch the receiver's existing footprint — directly, or via its sibling in this same plan?\n  const attaches=(newMunis,existing,adj)=>{\n    const touches=m=>[...adj.nbrs(m)].some(({o})=>existing.has(o));\n    const rest=new Set(newMunis);\n    const linked=new Set([...rest].filter(touches));\n    if(!linked.size) return false;\n    let grew=true;\n    while(grew){\n      grew=false;\n      for(const m of rest){\n        if(linked.has(m)) continue;\n        if([...adj.nbrs(m)].some(({o})=>linked.has(o))){ linked.add(m); grew=true; }\n      }\n    }\n    return linked.size===rest.size;\n  };\n  const geomOk=combo=>{\n    const byDonor={}; combo.forEach(c=>(byDonor[c.donor]=byDonor[c.donor]||[]).push(c.muni));\n    for(const dn in byDonor){\n      const remaining=[...ownByDonor[dn]].filter(m=>!byDonor[dn].includes(m));\n      if(!remaining.length||!connected(remaining,adjByDonor[dn])) return false;\n    }\n    const byRecv={}; combo.forEach(c=>(byRecv[c.rc]=byRecv[c.rc]||[]).push(c.muni));\n    for(const rc in byRecv){\n      const adj=adjByDonor[combo.find(c=>c.rc===rc).donor];\n      if(!attaches(byRecv[rc],recvBase[rc],adj)) return false;\n    }\n    return true;\n  };\n  const roomOk=combo=>{\n    const used={}; combo.forEach(c=>{ used[c.rc]=(used[c.rc]||0)+c.cbs; });\n    return Object.entries(used).every(([rc,cbs])=>cbs<=(lim.max?lim.max:Infinity)-sp[rc]);\n  };\n  const planFrom=combo=>{\n    const spNow={...sp};\n    combo.forEach(c=>{ spNow[c.donor]-=c.cbs; spNow[c.rc]+=c.cbs; });\n    return {moves:combo.map(c=>({from:c.donor,to:c.rc,muni:c.muni,cbs:c.cbs})), resolved:true, spNow};\n  };\n\n  // 1 município: every (candidate × receiver) pairing that alone covers the whole gap. Smallest\n  // cbs wins — the least overshoot, the least disruptive single move.\n  let best1=null;\n  for(const c of pool){\n    if(c.cbs<gapNeeded) continue;\n    for(const rc of receiverPool){\n      if(rc===c.donor) continue;\n      const combo=[{...c,rc}];\n      if(!roomOk(combo)) continue;\n      if(!geomOk(combo)){ continue; }\n      if(!best1||c.cbs<best1[0].cbs) best1=combo;\n    }\n  }\n  if(best1) return planFrom(best1);\n\n  // 2 municípios: cheapest combined-cbs pair (any donor/receiver split) that together cover the\n  // gap — the plan someone would actually pick over closing a whole sector.\n  let best2=null,best2Cost=Infinity;\n  for(let i=0;i<pool.length;i++){\n    for(let j=i+1;j<pool.length;j++){\n      const c1=pool[i],c2=pool[j];\n      if(c1.muni===c2.muni) continue;\n      const total=c1.cbs+c2.cbs;\n      if(total<gapNeeded||total>=best2Cost) continue;\n      for(const rc1 of receiverPool){\n        if(rc1===c1.donor) continue;\n        for(const rc2 of receiverPool){\n          if(rc2===c2.donor) continue;\n          const combo=[{...c1,rc:rc1},{...c2,rc:rc2}];\n          if(!roomOk(combo)) continue;\n          if(!geomOk(combo)){ continue; }\n          best2=combo; best2Cost=total;\n        }\n      }\n    }\n  }\n  if(best2) return planFrom(best2);\n\n  return {moves:null, resolved:false};\n}\n\n// ---------- cascade engine ----------\nasync function computeAt(i){\n  await ensurePinned();\n  const cur=SESS.timeline[i];\n  if(cur && cur.kind==='transfer'){ await computeTransferMovement(i); return; }\n  const world=foldWorldState(SESS.timeline,i);\n  const sec=SESS.timeline[i].sector;\n  const level=SESS.timeline[i].level||'setor';\n  const mv=await computeMovement(sec,level,world);\n  SESS.timeline[i]=mv;\n}\nasync function recomputeFrom(i){\n  for(let k=i;k<SESS.timeline.length;k++){\n    loader(true,`recalculando etapa ${k+1} de ${SESS.timeline.length}…`);\n    try{ await computeAt(k); }\n    catch(e){\n      // A stage that failed to recompute still holds its PREVIOUS result, so the timeline would\n      // silently mix pre- and post-change etapas. Flag this stage and everything after it as stale\n      // (dashed chip + ⟳) so the state is visible and flushDirty retries from here.\n      SESS.dirtyFrom = SESS.dirtyFrom==null ? k : Math.min(SESS.dirtyFrom,k);\n      throw e;\n    }\n  }\n}\nasync function flushDirty(){\n  if(SESS.dirtyFrom==null) return;\n  const from=SESS.dirtyFrom; SESS.dirtyFrom=null;\n  loader(true,'recalculando etapas seguintes…');\n  try{ await recomputeFrom(from); }\n  catch(e){ $('#ds').textContent='erro ao recalcular cadeia: '+e.message; $('#ds').classList.add('err'); }\n  finally{ loader(false); }\n}\nasync function addMovement(level,sec){\n  if(guardLocked()) return;\n  level=level||'setor';\n  sec=(sec==null?'':String(sec)).trim();\n  if(!sec) return;\n  if(!movementAvailable(level)){ $('#ds').textContent=`Fechar ${LEVELS[level].label} ainda não está disponível.`; $('#ds').classList.add('err'); return; }\n  if(!hierarchyAllows(level)){ const minLabel=LEVELS[Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===sessionMinRank())].label; $('#ds').textContent=`esta sessão já fechou um nível mais alto — só \"${minLabel}\" (ou acima) fica disponível.`; $('#ds').classList.add('err'); return; }\n  const closedNow=new Set(SESS.timeline.filter(mv=>mv.kind!=='transfer' && (mv.level||'setor')===level).map(mv=>mv.sector));\n  if(closedNow.has(sec)){ $('#ds').textContent=LEVELS[level].label+' '+sec+' já fechado(a) nesta timeline.'; $('#ds').classList.add('err'); return; }\n  if(level==='setor' && !SESS.sectorsList.some(s=>s.setor===sec)){ $('#ds').textContent='setor \"'+sec+'\" não existe no snapshot congelado.'; $('#ds').classList.add('err'); return; }\n  await flushDirty();\n  loader(true,`fechando ${lc(LEVELS[level].label)} `+sec+' — puxando dado do snapshot congelado…');\n  $('#liveState').classList.remove('err');\n  try{\n    SESS.timeline.push({sector:String(sec),level:level});\n    await computeAt(SESS.timeline.length-1);\n    PULL_SET=new WeakSet();\n    SESS.selected=SESS.timeline.length-1;\n    $('#ds').classList.remove('err'); $('#ds').textContent='';\n  }catch(e){\n    SESS.timeline.pop();\n    $('#ds').textContent=`erro ao fechar ${lc(LEVELS[level].label)} `+sec+': '+e.message; $('#ds').classList.add('err');\n  } finally { loader(false); renderAll(); }\n}\nfunction removeFrom(i){\n  if(guardLocked()) return;\n  SESS.timeline.length=i;\n  PULL_SET=new WeakSet();\n  SESS.selected=Math.min(SESS.selected,Math.max(0,SESS.timeline.length-1));\n  if(SESS.dirtyFrom!=null && SESS.dirtyFrom>=SESS.timeline.length) SESS.dirtyFrom=null;\n  renderAll();\n}\nasync function editSelected(mutator){\n  if(guardLocked()) return;\n  const mv=SESS.timeline[SESS.selected]; if(!mv) return;\n  mutator(mv);\n  FIX_CACHE.get(mv)?.clear();\n  if(SESS.selected<SESS.timeline.length-1){\n    SESS.dirtyFrom = SESS.dirtyFrom==null ? SESS.selected+1 : Math.min(SESS.dirtyFrom, SESS.selected+1);\n  }\n  renderAll();\n}\n\n// ---------- sectors list / picker ----------\n// CICLO (19/09): esta query reimplementava INLINE o penultimo ciclo global do ELO — a mesma regra\n// que queryEloCycle() abandonou, porque um ciclo pode ter algumas linhas processadas sem estar\n// fechado. Dava 202616, que tem 72 pessoas no país inteiro: o JOIN não casava e o picker mostrava\n// 0 CBs em TODO setor (medido: 617 setores zerados; com a regra certa, 1.592.074 CBs e só 2 zerados).\n// Corrigir queryEloCycle() não consertava esta — eram duas cópias da mesma regra velha.\n// Por que a expressão vai EMBUTIDA em vez de usar SESS.eloMaxCycle: loadSectors roda no init, dentro\n// do Promise.all, ANTES de o operador escolher o ciclo no card de confirmação — interpolar\n// SESS.eloMaxCycle aqui produziria `operational_cycle=null`, que nunca casa em SQL, e devolveria o\n// mesmo zero de antes por outro caminho. Embutida, ela concorda com queryEloCycle() por construção:\n// é a MESMA regra (maior ciclo do ELO anterior ao CD_CICLO_ATUAL da maioria das CBs no snapshot).\n// O snapshot também passou a ser o mesmo COALESCE do resolveSnapshotPin, no lugar do MAX ao vivo.\nasync function loadSectors(){\n  const SNAP_SUBQ=`COALESCE((SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1 AND DT_HISTORICO<(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1)),(SELECT MAX(DT_HISTORICO) FROM ${T} WHERE CD_PAIS=1))`;\n  const CICLO_FECHADO_SUBQ=`(SELECT MAX(c.operational_cycle) FROM ${ELO} c WHERE c.country_code=1 AND c.operational_cycle<(SELECT m.CD_CICLO_ATUAL FROM ${T} m WHERE m.CD_PAIS=1 AND m.CD_SETOR>0 AND m.CD_GRUPO>0 AND m.DT_HISTORICO=${SNAP_SUBQ} GROUP BY m.CD_CICLO_ATUAL ORDER BY COUNT(*) DESC LIMIT 1))`;\n  const sql=`SELECT t.CD_SETOR setor, MAX(t.DS_ESTADO) uf, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs, COUNT(DISTINCT t.DS_MUNICIPIO) munis\n    FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${CICLO_FECHADO_SUBQ} AND e.country_code=1\n    WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR>0 AND t.DT_HISTORICO=${SNAP_SUBQ} GROUP BY t.CD_SETOR ORDER BY t.CD_SETOR`;\n  SESS.sectorsList=rows(await q(sql)).map(r=>({setor:String(r.setor),uf:r.uf,cbs:+r.cbs,munis:+r.munis}));\n}\nfunction renderSectorPicker(){\n  const ready=SESS.stage==='main';\n  const ob=$('#openMove'); if(ob) ob.disabled=!ready;\n}\n\n// ---------- movement modal (setor / GV / mercado) ----------\n// Hierarquia mercado > GV > setor: a sessão trava no nível da primeira movimentação. Misturar\n// níveis quebraria a leitura de \"antes/depois\" da cadeia — fechar uma GV já redistribui setores\n// inteiros pra outras GVs; fechar um setor isolado depois não bate mais com o mundo que sobrou.\n// de/a/no: contrações de \"de\"/\"a\"/\"em\" com o artigo de cada nível — GV é feminina (a Gerência de\n// Vendas), setor e mercado são masculinos. Errar o gênero aqui é o tipo de coisa que grita \"isto foi\n// feito pra setor e só remendado pros outros níveis\", exatamente o que este objeto existe pra evitar.\nconst LEVELS={\n  setor:{label:'Setor',codeLbl:'Setor a fechar',placeholder:'código do setor',childLabel:'Município',destLabel:'Setor',de:'do',a:'ao',no:'no',parentLabel:'GV',parentPhrase:'da mesma GV',parentWord:'gerência'},\n  gv:{label:'GV',codeLbl:'GV a fechar',placeholder:'código da GV',childLabel:'Setor',destLabel:'GV',de:'da',a:'à',no:'na',parentLabel:'Mercado',parentPhrase:'do mesmo mercado',parentWord:'mercado'},\n  mercado:{label:'Mercado',codeLbl:'Mercado a fechar',placeholder:'nome do mercado',childLabel:'GV',destLabel:'Mercado',de:'do',a:'ao',no:'no',parentLabel:null,parentPhrase:null,parentWord:null},\n};\n// GV é sigla — .toLowerCase() a transformaria em \"gv\", errado em qualquer posição da frase. Palavras\n// normais (Setor/Mercado/Município) minúsculam à vontade no meio de uma frase; GV nunca.\nconst LOWER={Setor:'setor',Mercado:'mercado',Município:'município',GV:'GV'};\nconst lc=w=>LOWER[w]||w.toLowerCase();\n// Hierarquia é uma catraca, não um cadeado: mercado(0) > GV(1) > setor(2). Fechar um nível mais\n// alto (número menor) trava PRA SEMPRE nesta sessão qualquer nível mais baixo (número maior) —\n// mas o próprio nível, ou um ainda mais alto, continua liberado. É assim que \"posso fechar setores\n// até fechar uma GV; posso fechar GVs até fechar um mercado\" funciona: nunca destrava de volta.\nconst LEVEL_RANK={mercado:0,gv:1,setor:2};\nfunction sessionMinRank(){\n  let r=LEVEL_RANK.setor;\n  SESS.timeline.forEach(mv=>{ const rk=LEVEL_RANK[mv.level||'setor']; if(rk<r) r=rk; });\n  return r;\n}\nfunction hierarchyAllows(lvl){ return LEVEL_RANK[lvl]<=sessionMinRank(); }\nfunction movementAvailable(lvl){ return lvl==='setor'||lvl==='gv'||lvl==='mercado'; }\n// ---------- hierarquia: setor ⊂ GV ⊂ mercado — tudo derivado da base de dominantes (DOM) ----------\n// memberSectors: o escopo de CD_SETOR que a query no CRM precisa cobrir pra fechar este código, em\n// qualquer nível — um setor é ele mesmo; uma GV é todo setor que a base lhe atribui; um mercado é\n// todo setor de toda GV que a base atribui a ele.\n// membros ATUAIS de um GV/mercado, um nível abaixo (setores de uma GV; GVs de um mercado) — nunca\n// só a foto estática do CSV: uma etapa anterior NESTA MESMA sessão pode já ter movido um filho pra\n// dentro ou pra fora deste código, e um fechamento seguinte tem de enxergar o mundo como ele está\n// agora, não como o CSV descreveu antes de qualquer movimentação.\nfunction currentMembersOf(level,code,world){\n  code=String(code);\n  // \"gv\": setores da GV (DOM.sectorsByGv). \"mercado\": GVs do mercado (DOM.gvsByMercado) — NUNCA\n  // sectorsByMercado, que pula a camada de GV inteira e faz memberSectorsOf tratar códigos de\n  // setor como se fossem códigos de GV (não batem em DOM.sectorsByGv, e o fechamento morre com\n  // \"não existe na base\" mesmo quando o mercado está lá).\n  const staticMembers=level==='gv'?(DOM.sectorsByGv[code]||new Set()):(DOM.gvsByMercado[code]||new Set());\n  const overrideMap=level==='gv'?(world&&world.secOverride):(world&&world.gvOverride);\n  const out=new Set(staticMembers);\n  if(overrideMap){\n    Object.entries(overrideMap).forEach(([child,dest])=>{\n      if(dest===code) out.add(child);\n      else if(staticMembers.has(child)) out.delete(child);\n    });\n  }\n  return [...out];\n}\n// setores que a query no CRM precisa cobrir pra fechar este código, em qualquer nível — setor é\n// ele mesmo; GV é seus setores ATUAIS; mercado é todo setor de toda GV ATUALMENTE sua (setor não\n// troca de GV sozinho numa sessão de mercado — só GV inteira troca de mercado).\nfunction memberSectorsOf(level,code,world){\n  code=String(code);\n  if(level==='setor') return [code];\n  if(level==='gv') return currentMembersOf('gv',code,world);\n  const gvs=currentMembersOf('mercado',code,world);\n  const out=new Set();\n  gvs.forEach(gv=>(DOM.sectorsByGv[gv]||new Set()).forEach(s=>out.add(s)));\n  return [...out];\n}\n// fronteira que um destino tem de respeitar: GV do setor que fecha, mercado da GV que fecha —\n// mercado não tem nada acima, então não existe regra pra ele. Estática de propósito: um setor não\n// troca de GV nem uma GV troca de mercado só por existirem etapas anteriores nesta sessão — quem\n// muda de dono é o NÍVEL ABAIXO do que está fechando, nunca o próprio código sendo avaliado aqui.\nfunction parentOf(level,code){\n  code=String(code);\n  if(level==='setor') return DOM.gvBySetor[code]||'';\n  if(level==='gv') return DOM.mercadoByGv[code]||'';\n  return '';\n}\n// dado o setor que uma município vizinha atualmente pertence (fato ao vivo do CRM, nunca muda de\n// verdade), a que CANDIDATO A DESTINO isso corresponde neste nível — considerando que uma etapa\n// anterior desta sessão pode já ter movido esse setor (GV fechando) ou essa GV (mercado fechando)\n// pra outro dono, o que a foto estática do CSV sozinha não enxergaria.\nfunction destUnitOfSector(level,setorCode,world){\n  // setorCode is undefined when the live neighbour query found no row for that município (e.g. zero\n  // CBs in this snapshot) — String(undefined) would silently turn that into the literal text\n  // \"undefined\" and the caller would treat it as a real (bogus) sector: shown as a fake rejected\n  // sector on the map, tooltip and all. Bail out to '' — no resolvable sector — instead.\n  if(setorCode==null||setorCode==='') return '';\n  setorCode=String(setorCode);\n  if(level==='setor') return setorCode;\n  if(level==='gv'){ const ov=world&&world.secOverride&&world.secOverride[setorCode]; return ov||DOM.gvBySetor[setorCode]||''; }\n  const gv=DOM.gvBySetor[setorCode]||'';\n  const ov=world&&world.gvOverride&&world.gvOverride[gv];\n  return ov||DOM.mercadoBySetor[setorCode]||'';\n}\nfunction currentMoveLevel(){ const r=root.querySelector('input[name=\"moveLevel\"]:checked'); return (r&&r.value)||'setor'; }\nfunction openMove(){\n  const minRank=sessionMinRank();\n  const lastLevel=SESS.timeline.length?(SESS.timeline[SESS.timeline.length-1].level||'setor'):'setor';\n  root.querySelectorAll('input[name=\"moveLevel\"]').forEach(r=>{\n    const lvl=r.value;\n    r.disabled=!movementAvailable(lvl)||!hierarchyAllows(lvl);\n    r.checked=hierarchyAllows(lastLevel)?lvl===lastLevel:lvl===Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===minRank);\n  });\n  const note=$('#moveLockNote');\n  const minLabel=LEVELS[Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===minRank)].label;\n  if(note) note.textContent=minRank<LEVEL_RANK.setor\n    ? `Esta sessão já fechou um nível mais alto na hierarquia — só \"${minLabel}\" (ou acima) continua disponível daqui pra frente.`\n    : 'Escolha o nível a fechar. Você pode fechar setores à vontade até fechar uma GV; aí só GV (ou mercado) daqui pra frente. Fechar um mercado trava a sessão em mercado.';\n  $('#moveCode').value='';\n  const err=$('#moveErr'); if(err){ err.textContent=''; err.classList.add('hidden'); }\n  const st=$('#moveStatus'); if(st) st.textContent='';\n  $('#moveBg').classList.remove('hidden');\n  renderMoveForm();\n  const c=$('#moveCancel'); if(c) c.focus();\n}\nfunction closeMove(){ const b=$('#moveBg'); if(b) b.classList.add('hidden');\n  // #moveCode's dropdown panel lives in #macPortal, appended straight to document.body — a\n  // separate DOM subtree from this modal, so hiding #moveBg alone never touches it. It normally\n  // closes on a document-level click (ensureMacGlobalClose), but submitMove() via Enter never\n  // fires one, so the panel was left floating on screen after the modal itself had already closed.\n  closeAllMacSelects();\n}\nfunction renderMoveForm(){\n  root.querySelectorAll('#moveBg .opt-row').forEach(r=>r.classList.toggle('on',r.dataset.lvl===currentMoveLevel()));\n  const lvl=LEVELS[currentMoveLevel()];\n  $('#moveCodeLbl').textContent=lvl.codeLbl;\n  $('#moveCode').placeholder=lvl.placeholder;\n  loadMoveOptions();\n}\nasync function loadMoveOptions(){\n  const lvl=currentMoveLevel();\n  const dl=$('#moveCodeList'); if(!dl) return;\n  const closedSet=new Set(SESS.timeline.filter(mv=>mv.kind!=='transfer' && (mv.level||'setor')===lvl).map(mv=>mv.sector));\n  dl.innerHTML='';\n  // Marks the window during which the datalist is empty because it's still loading, not because\n  // there are genuinely no options — initMacCombo's panel reads this to show \"carregando…\" instead\n  // of misreporting \"nenhum resultado\" while ensureDominantes() (the CSV fetch, can be slow cold) is\n  // still in flight.\n  dl.dataset.loading='1';\n  const inp=$('#moveCode'); if(inp&&inp._macRefresh) inp._macRefresh();\n  try{\n    try{ await ensureDominantes(); }catch(e){ return; }\n    if(lvl==='setor'){\n      // Setor picker reads STRICTLY from the dominantes CSV (DOM.bySetor) now, never from a live CRM\n      // query — código/UF/município count are all derivable from the same base already loaded for the\n      // hierarchy rules, so this list shouldn't cost a Databricks round trip on top of it. CB count is\n      // dropped along with it: it only ever came from that same CRM query.\n      const codes=Object.keys(DOM.bySetor||{}).filter(s=>!closedSet.has(s)).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      dl.innerHTML=codes.map(s=>{\n        const ms=DOM.bySetor[s]||new Set();\n        const ufCount={};\n        ms.forEach(m=>{ const u=mUf(m); if(u) ufCount[u]=(ufCount[u]||0)+1; });\n        const uf=Object.keys(ufCount).sort((a,b)=>ufCount[b]-ufCount[a])[0];\n        return `<option value=\"${s}\">${s}${uf?' · '+uf:''} · ${ms.size} munic.</option>`;\n      }).join('');\n    } else if(lvl==='gv'){\n      const gvs=Object.keys(DOM.sectorsByGv||{}).filter(g=>!closedSet.has(g)).sort();\n      dl.innerHTML=gvs.map(g=>`<option value=\"${g}\">${g}${DOM.gvLabel[g]?' · '+esc(DOM.gvLabel[g]):''} · ${DOM.sectorsByGv[g].size} setor(es)</option>`).join('');\n    } else if(lvl==='mercado'){\n      const mks=Object.keys(DOM.sectorsByMercado||{}).filter(m=>!closedSet.has(m)).sort();\n      dl.innerHTML=mks.map(m=>`<option value=\"${m}\">${m} · ${(DOM.gvsByMercado[m]||new Set()).size} GV(s) · ${DOM.sectorsByMercado[m].size} setor(es)</option>`).join('');\n    }\n  } finally {\n    dl.dataset.loading='0';\n    if(inp&&inp._macRefresh) inp._macRefresh();\n  }\n}\nfunction moveRandPick(){\n  const opts=[...($('#moveCodeList').options||[])].map(o=>o.value);\n  if(!opts.length) return;\n  $('#moveCode').value=opts[Math.floor(Math.random()*opts.length)];\n}\nasync function submitMove(){\n  const lvl=currentMoveLevel();\n  const code=($('#moveCode').value||'').trim();\n  const err=$('#moveErr');\n  const fail=msg=>{ if(err){ err.textContent=msg; err.classList.remove('hidden'); } };\n  if(err){ err.textContent=''; err.classList.add('hidden'); }\n  if(!movementAvailable(lvl)) return fail(`Fechar ${LEVELS[lvl].label} ainda não está disponível.`);\n  if(!hierarchyAllows(lvl)){ const minLabel=LEVELS[Object.keys(LEVEL_RANK).find(k=>LEVEL_RANK[k]===sessionMinRank())].label; return fail(`Esta sessão já fechou um nível mais alto — só \"${minLabel}\" (ou acima) fica disponível.`); }\n  if(!code) return fail('Informe um código.');\n  closeMove();\n  await addMovement(lvl,code);\n}\nfunction renderStage(){\n  const isMain=SESS.stage==='main';\n  const cc=$('#confirmCard'), mw=$('#mainWrap');\n  const hdr=$('#topHeader'), rl=$('#timelineRail');\n  if(hdr) hdr.classList.toggle('hidden', !isMain);\n  if(rl) rl.classList.toggle('hidden', !isMain);\n  if(cc) cc.classList.toggle('hidden', isMain);\n  if(mw) mw.classList.toggle('hidden', !isMain || !SESS.timeline.length);\n  const cb=$('#confirmBody');\n  if(cb && !isMain) cb.innerHTML = SESS.snapshotRaw\n    ? (SESS.snapshotPinnedDate\n        ? `📌 Snapshot histórico congelado · fixado em <b>${fmtSyncedAt(SESS.snapshotRaw)}</b> · <b>${SESS.sectorsList.length}</b> setor(es) disponível(eis) pra fechar. Nenhuma consulta de detalhe roda até você confirmar e escolher o primeiro setor.`\n        : `Snapshot congelado · atualizado em <b>${fmtSyncedAt(SESS.snapshotSyncedAt)}</b> · <b>${SESS.sectorsList.length}</b> setor(es) disponível(eis) pra fechar. Nenhuma consulta de detalhe roda até você confirmar e escolher o primeiro setor.`)\n    : 'escolha um ciclo operacional abaixo para congelar o snapshot desta sessão.';\n  const ecs=$('#eloCycleStatus');\n  if(ecs) ecs.textContent = SESS.eloMaxCycle ? `ciclo escolhido: ${SESS.eloMaxCycle}` : 'nenhum ciclo escolhido ainda — obrigatório antes de confirmar.';\n  const csBtn=$('#confirmSnap');\n  if(csBtn) csBtn.disabled = !SESS.eloMaxCycle;\n}\n\n// ---------- session persistence ----------\n// SESSIONS_DS (`geo-timeline-sessions`) and SHARED_DS (`timeline-shared-sessions`) are now TWO\n// SEPARATE tables — they used to be the same DataSet wearing two row shapes, but that let a\n// write-once shared snapshot show up next to editable working sessions in the same store:\n//   · working session  — carries `movements`; lives in SESSIONS_DS; saveSession updates it in place\n//   · shared snapshot  — carries `sharedAt` + `stages`; lives in SHARED_DS; doShare CREATES it and\n//     nothing ever updates it\n// listSessions still excludes any row carrying `sharedAt` as a defensive guard (belt-and-braces\n// now that the tables are split, load-bearing back when they were one).\n// Both lookups (`d2.data('sessionsDataset')||d2.data(SESSIONS_DS)`) stay because a policy-gated\n// DataSet's Process resolves late; same race the reader component polls for. SESSIONS_DS lives at\n// module scope so no call site can hit it in the TDZ.\nasync function listSessions(){\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS)); if(!ds) return [];\n  await ds.refresh();\n  const recs=ds.records()||[];\n  return [...recs].filter(r=>!r.sharedAt).sort((a,b)=>new Date(b.updatedAt||0)-new Date(a.updatedAt||0));\n}\nasync function refreshSessionPicker(){\n  const list=await listSessions().catch(()=>[]);\n  SESS.sessionsList=list;\n  const opts=list.map(s=>`<option value=\"${s._id}\">${esc(s.label||'sem nome')} · ${fmt((s.movements||[]).length)} etapa(s)</option>`).join('')+`<option value=\"\">+ Nova sessão</option>`;\n  const sel=$('#sessionPick'); if(sel){ sel.innerHTML=opts; sel.value=SESS.sessionId||''; initMacSelect(sel); }\n  const csel=$('#confirmSessionPick'); if(csel){ csel.innerHTML=opts; csel.value=SESS.sessionId||''; initMacSelect(csel); }\n}\nasync function saveSession(){\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS));\n  if(!ds){ $('#sessStatus').textContent='dataset de sessões indisponível'; return; }\n  if(!SESS.timeline.length){ $('#sessStatus').textContent='nada pra salvar ainda'; return; }\n  await flushDirty();\n  const label=SESS.sessionLabel||('Sessão '+new Date().toLocaleString('pt-BR'));\n  const nowIso=new Date().toISOString();\n  const payload={\n    label,\n    settings:{...DEFAULT_SETTINGS,...(SESS.settings||{})},\n    snapshotRaw:SESS.snapshotRaw, snapshotSyncedAt:SESS.snapshotSyncedAt, snapshotPinnedDate:SESS.snapshotPinnedDate, snapLiteral:SESS.snapLiteral, eloMaxCycle:SESS.eloMaxCycle,\n    // A session now freezes the RENDERED business data per etapa (trimmed to what the render layer\n    // actually reads), not just the recipe — loading no longer re-queries Databricks. Map geometry\n    // (geoFeat/nbrFeat/muniCentroid/secCentroid/sharedMuniSlices) and anything from Overpass/OSM are\n    // deliberately NEVER frozen — hydrateFromFrozen() rebuilds those live from ufList/closingKeys/\n    // nbrKeys against whatever shapes are current. GV/mercado etapas skip the setor-only fields\n    // (matrix/levelByMuni/allowed/muniSiblings/sharedSiblingCodes/orphan*) since renderMatrix* never\n    // shows them at those levels — this is also what keeps a large GV/mercado closure's payload\n    // bounded, after an earlier full-state freeze blew the write-size limit (tens of MB for one).\n    movements:SESS.timeline.map(mv=>{\n      const isSetor=(mv.level||'setor')==='setor';\n      return {\n        sector:mv.sector, level:mv.level||'setor', assign:mv.assign, policy:mv.policy, optBadge:mv.optBadge,\n        dest:mv.dest, munis:mv.munis, leaders:mv.leaders, baseMetrics:mv.baseMetrics, baseLevel:mv.baseLevel,\n        trend:mv.trend, pureOwnMetrics:mv.pureOwnMetrics, muniOwners:mv.muniOwners,\n        rejectedCodes:[...(mv.rejectedCodes||[])], rejectedInfo:mv.rejectedInfo, nbrCurrentSector:mv.nbrCurrentSector,\n        validCandidates:[...(mv.validCandidates||[])], gvRejected:mv.gvRejected, unitOfMuni:mv.unitOfMuni, unitMunis:mv.unitMunis,\n        closeAdj:mv.closeAdj, csvMunisTotal:mv.csvMunisTotal, domCsvMunis:mv.domCsvMunis,\n        excludedMunis:mv.excludedMunis, excludedCbs:mv.excludedCbs, noShapeMunis:mv.noShapeMunis, noShapeCbs:mv.noShapeCbs,\n        cbsByMuni:mv.cbsByMuni, gv:mv.gv, domUF:mv.domUF, ufList:mv.ufList, closingKeys:Object.keys(mv.geoFeat||{}),\n        nbrKeys:(mv.nbrFeat||[]).map(f=>mkey(f.properties.name||f.properties.NOME||f.properties.nome,f.__uf)),\n        optTier:mv.optTier, reachable:mv.reachable||{},\n        ...(mv.kind==='transfer'?{kind:'transfer',xferFrom:mv.xferFrom,xferTo:mv.xferTo,xferMuni:mv.xferMuni,xferMetrics:mv.xferMetrics,xferRows:mv.xferRows}:{}),\n        ...(isSetor?{\n          matrix:(mv.matrix||[]).map(r=>({municipio:r.municipio,uf:r.uf,setor:r.setor,grupo:r.grupo,cbs:r.cbs})),\n          levelByMuni:mv.levelByMuni, allowed:mv.allowed, muniSiblings:mv.muniSiblings,\n          sharedSiblingCodes:[...(mv.sharedSiblingCodes||[])], missingMunis:mv.missingMunis,\n          orphanByGroup:mv.orphanByGroup, orphanMetricsByGroup:mv.orphanMetricsByGroup, orphanLevelByGroup:mv.orphanLevelByGroup,\n          foraDoSetorByGrupo:mv.foraDoSetorByGrupo,\n        }:{}),\n      };\n    }),\n    sectorBaseCache:{...SESS.sectorBaseCache},\n    updatedAt:nowIso,\n  };\n  $('#sessStatus').textContent='salvando…';\n  try{\n    if(SESS.sessionId){\n      await ds.update({_id:SESS.sessionId,...payload});\n    } else {\n      payload.createdAt=nowIso;\n      const created=await ds.create(payload);\n      SESS.sessionId=(created&&created._id)||(created&&created.data&&created.data._id)||(ds.selected&&ds.selected()&&ds.selected()._id)||null;\n    }\n    SESS.sessionLabel=label;\n    await refreshSessionPicker();\n    $('#sessStatus').textContent='salvo ✓';\n  }catch(e){ $('#sessStatus').textContent='erro ao salvar: '+e.message; }\n  setTimeout(()=>{ if($('#sessStatus')) $('#sessStatus').textContent=''; },2200);\n}\n// Duplicate = drop sessionId so saveSession takes its CREATE branch instead of its update branch.\n// The source record is never written to, the copy gets a fresh _id and its own createdAt, and the\n// copy becomes the active session (so the next Salvar edits the copy, not the original).\nasync function renameSession(){\n  const next=prompt('Novo nome da sessão:', SESS.sessionLabel||'');\n  if(next==null) return;\n  const trimmed=next.trim(); if(!trimmed) return;\n  SESS.sessionLabel=trimmed;\n  if(!SESS.sessionId) return;   // ainda não salva — o novo nome só pega valer no próximo \"Salvar sessão\"\n  const ds=(d2.data('sessionsDataset')||d2.data(SESSIONS_DS)); if(!ds) return;\n  if($('#sessStatus')) $('#sessStatus').textContent='renomeando…';\n  try{\n    await ds.update({_id:SESS.sessionId, label:trimmed, updatedAt:new Date().toISOString()});\n    await refreshSessionPicker();\n    if($('#sessStatus')) $('#sessStatus').textContent='renomeado ✓';\n  }catch(e){ if($('#sessStatus')) $('#sessStatus').textContent='erro ao renomear: '+e.message; }\n  setTimeout(()=>{ const s=$('#sessStatus'); if(s&&(s.textContent==='renomeado ✓')) s.textContent=''; }, 2000);\n}\nasync function duplicateSession(){\n  if(!SESS.timeline.length){ $('#sessStatus').textContent='nada pra duplicar ainda'; return; }\n  const prevId=SESS.sessionId, prevLabel=SESS.sessionLabel;\n  SESS.sessionId=null;\n  SESS.sessionLabel=(SESS.sessionLabel||'Sessão')+' (cópia)';\n  await saveSession();\n  if(!SESS.sessionId){\n    // saveSession swallows its own errors into #sessStatus, so a still-null id after the await is\n    // the only failure signal we get. Put the original identity back — otherwise a failed copy\n    // silently leaves the session detached, and the next Salvar would fork a new record instead\n    // of updating the one the user thought they were editing.\n    SESS.sessionId=prevId; SESS.sessionLabel=prevLabel;\n    await refreshSessionPicker().catch(()=>{});\n    return;\n  }\n  if($('#sessStatus')) $('#sessStatus').textContent='duplicado ✓';\n}\nasync function loadSession(id){\n  id=id||'';\n  // Same code path the URL already drives at mount (see the /simulador/:session_id block, init) —\n  // now made bidirectional: picking a session here pushes that same URL shape, so the address bar,\n  // back/forward, and a copied link all agree with whatever loadSession/newSession just did. Base\n  // is derived by stripping any existing trailing /simulador(/id) rather than assumed empty, so this\n  // also degrades harmlessly (inert URL, no server round-trip — pushState never reloads) inside the\n  // builder preview, where the real base is /preview/<ref> instead of the app domain's root.\n  try{\n    const base=window.location.pathname.replace(/\\/simulador(\\/[^/?#]*)?\\/?$/,'');\n    history.pushState({}, '', base+'/simulador'+(id?('/'+encodeURIComponent(id)):''));\n  }catch(e){}\n  if(!id) return newSession();\n  const list=(SESS.sessionsList&&SESS.sessionsList.length)?SESS.sessionsList:await listSessions().catch(()=>[]);\n  const rec=list.find(s=>String(s._id)===String(id));\n  if(!rec) return newSession();\n  loader(true,'carregando sessão…');\n  SESS.sessionId=rec._id; SESS.sessionLabel=rec.label;\n  SESS.snapshotRaw=rec.snapshotRaw; SESS.snapshotSyncedAt=rec.snapshotSyncedAt||null; SESS.snapLiteral=rec.snapLiteral; SESS.eloMaxCycle=rec.eloMaxCycle;\n  SESS.snapshotPinnedDate=rec.snapshotPinnedDate||null;\n  SESS.settings={...DEFAULT_SETTINGS,...(rec.settings||{}),span:migrateSpanSettings(rec.settings)};\n  SESS.timeline=(rec.movements||[]).map(m=>({sector:String(m.sector),level:m.level||'setor'}));\n  SESS.sectorBaseCache={...(rec.sectorBaseCache||{})};\n  SESS.selected=0; SESS.stage='main'; SESS.dirtyFrom=null;\n  try{\n    // A saved session now freezes the RENDERED business data per etapa (hydrateFromFrozen), not just\n    // the recipe — loading no longer re-queries Databricks. Whether the session ends up LOCKED depends\n    // on comparing its frozen snapshot against the current live one: identical means nothing is stale,\n    // so it loads as a normal editable session; different means the frozen data is behind, so it's\n    // locked until refreshSnapshot() recomputes against the live snapshot. A failed check defaults to\n    // locked (safe: gate mutations rather than silently assume the frozen data is still current).\n    try{\n      const liveP=await resolveSnapshotPin();\n      SESS.locked = !liveP.snapshotRaw || String(liveP.snapshotRaw)!==String(rec.snapshotRaw);\n    }catch(e){ SESS.locked=true; }\n    // suggestMuniMoves/computeMovement assume DOM.bySetor is already populated — true in the live\n    // editing flow (loadMoveOptions calls ensureDominantes before any movement can exist), but a\n    // loaded session can have etapas from the moment it lands, without ever visiting that flow. Kicked\n    // off here (parallel with hydration/geometry fetches) so it's ready by the time the UI is\n    // interactive; a failure is surfaced but doesn't block the frozen data from displaying.\n    const domP=ensureDominantes().catch(e=>e);\n    for(let i=0;i<SESS.timeline.length;i++){\n      const saved=(rec.movements||[])[i]||{};\n      loader(true,`carregando etapa ${i+1}/${SESS.timeline.length} — setor ${SESS.timeline[i].sector}…`);\n      await hydrateFromFrozen(i,saved);\n    }\n    await loadSectors();\n    const domRes=await domP;\n    if(domRes instanceof Error){ $('#ds').textContent='sessão carregada, mas a base de municípios dominantes falhou: '+domRes.message+' — sugestões de correção e \"Adicionar movimentação\" ficam indisponíveis até recarregar.'; $('#ds').classList.add('err'); }\n  }catch(e){ $('#ds').textContent='erro ao carregar sessão: '+e.message; $('#ds').classList.add('err'); }\n  finally{ loader(false); }\n  // Sessão trocou de identidade (SESS.sessionId mudou lá em cima) — o <select id=\"sessionPick\">/\n  // #confirmSessionPick e o rótulo visível do macsel só refletem isso through refreshSessionPicker\n  // (é ela que faz sel.value=SESS.sessionId||'' + initMacSelect), nunca automaticamente.\n  await refreshSessionPicker().catch(()=>{});\n  renderAll();\n}\nfunction newSession(){\n  SESS.sessionId=null; SESS.sessionLabel=null; SESS.timeline=[]; SESS.selected=0; SESS.sectorBaseCache={};\n  SESS.settings={...DEFAULT_SETTINGS};\n  SESS.snapshotRaw=null; SESS.snapshotSyncedAt=null; SESS.snapshotPinnedDate=null; SESS.snapLiteral=null; SESS.eloMaxCycle=null; SESS.stage='loading'; SESS.dirtyFrom=null; SESS.locked=false;\n  (async()=>{\n    loader(true,'carregando setores…');\n    try{ await loadSectors(); SESS.stage='confirm'; }\n    catch(e){ setError('erro init: '+e.message); }\n    finally{ loader(false); await refreshSessionPicker().catch(()=>{}); renderAll(); }\n  })();\n}\n\n// ---------- snapshot refresh ----------\n// Deliberately breaks the frozen-snapshot guarantee, on demand: re-resolves DT_HISTORICO and\n// replays the whole timeline against the new one. Skips the replay when the snapshot has not\n// actually moved, so a stray click on a long timeline costs one cheap probe instead of ~8 queries\n// per etapa.\nasync function refreshSnapshot(){\n  if((SESS.stage!=='main' && SESS.stage!=='confirm') || SESS.snapBusy) return;\n  SESS.snapBusy=true;\n  const prevRaw=SESS.snapshotRaw;\n  const pill=$('#liveState'); if(pill){ pill.classList.add('busy'); pill.textContent='◌ atualizando snapshot…'; }\n  const sst0=$('#snapStatus'); if(sst0) sst0.textContent='buscando o snapshot mais recente…';\n  loader(true,'buscando o snapshot mais recente…');\n  let ok=false;\n  try{\n    const p=await resolveSnapshotPin();\n    if(!p.snapshotRaw) throw new Error('não foi possível resolver DT_HISTORICO');\n    const same=String(p.snapshotRaw)===String(prevRaw);\n    SESS.snapshotRaw=p.snapshotRaw; SESS.snapshotSyncedAt=p.syncedAt; SESS.snapLiteral=p.snapLiteral;\n    SESS.snapshotPinnedDate=null;   // clicking back to \"live\" always un-pins any historical date\n    SESS.locked=false;   // updating the snapshot always unlocks — this session is live again now\n    await loadSectors();   // the sector list follows the live max, so it can have moved too\n    let msg;\n    if(same){\n      msg=`snapshot já era o mais recente · atualizado em ${fmtSyncedAt(p.syncedAt)} — nada recalculado`;\n    } else if(SESS.timeline.length){\n      SESS.dirtyFrom=null;\n      SESS.sectorBaseCache={};   // baselines come from the snapshot, so every one of them changed\n      loader(true,'snapshot novo — recalculando a timeline…');\n      await recomputeFrom(0);\n      msg=`snapshot atualizado em ${fmtSyncedAt(p.syncedAt)} · ${fmt(SESS.timeline.length)} etapa(s) recalculada(s)`;\n    } else {\n      msg=`snapshot atualizado em ${fmtSyncedAt(p.syncedAt)}`;\n    }\n    const d=$('#ds'); if(d){ d.textContent=msg; d.classList.remove('err'); }\n    const sst=$('#snapStatus'); if(sst) sst.textContent=msg;\n    ok=true;\n  }catch(e){\n    const at=(SESS.dirtyFrom==null?null:SESS.dirtyFrom+1);\n    const errMsg='erro ao atualizar o snapshot'+(at?` na etapa ${at} — etapa ${at} em diante ficou desatualizada (⟳)`:'')+': '+e.message;\n    const d=$('#ds'); if(d){ d.textContent=errMsg; d.classList.add('err'); }\n    const sst=$('#snapStatus'); if(sst) sst.textContent=errMsg;\n  }finally{\n    SESS.snapBusy=false;\n    if(pill) pill.classList.remove('busy');\n    loader(false); renderAll();\n    if(ok) setTimeout(()=>{ const d=$('#ds'); if(d&&!d.classList.contains('err')) d.textContent=''; },7000);\n  }\n}\n\n\n// ---------- session options ----------\nfunction readOptForm(){\n  const scopeOf=lvl=>({\n    targetSpan:numOrNull($('#optTargetSpan_'+lvl).value),\n    maxSpan:numOrNull($('#optMaxSpan_'+lvl).value),\n    minSpan:numOrNull($('#optMinSpan_'+lvl).value),\n  });\n  // Mercado span has no UI control anymore — carry the session's current value through untouched\n  // instead of reading DOM inputs that no longer exist.\n  const fc=root.querySelector('input[name=\"forceContig\"]:checked');\n  return {span:{setor:scopeOf('setor'), gv:scopeOf('gv'), mercado:mergedSpanAll().mercado},\n    forceContiguity: fc?fc.value==='1':!!(SESS.settings&&SESS.settings.forceContiguity)};\n}\nfunction spanScopeError(sc,label){\n  if(sc.minSpan&&sc.maxSpan&&sc.minSpan>sc.maxSpan) return `${label} — span mínimo (${fmt(sc.minSpan)}) não pode ser maior que o máximo (${fmt(sc.maxSpan)}).`;\n  if(sc.targetSpan&&sc.maxSpan&&sc.targetSpan>sc.maxSpan) return `${label} — span alvo (${fmt(sc.targetSpan)}) não pode passar do máximo (${fmt(sc.maxSpan)}).`;\n  if(sc.targetSpan&&sc.minSpan&&sc.targetSpan<sc.minSpan) return `${label} — span alvo (${fmt(sc.targetSpan)}) não pode ficar abaixo do mínimo (${fmt(sc.minSpan)}).`;\n  return '';\n}\nfunction spanFormError(f){\n  return spanScopeError(f.span.setor,'Setor')||spanScopeError(f.span.gv,'GV')||spanScopeError(f.span.mercado,'Mercado');\n}\nfunction renderOptPreview(){\n  const el=$('#optPreview'); if(!el) return;\n  const f=readOptForm();\n  const err=spanFormError(f), ee=$('#optSpanErr');\n  if(ee){ ee.textContent=err; ee.classList.toggle('hidden',!err); }\n  const ap=$('#optApply'); if(ap) ap.disabled=!!err;\n  const mv=SESS.timeline[SESS.selected];\n  if(!mv||!mv.munis){ el.innerHTML='Nenhuma etapa calculada ainda — as regras passam a valer no próximo fechamento.'; return; }\n  const lvl=mv.level||'setor', sc=f.span[lvl];\n  let h=`<b style=\"color:var(--ink)\">Prévia — etapa ${SESS.selected+1}, ${lc(LEVELS[lvl].label)} ${mv.sector}</b><br>`;\n  if(sc.targetSpan||sc.maxSpan||sc.minSpan){\n    const sp=spanMapFor(mv,mv.assign||{}), v=spanViolationsOf(sp,{target:sc.targetSpan,max:sc.maxSpan,min:sc.minSpan});\n    h+=`Span dos destinos como está hoje: ${mv.dest.map(s=>s+' <b>'+fmt(sp[s])+'</b>').join(' · ')||'–'}`;\n    if(v.over.length||v.under.length) h+=`<br><span style=\"color:var(--closing)\">Fora dos limites: ${[...v.over.map(s=>s+' ▲ acima do máx'),...v.under.map(s=>s+' ▼ abaixo do mín')].join(', ')}</span> — o modelo ÓTIMO tenta corrigir no recálculo.`;\n    else h+=` <span style=\"color:var(--ok)\">— já dentro dos limites</span>`;\n  } else {\n    h+=`Sem limite de span pra ${lc(LEVELS[lvl].label)} — ${lc(LEVELS[lvl].destLabel)} destino pode ficar de qualquer tamanho.`;\n  }\n  if(SESS.timeline.length) h+=`<br><span style=\"margin-top:4px;display:inline-block\">Aplicar <b>recalcula as ${fmt(SESS.timeline.length)} etapa(s)</b> contra o snapshot congelado — os destinos ajustados à mão serão refeitos pelo modelo ÓTIMO.</span>`;\n  el.innerHTML=h;\n}\nfunction openOptions(){\n  const sp=mergedSpanAll();\n  ['setor','gv'].forEach(lvl=>{\n    $('#optTargetSpan_'+lvl).value=sp[lvl].targetSpan||'';\n    $('#optMaxSpan_'+lvl).value=sp[lvl].maxSpan||'';\n    $('#optMinSpan_'+lvl).value=sp[lvl].minSpan||'';\n  });\n  const fcVal=(SESS.settings&&SESS.settings.forceContiguity)?'1':'0';\n  const fcEl=root.querySelector('input[name=\"forceContig\"][value=\"'+fcVal+'\"]');\n  if(fcEl) fcEl.checked=true;\n  $('#optApply').disabled=false;\n  $('#optSessName').textContent=SESS.sessionLabel||'sessão não salva';\n  $('#optStatus').textContent='';\n  $('#optionsBg').classList.remove('hidden');\n  renderOptPreview();\n  const c=$('#optCancel'); if(c) c.focus();\n}\nfunction closeOptions(){ const b=$('#optionsBg'); if(b) b.classList.add('hidden'); }\nfunction openLockedSheet(){ const b=$('#lockedBg'); if(b) b.classList.remove('hidden'); const s=$('#lockedStatus'); if(s) s.textContent=''; const c=$('#lockedCancel'); if(c) c.focus(); }\nfunction closeLockedSheet(){ const b=$('#lockedBg'); if(b) b.classList.add('hidden'); }\n\nfunction guardLocked(){ if(!SESS.locked) return false; openLockedSheet(); return true; }\nfunction pillClick(){ if(SESS.locked){ openLockedSheet(); return; } refreshSnapshot(); }\nasync function applyOptions(){\n  if(guardLocked()) return;\n  const f=readOptForm();\n  if(spanFormError(f)) return;\n  const prevSp=mergedSpanAll();\n  const changed=['setor','gv','mercado'].some(lvl=>\n    numOrNull(prevSp[lvl].targetSpan)!==f.span[lvl].targetSpan\n    || numOrNull(prevSp[lvl].maxSpan)!==f.span[lvl].maxSpan\n    || numOrNull(prevSp[lvl].minSpan)!==f.span[lvl].minSpan\n  );\n  SESS.settings={...SESS.settings,span:f.span,forceContiguity:f.forceContiguity};\n  closeOptions();\n  if(!changed || !SESS.timeline.length){ renderAll(); return; }\n  SESS.dirtyFrom=null;\n  SESS.sectorBaseCache={};   // the recompute re-derives every baseline; keeping the old ones would\n                             // leave \"Impacto acumulado\" comparing against pre-change numbers\n  loader(true,'aplicando novos limites de span — recalculando timeline…');\n  try{\n    await recomputeFrom(0);\n    $('#ds').textContent=''; $('#ds').classList.remove('err');\n  }catch(e){\n    const at=(SESS.dirtyFrom==null?0:SESS.dirtyFrom)+1;\n    $('#ds').textContent=`erro ao aplicar o limite na etapa ${at}: ${e.message} — etapa ${at} em diante ficou desatualizada (⟳)`;\n    $('#ds').classList.add('err');\n  }finally{ loader(false); renderAll(); }\n}\n\nconst SHARED_DS='timeline-shared-sessionsDataSet';\nconst AUTH_REF='timelineSimAuthFinal';   // single source of truth for the auth strategy reference\nlet SHARE={busy:false,url:null,last:null};\n// Serialise a PRESENTATION model, not the raw session, so the reader stays purely presentational\n// and never re-derives spans/desloc/orphans — no second copy of this engine to keep in sync.\n// NOTE: METRICS_DEF[2] is the money marker; read it as !!md[2] (a literal dollar sign in this\n// position trips the upsert's parse check).\nfunction buildShareStages(){\n  return SESS.timeline.map((mv,i)=>{\n    const c=computeDerived(mv);\n    const incBy={};\n    mv.dest.forEach(s=>{ incBy[s]=Object.fromEntries(METRICS_DEF.map(md=>[md[0],0])); });\n    Object.entries(mv.munis).forEach(([m,o])=>{\n      if(m==='__OUTROS__') return;\n      const s=mv.assign[m]; if(!s||!incBy[s]) return;\n      METRICS_DEF.forEach(md=>{ incBy[s][md[0]]+=o[md[0]]||0; });\n    });\n    const saiTot={};\n    METRICS_DEF.forEach(md=>{\n      const k=md[0];\n      saiTot[k]=Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,p)=>a+((p[1][k])||0),0);\n    });\n    // orphans follow their leader's home: they enter that sector AND leave the closing one\n    c.leaders.forEach(L=>{\n      const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n      if(!om||!L.home) return;\n      METRICS_DEF.forEach(md=>{ const k=md[0];\n        if(incBy[L.home]) incBy[L.home][k]+=om[k]||0;\n        saiTot[k]+=om[k]||0; });\n    });\n    const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n    const sv=mv.dest.map(s=>c.span[s]);\n    const baseOf=(s,k)=>{ const b=mv.baseMetrics[s]||{}; return +(b[k]||0); };\n    return {\n      i:i, sector:mv.sector, level:mv.level||'setor', kind:mv.kind||null, xferFrom:mv.xferFrom||null, xferTo:mv.xferTo||null, xferMuni:mv.xferMuni||null, xferRows:mv.xferRows||null,\n      unitOfMuni:mv.unitOfMuni||{}, policy:mv.policy, domUF:mv.domUF,\n      optTier:(mv.optTier==null?0:mv.optTier),\n      dest:mv.dest.slice(), color:Object.assign({},mv.color), assign:Object.assign({},mv.assign),\n      kpis:{ cbsRealocadas:closingCbsOf(mv), desloc:c.desloc,\n             spread:(sv.length?Math.max.apply(null,sv)-Math.min.apply(null,sv):0),\n             municipios:ms.length, lideres:mv.leaders.length },\n      destinos:mv.dest.map(s=>({ s:s, base:baseOf(s,'cbs'), inc:c.inc[s]||0, orph:c.orph[s]||0, span:c.span[s]||0, gained:c.gained[s]||0 })),\n      municipios:ms.map(m=>(mv.level&&mv.level!=='setor')?{ nome:m, uf:'', cbs:mv.munis[m].cbs, destino:mv.assign[m]||null }:{ nome:mName(m), uf:mUf(m), cbs:mv.munis[m].cbs, destino:mv.assign[m]||null }),\n      excluidos:(mv.excludedMunis||[]).map(x=>({ muni:x.muni, cbs:x.cbs })),\n      orphanTotal:c.orphanTotal||0, orphanStranded:c.orphanStranded||0, noShapeCbs:mv.noShapeCbs||0,\n      lideres:c.leaders.map(L=>({ lider:(L.lider||'').split(' ').slice(0,2).join(' '), grupo:L.grupo,\n        total:L.total, home:L.home, desloc:L.desloc, orphan:L.orphan||0 })),\n      metricas:METRICS_DEF.map(md=>{ const k=md[0];\n        return { key:k, label:md[1], money:!!md[2],\n          rows:mv.dest.map(s=>({ s:s, antes:baseOf(s,k), depois:baseOf(s,k)+((incBy[s]&&incBy[s][k])||0) })),\n          sai:saiTot[k]||0 }; }),\n      geoFeat:mv.geoFeat, nbrFeat:mv.nbrFeat, excludedFeat:[],\n      // Everything below is for the shared read-only viewer (CNL_4z20SJcX0NMp2I2vz) to reproduce\n      // this live map's shared-município mosaic (hatch, clean outline, per-context colouring) —\n      // without it, a município like São Paulo can only ever render as this sector's own slice\n      // plus flat grey \"context\", because none of the resolution work (who else really owns it,\n      // what colour that owner gets, whether it's a rejected/novalid sibling) survived into the\n      // saved record. Every value here is already fully RESOLVED at this point — the read view\n      // only ever renders them, it never re-derives a business rule (no live DOM/world access\n      // there anyway). rejectedCodes is a Set, spread to an array — JSON has no Set.\n      sharedMuniSlices:(mv.sharedMuniSlices||[]).map(sl=>({feature:sl.feature,sector:sl.sector,muni:sl.muni})),\n      muniFullShape:mv.muniFullShape||{},\n      nbrCurrentSector:mv.nbrCurrentSector||{},\n      rejectedCodes:[...(mv.rejectedCodes||[])],\n      muniOwners:mv.muniOwners||{}\n    };\n  });\n}\nfunction buildShareSummary(stages){\n  const world=foldWorldState(SESS.timeline,SESS.timeline.length);\n  const receivers=[];\n  SESS.timeline.forEach((mv,i)=>{\n    if(mv.kind==='transfer') return;\n    const own=mv.pureOwnMetrics||{};\n    receivers.push({ s:mv.sector, situacao:'fechado (etapa '+(i+1)+')', antes:+(own.cbs||0), depois:0 });\n  });\n  const seen={};\n  Object.values(world.muniOverride).forEach(s=>{\n    if(seen[s]) return; seen[s]=1;\n    const base=SESS.sectorBaseCache[s]||{}, infl=world.sectorInflow[s]||{};\n    receivers.push({ s:s, situacao:'recebeu território', antes:+(base.cbs||0), depois:(+(base.cbs||0))+(infl.cbs||0) });\n  });\n  return { stages:stages.length, sectors:SESS.timeline.map(mv=>mv.sector),\n    totalCbs:stages.reduce((a,s)=>a+(s.kpis.cbsRealocadas||0),0),\n    totalDesloc:stages.reduce((a,s)=>a+(s.kpis.desloc||0),0), receivers:receivers };\n}\nfunction renderShareBody(){\n  const el=$('#shareBody'); if(!el) return;\n  const go=$('#shareGo'), cp=$('#shareCopy'), op=$('#shareOpen'), cc=$('#shareCancel');\n  if(SHARE.url){\n    el.innerHTML=`<div class=\"opt-note\" style=\"margin-top:0\"><b style=\"color:var(--ink)\">Sessão compartilhada ✓</b><br>\n      Link de leitura desta simulação congelada. Quem abrir precisa ter login no simulador.\n      <div class=\"shareurl\">${esc(SHARE.url)}</div></div>`;\n    if(go) go.classList.add('hidden');\n    if(cp) cp.classList.remove('hidden');\n    if(op) op.classList.remove('hidden');\n    if(cc) cc.textContent='Fechar';\n    return;\n  }\n  if(go) go.classList.remove('hidden');\n  if(cp) cp.classList.add('hidden');\n  if(op) op.classList.add('hidden');\n  if(cc) cc.textContent='Cancelar';\n  const desloc=SESS.timeline.reduce((a,mv)=>a+(mv.munis?computeDerived(mv).desloc:0),0);\n  el.innerHTML=`<div class=\"opt-lbl\">Confirmar compartilhamento</div>\n    <div class=\"opt-sub\">Publica uma <b>cópia congelada</b> desta sessão, exatamente no estado atual.</div>\n    <div class=\"opt-note\" style=\"margin-top:0\">\n      · <b>${fmt(SESS.timeline.length)}</b> etapa(s) — ${esc(SESS.timeline.map(mv=>mv.sector).join(' → '))}<br>\n      · snapshot atualizado em <b>${esc(fmtSyncedAt(SESS.snapshotSyncedAt))}</b><br>\n      · base: <b>municípios dominantes (CSV)</b> · destino: <b>qualquer município</b> · span: <b>${esc(spanShortAll())}</b><br>\n      · <b>${fmt(desloc)}</b> CBs deslocadas somando a cadeia\n    </div>\n    <div class=\"opt-note\">A cópia <b>não</b> acompanha edições posteriores desta sessão — pra atualizar, compartilhe de novo e use o link novo. O link exige login no simulador; não é público.</div>\n    ${SHARE.last?`<div class=\"opt-note\"><b style=\"color:var(--ink)\">Último link gerado nesta sessão</b> — aponta pro estado de <b>antes</b> das edições feitas depois dele.<div class=\"shareurl\">${esc(SHARE.last)}</div></div>`:''}`;\n}\nasync function openShare(){\n  if(!SESS.timeline.length){ $('#ds').textContent='nada pra compartilhar — feche pelo menos um setor primeiro.'; $('#ds').classList.add('err'); return; }\n  await flushDirty();\n  SHARE.url=null;\n  const s=$('#shareStatus'); if(s){ s.textContent=''; s.classList.remove('err'); }\n  renderShareBody();\n  $('#shareBg').classList.remove('hidden');\n  const b=$('#shareCancel'); if(b) b.focus();\n}\nfunction closeShare(){ const b=$('#shareBg'); if(b) b.classList.add('hidden'); }\nasync function doShare(){\n  if(SHARE.busy) return;\n  const go=$('#shareGo'), stx=$('#shareStatus');\n  SHARE.busy=true;\n  if(go){ go.disabled=true; go.textContent='Compartilhando…'; }\n  if(stx){ stx.textContent=''; stx.classList.remove('err'); }\n  try{\n    // Resolved as a GLOBAL PROCESS, not a declared param — which only works because the block\n    // instance on the `main` artboard binds `sharedDataset: @timeline-shared-sessionsDataSet`.\n    // That binding is load-bearing: a Process is only define()d once something on the rendering\n    // tree references it, so without it this lookup returns null and sharing dies on the guard\n    // below. Do NOT remove the instance param because this file never reads d2.params.sharedDataset.\n    const ds=d2.data(SHARED_DS);\n    if(!ds) throw new Error('dataset de sessões compartilhadas indisponível');\n    await flushDirty();\n    const stages=buildShareStages();\n    const summary=buildShareSummary(stages);\n    let who='';\n    try{ const a=d2.auth(AUTH_REF); who=(a&&a.user&&(a.user.email||a.user.nome))||''; }catch(e){}\n    const created=await ds.create({\n      label:SESS.sessionLabel||('Sessão '+new Date().toLocaleString('pt-BR')),\n      snapshotRaw:SESS.snapshotRaw, snapshotSyncedAt:SESS.snapshotSyncedAt,\n      settings:{...DEFAULT_SETTINGS,...(SESS.settings||{})},\n      stages:stages, summary:summary, sharedBy:who, sharedAt:new Date().toISOString()\n    });\n    const id=(created&&created._id)||(created&&created.data&&created.data._id)||(ds.selected&&ds.selected()&&ds.selected()._id)||null;\n    if(!id) throw new Error('registro gravado, mas sem _id na resposta — não foi possível montar o link');\n    SHARE.url=window.location.origin+'/sessions/'+id;\n    SHARE.last=SHARE.url;\n    renderShareBody();\n  }catch(e){\n    if(stx){ stx.textContent='erro ao compartilhar: '+e.message; stx.classList.add('err'); }\n  }finally{\n    SHARE.busy=false;\n    if(go){ go.disabled=false; go.textContent='Compartilhar'; }\n  }\n}\n\n// ---------- rendering ----------\nfunction renderTimeline(){\n  const el=$('#movementChips'); if(!el) return;\n  if(!SESS.timeline.length){ el.innerHTML='<span class=\"empty\">Nenhuma etapa ainda — use \"Adicionar movimentação\" à direita.</span>'; return; }\n  el.innerHTML=SESS.timeline.map((mv,i)=>{\n    const stale=SESS.dirtyFrom!=null && i>=SESS.dirtyFrom;\n    const on=i===SESS.selected?'on':'';\n    const isXfer=mv.kind==='transfer';\n    const label=isXfer\n      ? `<span class=\"tarw\" aria-hidden=\"true\">↳</span><span class=\"tsec\">${esc(mName(mv.xferMuni))}: ${esc(mv.xferFrom)} → ${esc(mv.xferTo)}</span>`\n      : `<span class=\"tsec\">${LEVELS[mv.level||'setor'].label} ${mv.sector}</span>`;\n    return `<div class=\"tchip ${isXfer?'tchip-transfer':''} ${on} ${stale?'stale':''}\" data-i=\"${i}\">\n      <span class=\"tnum\">${i+1}</span>${label}\n      ${stale?'<span class=\"tstale\" title=\"recalcula ao selecionar outra etapa\">⟳</span>':''}\n      <span class=\"trm\" data-rm=\"${i}\" title=\"remover esta etapa e as seguintes\">✕</span>\n    </div>`;\n  }).join('');\n  el.querySelectorAll('.tchip').forEach(ch=>{\n    ch.addEventListener('click',async (e)=>{\n      if(e.target.dataset.rm!=null && e.target.dataset.rm!==''){ removeFrom(+e.target.dataset.rm); return; }\n      const i=+ch.dataset.i;\n      if(i!==SESS.selected){ await flushDirty(); }\n      PULL_SET=new WeakSet();\n      SESS.selected=i; renderAll();\n    });\n  });\n}\nfunction renderRuleNote(){\n  const el=$('#ruleNote'); if(!el) return;\n  const mv=SESS.timeline[SESS.selected];\n  const lvl=LEVELS[(mv&&mv.level)||'setor'];\n  let h='';\n  if(!mv || (mv.level||'setor')==='setor'){\n    h+=`<b style=\"color:var(--ink)\">Base de municípios dominantes:</b> só os municípios que a base (CSV) atribui ao setor entram no mapa e na alocação`;\n    if(mv&&mv.domCsvMunis) h+=` · <b>${fmt(mv.domCsvMunis)}</b> na base do setor ${mv.sector}`;\n    if(mv&&mv.excludedCbs) h+=` · <b style=\"color:var(--closing)\">${fmt(mv.excludedMunis.length)} município(s) / ${fmt(mv.excludedCbs)} CBs</b> fora da base nesta etapa — não aparecem no mapa e as CBs seguem suas líderes (quebra de vínculo)`;\n  } else {\n    // GV/mercado não têm spillover (todo setor pertence a exatamente uma GV/mercado na base), então\n    // não existe \"excluído\" pra reportar aqui — só o tamanho da base e em quantas unidades ela se agrupa.\n    h+=`<b style=\"color:var(--ink)\">Base de ${lc(lvl.childLabel)}s dominantes:</b> ${mv.domCsvMunis!=null?`<b>${fmt(mv.domCsvMunis)}</b> município(s) `:''}na base ${lvl.de} ${lc(lvl.label)} ${mv.sector}, agrupados em <b>${fmt(Object.keys(mv.unitMunis||{}).length)}</b> ${lc(lvl.childLabel)}(s)`;\n  }\n  if(DOM.error) h+=` · <b style=\"color:var(--closing)\">⚠ base indisponível: ${esc(DOM.error)}</b>`;\n  el.innerHTML=h;\n}\nconst FIX_CACHE = new WeakMap();\nasync function computeFixSpan(mv, code, lvl, sp, lim, isOver){\n  const closeWork=(async()=>{\n    try{\n      const world=foldWorldState(SESS.timeline,SESS.timeline.length);\n      const pv=await computeMovement(String(code),lvl,world);\n      pv.assign=optimizeFor(pv);\n      const pvLim=spanLimits(pv);\n      const pvSp=spanMapFor(pv,pv.assign||{});\n      const pvV=spanLimitsOn(pvLim)?spanViolationsOf(pvSp,pvLim):{over:[],under:[]};\n      return {ok:true, dest:pv.dest||[], spMap:pvSp, over:pvV.over, under:pvV.under, optTier:pv.optTier};\n    }catch(e){\n      return {ok:false, errorMsg:e.message, rejected:[]};\n    }\n  })();\n  const moveWork=(async()=>{\n    try{\n      const plan=await suggestMuniMoves(mv,sp,lim,code,isOver);\n      return {ok:true, plan};\n    }catch(e){\n      return {ok:false, errorMsg:e.message, plan:null};\n    }\n  })();\n  const [close,move]=await Promise.all([closeWork,moveWork]);\n  return {code, isOver, close, move};\n}\nfunction renderFixResult(resultEl, entry, ctx){\n  const {mv,code,lvl,sp,lim}=ctx;\n  const close=entry.close;\n  const closeOk=!!(close&&close.ok&&!close.over.length&&!close.under.length);\n  const move=entry.move;\n  const plan=(move&&move.ok)?move.plan:null;\n  const moveOk=!!(plan&&plan.moves&&plan.resolved);\n\n  if(!closeOk&&!moveOk){\n    resultEl.innerHTML='<div class=\"hint\">nenhuma simulação resolve esta violação por completo.</div>';\n    return;\n  }\n  resultEl.innerHTML='';\n\n  if(closeOk){\n    const closeSlot=document.createElement('div'); resultEl.appendChild(closeSlot);\n    const chips=(close.dest||[]).map(d=>`<span class=\"chip\">${d} ${fmt(close.spMap[d])}</span>`).join('');\n    let pvTier='';\n    if(close.optTier===1) pvTier='nenhuma alocação conectada respeitaria o mínimo — o modelo relaxou essa exigência nesta simulação.';\n    else if(close.optTier===2) pvTier='nenhuma alocação conectada respeitaria os limites — o modelo relaxou essa exigência nesta simulação.';\n    else if(close.optTier===-1) pvTier='simulação resolvida por heurística — os limites não são garantidos.';\n    closeSlot.className='fixcard';\n    closeSlot.innerHTML=`<div>fechar ${LEVELS[lvl].label} ${code} redistribui a carga (base + já acumulado) entre:</div>\n      <div class=\"chips\">${chips||'—'}</div>\n      <div class=\"verdict ok\">✓ fecha limpo — nenhum receptor estoura o limite</div>\n      ${pvTier?`<div class=\"hint\">${pvTier}</div>`:''}\n      <button class=\"act applyfix\">✓ Aplicar — fechar ${code} como próxima etapa</button>`;\n    closeSlot.querySelector('.applyfix').onclick=()=>{ addMovement(lvl,code); };\n  }\n\n  if(moveOk){\n    const moveSlot=document.createElement('div'); resultEl.appendChild(moveSlot);\n    const rows=plan.moves.map(m=>`<div>${esc(mDisp(m.muni))} (${fmt(m.cbs)} CBs): <b style=\"color:${mv.color[m.from]}\">${m.from}</b> → <b style=\"color:${mv.color[m.to]}\">${m.to}</b></div>`).join('');\n    const touched=[...new Set(plan.moves.flatMap(m=>[m.from,m.to]))];\n    const afterChips=touched.map(s=>`<span class=\"chip\">${s} ${fmt(sp[s])} → ${fmt(plan.spNow[s])}</span>`).join('');\n    moveSlot.className='fixcard';\n    moveSlot.innerHTML=`<div>mover ${fmt(plan.moves.length)} município(s), sem fechar nenhum setor:</div>\n      <div>${rows}</div>\n      <div class=\"chips\">${afterChips}</div>\n      <div class=\"verdict ok\">✓ resolve esta violação por completo</div>\n      <button class=\"act applymove\">✓ Aplicar — mover ${plan.moves.length>1?'estes municípios':'só este município'}</button>`;\n    moveSlot.querySelector('.applymove').onclick=async()=>{\n      const mb=moveSlot.querySelector('.applymove');\n      mb.disabled=true; mb.textContent='movendo…';\n      try{\n        for(const m of plan.moves){ await applyTransfer(m.from,m.to,m.muni); }\n        mb.textContent='recalculando etapa…';\n        await flushDirty();\n        renderAll();\n      }catch(e){ mb.disabled=false; mb.textContent='erro: '+e.message; }\n    };\n    const applyBtn=moveSlot.querySelector('.applymove');\n    const setPlanHl=on=>{\n      const svg=$('#map'); if(!svg) return;\n      const want=new Set(plan.moves.map(m=>m.muni));\n      svg.querySelectorAll('[data-muni]').forEach(el=>{ if(want.has(el.getAttribute('data-muni'))) el.classList.toggle('munihl',on); });\n    };\n    applyBtn.addEventListener('mouseenter',()=>setPlanHl(true));\n    applyBtn.addEventListener('mouseleave',()=>setPlanHl(false));\n  }\n}\nfunction renderSpanRule(){\n  const el=$('#spanRule'); if(!el) return;\n  const mv=SESS.timeline[SESS.selected], lim=spanLimits(mv);\n  if(!mv||!mv.munis||mv.view==='asis'||!spanLimitsOn(lim)){ el.innerHTML=''; return; }\n  const sp=spanMapFor(mv,mv.assign||{}), v=spanViolationsOf(sp,lim);\n  const bits=[];\n  if(lim.target) bits.push(`alvo ${fmt(lim.target)}`);\n  if(lim.max) bits.push(`máx ${fmt(lim.max)}`);\n  if(lim.min) bits.push(`mín ${fmt(lim.min)}`);\n  const limTxt=`Pré-requisito de span: ${bits.join(' · ')} CBs`;\n  let tierTxt='';\n  if(mv.optTier===1) tierTxt=' — nenhuma alocação conectada respeita o mínimo; o modelo relaxou essa exigência.';\n  else if(mv.optTier===2) tierTxt=' — nenhuma alocação conectada respeita os limites; o modelo relaxou essa exigência.';\n  else if(mv.optTier===-1) tierTxt=' — etapa resolvida por heurística; os limites não são garantidos.';\n  if(v.over.length||v.under.length){\n    const lvl=mv.level||'setor';\n    const fixCodes=[...v.over,...v.under];\n    const rows=fixCodes.map(s=>{\n      const dir=v.over.includes(s)?'acima do máximo':'abaixo do mínimo';\n      return `<div class=\"rule-issue-row\" data-code=\"${s}\">\n        <div class=\"rule-issue-hd\">\n          <button class=\"rule-issue fixspan\" data-code=\"${s}\" title=\"${limTxt}${tierTxt} — clique pra simular fechar ${s} e ver, de verdade, pra onde a carga dele iria\">⚠ ${s} ${dir}: ${fmt(sp[s])}</button>\n          <button class=\"fixdismiss hidden\" data-code=\"${s}\" title=\"Recolher as sugestões desta violação\">✕</button>\n        </div>\n        <div class=\"fixresult\"></div>\n      </div>`;\n    }).join('');\n    el.innerHTML=`<div class=\"rule-item\">\n      <div class=\"rule-cap\">Span</div>\n      <div class=\"rule-issues\">${rows}</div>\n    </div>`;\n    el.querySelectorAll('.rule-issue-row').forEach(row=>{\n      const code=row.dataset.code;\n      const btn=row.querySelector('.fixspan');\n      const resultEl=row.querySelector('.fixresult');\n      const dismissBtn=row.querySelector('.fixdismiss');\n      let cache=FIX_CACHE.get(mv);\n      const cachedEntry=cache&&cache.get(code);\n      if(cachedEntry){\n        renderFixResult(resultEl,cachedEntry,{mv,code,lvl,sp,lim});\n        resultEl.classList.remove('hidden');\n        dismissBtn.classList.remove('hidden');\n      }\n      dismissBtn.onclick=()=>{ FIX_CACHE.get(mv)?.delete(code); resultEl.classList.add('hidden'); dismissBtn.classList.add('hidden'); };\n      btn.onclick=async ()=>{\n        const origText=btn.textContent;\n        resultEl.classList.remove('hidden');\n        if(!hierarchyAllows(lvl)){\n          resultEl.innerHTML=`<div class=\"fixcard\"><span style=\"color:var(--closing)\">esta sessão já fechou um nível mais alto — a sugestão não está mais disponível.</span></div>`;\n          btn.disabled=true; btn.textContent='indisponível'; return;\n        }\n        const closedNow=new Set(SESS.timeline.filter(m=>m.kind!=='transfer' && (m.level||'setor')===lvl).map(m=>m.sector));\n        if(closedNow.has(String(code))){\n          resultEl.innerHTML=`<div class=\"fixcard\"><span style=\"color:var(--closing)\">${LEVELS[lvl].label} ${code} já foi fechado(a) em outra etapa desta timeline — a sugestão não se aplica mais.</span></div>`;\n          btn.disabled=true; btn.textContent='indisponível'; return;\n        }\n        const isOver=v.over.includes(code);\n        let cache2=FIX_CACHE.get(mv); if(!cache2){ cache2=new Map(); FIX_CACHE.set(mv,cache2); }\n        let entry=cache2.get(code);\n        if(!entry){\n          dismissBtn.classList.add('hidden');\n          btn.disabled=true; btn.textContent='simulando movimentações…';\n          entry=await computeFixSpan(mv,code,lvl,sp,lim,isOver);\n          cache2.set(code,entry);\n          btn.disabled=false; btn.textContent=origText;\n        }\n        renderFixResult(resultEl,entry,{mv,code,lvl,sp,lim});\n        resultEl.classList.remove('hidden');\n        dismissBtn.classList.remove('hidden');\n      };\n    });\n  } else {\n    el.innerHTML=`<div class=\"rule-item\">\n      <div class=\"rule-cap\">Span</div>\n      <div class=\"rule-ok\" title=\"${limTxt}\">✓ todos os setores receptores dentro dos limites</div>\n    </div>`;\n  }\n}\nfunction renderContig(){const mv=SESS.timeline[SESS.selected];const el=$('#contig');if(!el||!mv)return;\n  if(mv.view==='asis'){el.innerHTML='';return;}\n  const lvl=LEVELS[mv.level||'setor'];\n  const bad=contigBadFor(mv,mv.assign);\n  if(bad.length){\n    el.innerHTML=`<div class=\"rule-item\">\n      <div class=\"rule-cap\">Conectividade</div>\n      <div class=\"rule-issues\"><button class=\"rule-issue\" id=\"fixContig\" title=\"${lc(lvl.childLabel)}(s) desconectado(s) do território ${lvl.de} ${lc(lvl.label)} — clique pra rodar ★ ÓTIMO e reconectar\">⚠ Território desconectado em ${bad.join(', ')}</button></div>\n    </div>`;\n    $('#fixContig').onclick=()=>editSelected(m=>{ m.assign=optimizeFor(m); });\n  } else {\n    el.innerHTML=`<div class=\"rule-item\">\n      <div class=\"rule-cap\">Conectividade</div>\n      <div class=\"rule-ok\">✓ cada ${lc(lvl.label)} forma uma região conectada</div>\n    </div>`;\n  }\n}\nfunction closingCbsOf(mv){ return Object.entries(mv.munis).filter(([m])=>m!=='__OUTROS__').reduce((a,[,o])=>a+o.cbs,0); }\nfunction sectorTotalCbs(mv){ return Object.values(mv.munis).reduce((a,o)=>a+o.cbs,0); }\nfunction sectorTotalAtivas(mv){ return Object.values(mv.munis).reduce((a,o)=>a+(o.ativas||0),0); }\nfunction renderKpis(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  const childPlural=lvl.childLabel==='Setor'?'Setores':(lvl.childLabel==='GV'?'GVs':'Municípios');\n  if(mv.view==='asis'){const mc=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').length;\n    // \"CBs fora do setor\" (ASIS): soma de mv.foraDoSetorByGrupo — CBs (linha crua do CRM em mv.matrix)\n    // cujo município não está na base de municípios dominantes (dominantesCsvUrl) para ESTE setor. Só\n    // existe no nível setor (GV/mercado não têm spillover — ver computeMovement/renderRuleNote).\n    const foraDoSetorCbs=Object.values(mv.foraDoSetorByGrupo||{}).reduce((a,v)=>a+v,0);\n    $('#kpis').innerHTML=[\n      {n:fmt(sectorTotalCbs(mv)),l:`CBs ${lvl.no} ${lc(lvl.label)} ${mv.sector} (antes desta etapa)`,t:`Total de CBs ${lvl.no} ${lc(lvl.label)} hoje — já considerando o que etapas anteriores da timeline mandaram pra cá.`},\n      {n:fmt(sectorTotalAtivas(mv)),l:`CBs ativos ${lvl.no} ${lc(lvl.label)} ${mv.sector} (antes desta etapa)`,t:`Total de CBs ativos (business_status_code=3) ${lvl.no} ${lc(lvl.label)} hoje — já considerando o que etapas anteriores da timeline mandaram pra cá.`},\n      {n:mv.csvMunisTotal?`${fmt(mc)}/${fmt(mv.csvMunisTotal)}`:fmt(mc),l:`${childPlural} ${lvl.de} ${lc(lvl.label)}`,t:`${lvl.childLabel}(s) desenhados nesta etapa${mv.csvMunisTotal?` de ${fmt(mv.csvMunisTotal)} que a base de dominantes atribui ${lvl.a} ${lc(lvl.label)}`:''}.${(mv.missingMunis||[]).length?' Fora do mapa: '+mv.missingMunis.map(x=>x.muni+' ('+x.reason+')').join(' · '):''}${Object.keys(mv.ufGuessed||{}).length?' UF inferida da UF dominante do setor (nome repetido em outra UF carregada): '+Object.entries(mv.ufGuessed).map(([nm,u])=>nm+'→'+u).join(', '):''}`},\n      {n:fmt(mv.leaders.length),l:'Líderes (grupos)',t:`Líderes presentes ${lvl.no} ${lc(lvl.label)} nesta etapa.`},\n      {n:fmt(mv.dest.length),l:`${lvl.destLabel}s adjacentes`,t:`${lvl.destLabel}s vizinh${lvl.destLabel==='GV'?'as':'os'} (ainda abertas)${lvl.parentPhrase?` ${lvl.parentPhrase}`:''}${mv.gv?` (${mv.gv})`:''} que poderiam receber os ${lc(lvl.childLabel)}s.${(mv.gvRejected||[]).length&&lvl.parentWord?' Fora do mapa, por serem de outr'+(lvl.parentWord==='gerência'?'a':'o')+' '+lvl.parentWord+': '+mv.gvRejected.join(', ')+'.':''}`},\n      (mv.level||'setor')==='setor'?{n:fmt(foraDoSetorCbs),l:`CBs fora ${lvl.de} ${lc(lvl.label)}`,cls:foraDoSetorCbs?'warn':'ok',t:`CBs (dado do Databricks, antes desta etapa) em município(s) que a base de municípios dominantes (dominantesCsvUrl) não atribui a este ${lc(lvl.label)} — ficam fora do mapa e da alocação geográfica e seguem a líder do seu grupo.`}:null,\n      mv.zeroCbsWarning?{n:'⚠',l:'ELO sem CBs neste ciclo',cls:'warn',t:mv.zeroCbsWarning}:null,\n    ].filter(Boolean).map(k=>`<div class=\"kpi ${k.cls||''}\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n    return;}\n  const sv=mv.dest.map(s=>c.span[s]);const spread=sv.length?Math.max(...sv)-Math.min(...sv):0;\n  // Only flags a destination that actually received CBs (c.inc[s]>0) but ended up with no leader\n  // calling it home — a destination that received nothing tripped this trivially (gained is 0\n  // there by definition, never a real \"missing leader\" case) and was just noise on the KPI.\n  const zero=mv.dest.filter(s=>c.gained[s]===0&&c.inc[s]>0);const totCbs=closingCbsOf(mv);\n  $('#kpis').innerHTML=[\n    // \"CBs fora do X\" só faz sentido pra fechamento de setor — a mecânica de vínculo (CBs que\n    // seguem a líder pra fora do território) é uma coisa de setor/orphan, não de GV/mercado.\n    (mv.level||'setor')==='setor'?{n:fmt(c.desloc),l:mv.policy==='fora'?`CBs fora ${lvl.de} ${lc(lvl.destLabel)}`:'CBs c/ quebra de relação',cls:'warn',t:`CBs cuja líder acabou noutr${lvl.a==='à'?'a':'o'} ${lc(lvl.destLabel)} nesta etapa.`}:null,\n    {n:fmt(spread),l:'Desequilíbrio (máx−mín CBs)',cls:spread>800?'warn':'ok',t:`Diferença de tamanho entre ${lvl.destLabel==='GV'?'a maior e a menor':'o maior e o menor'} ${lc(lvl.destLabel)} depois desta etapa.`},\n    {n:fmt(totCbs),l:'CBs realocadas',cls:'',t:`Total de CBs ${lvl.de} ${lc(lvl.label)} ${mv.sector} sendo redistribuídas nesta etapa.`},\n    (mv.level||'setor')==='setor'?{n:zero.length?zero.join(', '):'nenhum',l:`${lvl.destLabel} sem líder herdada`,cls:zero.length?'warn':'ok',t:`${lvl.destLabel} que recebe ${lc(lvl.childLabel)}(s) mas nenhuma líder nesta etapa.`}:null,\n  ].filter(Boolean).concat(mv.excludedCbs?[{n:fmt(mv.excludedCbs),l:`CBs fora do setor · ${fmt(mv.excludedMunis.length)} município(s) fora da base`,cls:'warn',t:'CBs em municípios que a base de dominantes não atribui a este setor: ficam fora do mapa e da alocação geográfica e seguem a líder do seu grupo.'}]:[])\n   .concat((mv.missingMunis||[]).length?[{n:fmt(mv.missingMunis.length),l:`Município(s) da base fora do mapa · ${fmt(mv.csvMunisTotal)} na base`,cls:'warn',t:`A base de dominantes atribui ${fmt(mv.csvMunisTotal)} município(s) ao setor ${mv.sector} e ${fmt(mv.csvMunisTotal-mv.missingMunis.length)} chegaram ao mapa. Fora: ${mv.missingMunis.map(x=>x.muni+' — '+x.reason+(x.cbs?' ('+fmt(x.cbs)+' CBs)':'')).join(' · ')}`}]:[])\n   .concat(mv.zeroCbsWarning?[{n:'⚠',l:'ELO sem CBs neste ciclo',cls:'warn',t:mv.zeroCbsWarning}]:[])\n   .map(k=>`<div class=\"kpi ${k.cls}\" title=\"${k.t}\"><div class=\"n\">${k.n}</div><div class=\"l\">${k.l}</div></div>`).join('');\n}\nfunction renderSpans(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  const spansHd=$('#spansHd'); if(spansHd) spansHd.textContent=`Span por ${lc(lvl.label)} (CBs) — base + entrantes`;\n  if(mv.view==='asis'){const items=[{s:mv.sector,val:sectorTotalCbs(mv),color:CLOSE_COLOR}].concat(mv.dest.map(s=>({s,val:+(mv.baseMetrics[s]?.cbs||0),color:mv.color[s]})));\n    const max=Math.max(...items.map(x=>x.val),1);\n    $('#spans').innerHTML=items.map(x=>`<div class=\"bar-row\"><div class=\"tag\" style=\"color:${x.color}\">${x.s}</div><div class=\"bar-track\">\n      <div class=\"bar-inc\" style=\"width:${x.val/max*100}%;background:${x.color}\"></div></div>\n      <div class=\"bar-val\">${fmt(x.val)}</div></div>`).join('')\n      +`<div class=\"note\">Estrutura antes desta etapa — ${lvl.a} ${lc(lvl.label)} <b>${mv.sector}</b> ainda existe. Alterne pra <b>TOBE</b> pra ver a redistribuição.</div>`;\n    return;}\n  const lim=spanLimits(mv),on=spanLimitsOn(lim);\n  const rawMax=Math.max(...mv.dest.map(s=>c.span[s]),lim.target||0,lim.max||0,1);\n  const max=on?rawMax*1.04:rawMax;   // headroom so a marker sitting at the scale top stays visible\n  const marks='';\n  // A manual município transfer (mv.kind==='transfer') patches mv.baseMetrics[to]/[from] directly\n  // (computeTransferMovement) instead of going through mv.munis/mv.assign — so c.inc[s] (this\n  // stage's own geographic redistribution) never sees it, and the transfer's gain/loss silently\n  // disappeared into \"base\" as if it had already existed before this very stage (always \"+0\" here,\n  // even though this exact stage is the transfer). xferDeltaOf surfaces it as THIS stage's own\n  // +/- instead, exactly like c.inc already does for a normal closing movement.\n  const xferDeltaOf=s=>{ if(mv.kind!=='transfer') return null; if(s===mv.xferTo) return +mv.xferMetrics.cbs; if(s===mv.xferFrom) return -mv.xferMetrics.cbs; return 0; };\n  $('#spans').innerHTML=mv.dest.map(s=>{\n    const xd=xferDeltaOf(s);\n    const delta=xd==null?c.inc[s]:xd;\n    // Only ever highlight a genuine GAIN as the filled \"entrante\" segment — a transfer's LOSS still\n    // shows correctly in the total and in its own (-X) label, but stretching the pale \"base\" segment\n    // past the sector's real current size to depict a shrink would draw a bigger bar for a smaller\n    // number, more confusing than simply not drawing a segment for it at all.\n    const shownInc=xd==null?c.inc[s]:Math.max(xd,0);\n    const base=c.span[s]-shownInc;\n    const over=lim.max&&c.span[s]>lim.max,under=lim.min&&c.span[s]<lim.min;\n    return `<div class=\"bar-row\"><div class=\"tag\" style=\"color:${mv.color[s]}\">${s}</div><div class=\"bar-track\">\n      <div class=\"bar-base\" style=\"width:${base/max*100}%;background:${mv.color[s]}\"></div>\n      <div class=\"bar-inc${xd!=null&&xd>0?' xfer':''}\" style=\"width:${shownInc/max*100}%;background:${mv.color[s]}\"></div>${marks}</div>\n      <div class=\"bar-val\" style=\"${over?'color:var(--closing);font-weight:700':(under?'color:#ff9500;font-weight:700':'')}\">${fmt(c.span[s])} <span style=\"opacity:.6\">(${delta>=0?'+':''}${fmt(delta)})</span>${over?' ▲':''}${under?' ▼':''}</div></div>`;}).join('')\n    +`<div class=\"note\">Claro = base (já incluindo etapas anteriores) · cheio = CBs entrantes ${lvl.de} ${mv.sector} nesta etapa.${mv.kind==='transfer'?' Nesta etapa, a faixa <b>listrada</b> é a transferência manual de município (perda no setor de origem aparece só no rótulo, sem faixa).':''}${c.orphanTotal?` Inclui <b>${fmt(c.orphanTotal-c.orphanStranded)}</b> CB(s) de municípios fora da base de dominantes que seguiram suas líderes.`:''}</div>`;\n}\n// Two very different reasons land rows in the __OUTROS__ bucket — no IBGE shape, or outside the\n// sector's dominance base. Same bucket computationally, but they must not read as the same thing.\nfunction outsideNote(mv){\n  const bits=[];\n  if(mv.noShapeCbs) bits.push(`<b>${fmt(mv.noShapeCbs)}</b> CBs em ${fmt(mv.noShapeMunis.length)} município(s) sem shape na UF`);\n  if(mv.excludedCbs) bits.push(`<b>${fmt(mv.excludedCbs)}</b> CBs em ${fmt(mv.excludedMunis.length)} município(s) <b>fora da base de dominantes</b> deste setor — <b style=\"color:var(--closing)\">quebra de vínculo</b>`);\n  if(!bits.length) return '';\n  let h=`<div class=\"note\">Fora do mapa e da alocação geográfica: ${bits.join(' · ')}.`;\n  if(mv.excludedCbs){\n    const c=computeDerived(mv);\n    h+=` As <b>${fmt(mv.excludedCbs)}</b> CBs em quebra de vínculo <b>seguem a líder do seu grupo</b>`;\n    const byS=mv.dest.filter(s=>c.orph[s]).map(s=>`<b style=\"color:${mv.color[s]}\">${s}</b> (${fmt(c.orph[s])})`);\n    h+=byS.length?`: ${byS.join(' · ')}.`:'.';\n    if(c.orphanStranded) h+=` <b style=\"color:var(--closing)\">${fmt(c.orphanStranded)} CB(s) sem destino</b> — a líder não ficou com nenhum município realocado, então não há setor pra seguir.`;\n  }\n  if(mv.noShapeCbs) h+=` As CBs sem shape na UF continuam fora de qualquer setor destino.`;\n  if(mv.excludedMunis&&mv.excludedMunis.length) h+=`<br><span style=\"opacity:.85\">${mv.excludedMunis.slice(0,8).map(x=>x.muni+' ('+fmt(x.cbs)+')').join(', ')}${mv.excludedMunis.length>8?' +'+(mv.excludedMunis.length-8):''}</span>`;\n  return h+`</div>`;\n}\nlet PULL_SET=new WeakSet();\nlet PULL_CTX=null;\nlet PULL_LOADING=new WeakSet();\nfunction pullModeOn(mv){ return !!mv && PULL_SET.has(mv); }\nasync function loadNbrCbs(mv){\n  const secs=[...new Set(Object.values(mv.nbrCurrentSector||{}).filter(Boolean))];\n  const out={};\n  if(secs.length){\n    const sql=`SELECT t.DS_MUNICIPIO municipio, t.DS_ESTADO uf, t.CD_SETOR setor, COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs\n      FROM ${T} t LEFT JOIN ${ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=${SESS.eloMaxCycle} AND e.country_code=1\n      WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR IN (${secs.join(',')}) AND t.DT_HISTORICO=${SESS.snapLiteral}\n      GROUP BY t.DS_MUNICIPIO, t.DS_ESTADO, t.CD_SETOR`;\n    const recs=rows(await q(sql));\n    recs.forEach(r=>{\n      const ru=r.uf||DOM.ufBySetorMuni[String(r.setor)+'|'+muniN(r.municipio)]||'';\n      const k=mkey(r.municipio,ru);\n      out[k]=(out[k]||0)+ +r.cbs;\n    });\n  }\n  mv.nbrCbsByMuni=out;\n}\nasync function togglePullMode(){\n  const mv=SESS.timeline[SESS.selected]; if(!mv||(mv.level||'setor')!=='setor'||PULL_LOADING.has(mv)) return;\n  if(PULL_SET.has(mv)){ PULL_SET.delete(mv); drawMap(); renderMuni(); return; }\n  PULL_SET.add(mv);\n  if(!mv.nbrCbsByMuni){\n    PULL_LOADING.add(mv); renderMuni();\n    try{ await loadNbrCbs(mv); }catch(e){}\n    finally{ PULL_LOADING.delete(mv); }\n  }\n  drawMap();\n  renderMuni();\n}\nfunction closePullConfirm(){ const b=$('#pullBg'); if(b) b.classList.add('hidden'); PULL_CTX=null; }\nfunction openPullConfirm(fromSector,muniKey){\n  const mv=SESS.timeline[SESS.selected]; if(!mv) return;\n  PULL_CTX={from:String(fromSector),muni:muniKey};\n  const info=$('#pullMuniInfo'); if(info) info.innerHTML=`<b>${mDisp(muniKey)}</b> — hoje ${lc(LEVELS[mv.level||'setor'].destLabel)} <b>${esc(String(fromSector))}</b>.`;\n  const seg=$('#pullDestSeg');\n  if(seg){\n    seg.innerHTML=mv.dest.map((s,i)=>`<button data-s=\"${s}\" class=\"${i===0?'on':''}\" style=\"${i===0?'background:'+mv.color[s]:''}\">${s}</button>`).join('');\n    seg.querySelectorAll('button').forEach(b=>b.onclick=()=>{\n      seg.querySelectorAll('button').forEach(x=>{x.classList.remove('on');x.style.background='';});\n      b.classList.add('on'); b.style.background=mv.color[b.dataset.s]||'';\n    });\n  }\n  const st=$('#pullStatus'); if(st) st.textContent='';\n  const bg=$('#pullBg'); if(bg) bg.classList.remove('hidden');\n  const cx=$('#pullClose'); if(cx) cx.onclick=closePullConfirm;\n  const cc=$('#pullCancel'); if(cc) cc.onclick=closePullConfirm;\n  const go=$('#pullGo'); if(go){ go.disabled=false; go.textContent='Aplicar'; go.onclick=confirmPull; }\n  if(cc) cc.focus();\n}\nasync function confirmPull(){\n  if(!PULL_CTX) return;\n  const onBtn=$('#pullDestSeg')&&$('#pullDestSeg').querySelector('button.on');\n  const to=onBtn&&onBtn.dataset.s; if(!to) return;\n  const go=$('#pullGo'); if(go){ go.disabled=true; go.textContent='Aplicando…'; }\n  try{\n    await applyTransfer(PULL_CTX.from,to,PULL_CTX.muni);\n    closePullConfirm();\n    await flushDirty();\n    renderAll();\n  }catch(e){\n    const st=$('#pullStatus'); if(st) st.textContent='erro: '+e.message;\n    if(go){ go.disabled=false; go.textContent='Aplicar'; }\n  }\n}\nfunction renderMuni(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const lvl=LEVELS[mv.level||'setor'];\n  const plural=lvl.childLabel==='Setor'?'Setores':(lvl.childLabel==='GV'?'GVs':'Municípios');\n  const fem=lvl.childLabel==='GV';\n  const t=$('#muniListTitle'); if(t) t.textContent=`${plural} → ${lc(lvl.destLabel)} destino`;\n  const nt=$('#muniListNote'); if(nt) nt.innerHTML=`Clique no <b>código ${lvl.a} ${lc(lvl.destLabel)}</b> pra mandar ${lvl.a} ${lc(lvl.childLabel)} <b>inteir${fem?'a':'o'}</b> pra lá (regra de negócio). A cor acompanha o mapa.`;\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  if(mv.view==='asis'){$('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"muni-item-hd\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(mv.munis[m].cbs)} CBs</div></div><span class=\"badge\" style=\"background:${CLOSE_COLOR};color:#fff;border-color:${CLOSE_COLOR}\">${lc(lvl.label)} ${mv.sector}</span></div>`).join('')\n      +`<div class=\"note\">Hoje tod${fem?'as':'os'} pertencem ${lvl.a} ${lc(lvl.label)} <b>${mv.sector}</b>. No <b>TOBE</b> cada um vira botões de destino.</div>`+outsideNote(mv);return;}\n  const xferBlock=(mv.kind==='transfer')?(\n    `<div class=\"note\" style=\"margin-top:10px;font-weight:600;color:var(--ink)\">Municípios do setor ${mv.xferFrom}</div>\n    <div class=\"muni-item\"><div class=\"muni-item-hd\"><div class=\"nm\">${mDisp(mv.xferMuni)}</div><div class=\"ct\">${fmt(mv.xferMetrics.cbs)} CBs</div></div>\n      <div class=\"seg seg-xfer\">${mv.dest.map(s=>`<button data-s=\"${s}\" class=\"${mv.xferTo===s?'on':''}\" style=\"${mv.xferTo===s?'background:'+mv.color[s]:''}\">${s}</button>`).join('')}</div>\n    </div>`\n  ):'';\n  $('#muniList').innerHTML=ms.map(m=>`<div class=\"muni-item\"><div class=\"muni-item-hd\"><div class=\"nm\">${m}</div><div class=\"ct\">${fmt(mv.munis[m].cbs)} CBs</div></div>\n    <div class=\"seg\" data-m=\"${m}\">${mv.dest.map(s=>`<button data-s=\"${s}\" class=\"${mv.assign[m]===s?'on':''}\" style=\"${mv.assign[m]===s?'background:'+mv.color[s]:''}\">${s}</button>`).join('')}</div></div>`).join('')\n    +xferBlock+outsideNote(mv);\n  $('#muniList').querySelectorAll('.seg:not(.seg-xfer) button').forEach(b=>b.onclick=()=>{const m=b.parentElement.dataset.m,s=b.dataset.s;editSelected(m2=>{m2.assign[m]=s;});});\n  $('#muniList').querySelectorAll('.seg-xfer button').forEach(b=>b.onclick=()=>setTransferTarget(b.dataset.s));\n  const tb=$('#togglePull'); if(tb){ const isSetor=(mv.level||'setor')==='setor'; const loading=PULL_LOADING.has(mv);\n    tb.classList.toggle('hidden',!isSetor); tb.classList.toggle('on',isSetor&&pullModeOn(mv));\n    tb.disabled=loading; tb.innerHTML=loading?'<span class=\"spinning\">◌</span>':'✎'; tb.onclick=togglePullMode; }\n}\nfunction renderLeaders(){const mv=SESS.timeline[SESS.selected]; if(!mv){ renderXferLeaders(null); return; } const c=computeDerived(mv);\n  const showOrph=c.orphanTotal>0;\n  const lvl=LEVELS[mv.level||'setor'];\n  const titleEl=$('#leadersTitle'), noteEl=$('#leadersNote');\n  if((mv.level||'setor')!=='setor'){\n    // GV/mercado: quem troca de dono é o setor/GV inteiro (mv.assign), nunca a líder isolada — o\n    // grão que importa aqui é MUNICÍPIO × UNIDADE (a mesma matriz de renderMatrix/renderMatrixTobe),\n    // não líder × unidade: um município com CBs em mais de uma unidade (compartilhado entre setores\n    // da mesma GV) já é o caso que esta tabela existe pra revelar, e não depende de ASIS/TOBE — é\n    // posse ATUAL, a mesma nos dois modos.\n    if(titleEl) titleEl.textContent=`Municípios por ${lc(lvl.childLabel)} (nesta etapa)`;\n    if(noteEl) noteEl.innerHTML=`Cada linha é um município; os blocos são o(s) ${lc(lvl.childLabel)}(s) onde ele tem CBs, largura proporcional ao total do bloco — mais de um bloco já é compartilhamento entre ${lc(lvl.childLabel)}s.`;\n    const units=Object.keys(mv.unitMunis||{}).sort((a,b)=>(mv.munis[b]?.cbs||0)-(mv.munis[a]?.cbs||0));\n    const ms=Object.keys(mv.geoFeat||{}).sort((a,b)=>(mv.cbsByMuni[b]||0)-(mv.cbsByMuni[a]||0));\n    if(!units.length||!ms.length){\n      $('#leaders').innerHTML=`<tr><td class=\"note\" style=\"padding:10px\">Nenhum ${lc(lvl.childLabel)} com município desenhado nesta etapa.</td></tr>`;\n      renderXferLeaders(mv);\n      return;\n    }\n    const unitOfRow=r=>mv.level==='gv'?String(r.setor):(DOM.gvBySetor[String(r.setor)]||'');\n    const cellBy={};\n    mv.matrix.forEach(r=>{ const m=mkey(r.municipio,r.uf); if(!mv.geoFeat[m]) return; const u=unitOfRow(r); if(!u) return;\n      cellBy[m+'|'+u]=(cellBy[m+'|'+u]||0)+ +r.cbs; });\n    const cell=(m,u)=>cellBy[m+'|'+u]||0;\n    const mx=Math.max(...ms.flatMap(m=>units.map(u=>cell(m,u))),1);\n    // Uma coluna por unidade virava uma grade enorme e quase toda vazia — a esmagadora maioria dos\n    // municípios pertence a UMA única unidade. Em vez de coluna fixa, cada linha carrega só os\n    // blocos das unidades onde aquele município REALMENTE tem CBs (segsFor), largura proporcional\n    // (flex-grow=v, base 0) — um compartilhamento entre unidades vira mais de um bloco na mesma\n    // linha, sem precisar de coluna nem seção própria pra revelar o caso.\n    const segsFor=m=>units.map(u=>({u,v:cell(m,u)})).filter(s=>s.v>0).sort((a,b)=>b.v-a.v);\n    $('#leaders').innerHTML=`<thead><tr><th>Município</th><th style=\"text-align:left\">${lvl.childLabel}(s) — CBs</th><th>Tot</th></tr></thead><tbody>`\n      +ms.map(m=>{\n        const tot=mv.cbsByMuni[m]||0; const segs=segsFor(m);\n        const segsHtml=segs.length\n          ?`<div style=\"display:flex;gap:2px;min-height:30px\">`\n            +segs.map(({u,v})=>{const pct=tot?Math.round(v/tot*100):0;\n              return `<div style=\"flex:${v} 0 0%;min-width:2px;background:rgba(229,181,63,${.15+v/mx*.5});border-radius:4px;display:flex;flex-direction:column;align-items:center;justify-content:center;overflow:hidden;padding:2px 4px;font-size:10.5px;line-height:1.25;white-space:nowrap\" title=\"${lvl.childLabel} ${u} · ${fmt(v)} CBs (${pct}%)\"><b>${u}</b><span style=\"opacity:.65\">${fmt(v)} (${pct}%)</span></div>`;\n            }).join('')\n          +`</div>`\n          :`<span style=\"color:var(--mut)\">·</span>`;\n        return `<tr><td>${m}</td><td>${segsHtml}</td><td style=\"font-weight:700\">${fmt(tot)}</td></tr>`;\n      }).join('')\n      +`<tr style=\"font-weight:700\"><td>TOTAL</td><td></td><td>${fmt(ms.reduce((a,m)=>a+(mv.cbsByMuni[m]||0),0))}</td></tr>`\n      +`</tbody>`;\n    renderXferLeaders(mv);\n    return;\n  }\n  if(titleEl) titleEl.textContent='Líderes — destino e CBs deslocadas (nesta etapa)';\n  if(noteEl) noteEl.innerHTML=`<b>Home</b> = setor que ficou com a maioria das CBs da líder. <b style=\"color:var(--closing)\">Deslocadas</b> = CBs dela que caíram noutro setor.`;\n  if(mv.view==='asis'){$('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>Grupo</th><th>CBs</th><th>${LEVELS[mv.level||'setor'].label}</th></tr></thead><tbody>`\n    +mv.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${L.grupo}</td><td>${fmt(L.total)}</td><td><span class=\"home\" style=\"background:${CLOSE_COLOR}\"></span>${mv.sector}</td></tr>`).join('')\n    +`<tr style=\"font-weight:700\"><td>TOTAL</td><td></td><td>${fmt(mv.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td></tr>`+`</tbody>`;renderXferLeaders(mv);return;}\n  $('#leaders').innerHTML=`<thead><tr><th>Líder</th><th>CBs</th><th>Home</th>${mv.dest.map(s=>`<th>${s}</th>`).join('')}${showOrph?'<th title=\"CBs em municípios fora da base de dominantes que seguiram esta líder\">Fora do setor</th>':''}<th>Desloc.</th></tr></thead><tbody>`\n   +c.leaders.map(L=>`<tr><td>${(L.lider||'').split(' ').slice(0,2).join(' ')}</td><td>${fmt(L.total)}</td>\n     <td>${L.home?`<span class=\"home\" style=\"background:${mv.color[L.home]}\"></span>${L.home}`:'–'}</td>\n     ${mv.dest.map(s=>`<td class=\"cell\" style=\"${s===L.home?'color:'+mv.color[s]+';font-weight:700':''}\">${L.byS[s]||'·'}</td>`).join('')}\n     ${showOrph?`<td class=\"cell\" style=\"${L.orphan&&!L.orphanHome?'color:var(--closing);font-weight:700':''}\" title=\"${L.orphan?(L.orphanHome?'seguem a líder para o setor '+L.orphanHome:'sem home — CBs sem setor pra seguir'):''}\">${L.orphan?fmt(L.orphan)+(L.orphanHome?'':' ⚠'):'·'}</td>`:''}\n     <td style=\"color:${L.desloc?'var(--closing)':'var(--mut)'};font-weight:${L.desloc?700:400}\">${L.desloc||'0'}</td></tr>`).join('')\n   +`<tr style=\"font-weight:700\"><td>TOTAL</td><td>${fmt(c.leaders.reduce((a,L)=>a+L.total,0))}</td><td></td>${mv.dest.map(s=>`<td>${fmt(c.leaders.reduce((a,L)=>a+L.byS[s],0))}</td>`).join('')}${showOrph?`<td>${fmt(c.orphanTotal)}</td>`:''}<td style=\"color:var(--closing)\">${fmt(c.desloc)}</td></tr>`+`</tbody>`;\n  renderXferLeaders(mv);\n}\nfunction renderXferLeaders(mv){\n  const block=$('#leadersXferBlock'); if(!block) return;\n  const hasXfer = mv && mv.kind==='transfer' && mv.xferRows && mv.xferRows.length;\n  block.classList.toggle('hidden', !hasXfer);\n  if(!hasXfer) return;\n  const title=$('#leadersXferTitle');\n  if(title) title.textContent=`Transferência manual — ${mDisp(mv.xferMuni)} (${mv.xferFrom} → ${mv.xferTo})`;\n  $('#leadersXfer').innerHTML=`<thead><tr><th>Líder</th><th>CBs</th><th>Grupo</th></tr></thead><tbody>`\n    +mv.xferRows.map(r=>`<tr><td>${esc((r.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+r.grupo))}</td><td>${fmt(r.cbs)}</td><td>${r.grupo}</td></tr>`).join('')\n    +`</tbody>`;\n}\nfunction renderXferBreakdown(mv, tableId, blockId, titleId){\n  const block=$('#'+blockId); if(!block) return;\n  const hasXfer = mv && mv.kind==='transfer' && mv.xferRows && mv.xferRows.length;\n  block.classList.toggle('hidden', !hasXfer);\n  if(!hasXfer) return;\n  const title=$('#'+titleId);\n  if(title) title.textContent=`Transferência manual — ${mDisp(mv.xferMuni)} (${mv.xferFrom} → ${mv.xferTo})`;\n  const rowsData=mv.xferRows;\n  const mx=Math.max(...rowsData.map(r=>+r.cbs||0),1);\n  const short=r=>(r.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+r.grupo);\n  $('#'+tableId).innerHTML=`<thead><tr><th>Município</th>${rowsData.map(r=>`<th title=\"${esc(r.lider||'')}\">${r.grupo}<br><span style=\"font-weight:400;font-size:10px;color:var(--mut)\">${esc(short(r))}</span></th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n    +`<tr><td>${mDisp(mv.xferMuni)}</td>`\n    +rowsData.map(r=>{const v=+r.cbs||0; return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v||'·'}</td>`;}).join('')\n    +`<td style=\"font-weight:700\">${fmt(mv.xferMetrics.cbs)}</td></tr></tbody>`;\n}\nfunction renderMatrix(){const mv=SESS.timeline[SESS.selected]; if(!mv) return;\n  // GV/mercado: a unidade que troca de dono é setor/GV, não município — então esta matriz cruza\n  // MUNICÍPIO × UNIDADE (setor fechando GV, GV fechando mercado) em vez de município × grupo.\n  // mv.matrix já carrega r.setor em toda linha (a query do CRM sempre pediu isso), então dá pra\n  // derivar a unidade de cada linha sem nenhuma consulta nova — unitOfRow abaixo é a mesma tradução\n  // setor→GV que resolveUnit/aggregateMovement já usam pra este grão.\n  if((mv.level||'setor')!=='setor'){\n    const lvl=LEVELS[mv.level];\n    const units=Object.keys(mv.unitMunis||{}).sort((a,b)=>(mv.munis[b]?.cbs||0)-(mv.munis[a]?.cbs||0));\n    const ms=Object.keys(mv.geoFeat||{}).sort((a,b)=>(mv.cbsByMuni[b]||0)-(mv.cbsByMuni[a]||0));\n    if(!units.length||!ms.length){ $('#matrix').innerHTML=`<tr><td class=\"note\" style=\"padding:10px\">Nenhum ${lc(lvl.childLabel)} com município desenhado nesta etapa.</td></tr>`; renderXferBreakdown(null,'matrixXfer','matrixXferBlock','matrixXferTitle'); return; }\n    const unitOfRow=r=>mv.level==='gv'?String(r.setor):(DOM.gvBySetor[String(r.setor)]||'');\n    const cellBy={};\n    mv.matrix.forEach(r=>{ const m=mkey(r.municipio,r.uf); if(!mv.geoFeat[m]) return; const u=unitOfRow(r); if(!u) return;\n      cellBy[m+'|'+u]=(cellBy[m+'|'+u]||0)+ +r.cbs; });\n    const cell=(m,u)=>cellBy[m+'|'+u]||0;\n    const mx=Math.max(...ms.flatMap(m=>units.map(u=>cell(m,u))),1);\n    $('#matrix').innerHTML=`<thead><tr><th>Município</th>${units.map(u=>`<th>${lvl.childLabel} ${u}</th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n      +ms.map(m=>{const tot=mv.cbsByMuni[m]||0; return `<tr><td>${m}</td>`\n        +units.map(u=>{const v=cell(m,u);const pct=tot?Math.round(v/tot*100):0;return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v?`${fmt(v)} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n        +`<td style=\"font-weight:700\">${fmt(tot)}</td></tr>`;}).join('')\n      +`<tr style=\"font-weight:700\"><td>TOTAL</td>${units.map(u=>`<td>${fmt(ms.reduce((a,m)=>a+cell(m,u),0))}</td>`).join('')}<td>${fmt(ms.reduce((a,m)=>a+(mv.cbsByMuni[m]||0),0))}</td></tr>`\n      +`</tbody>`;\n    renderXferBreakdown(null,'matrixXfer','matrixXferBlock','matrixXferTitle');\n    return;\n  }\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  // `_out` is a flag stamped on the ROW, and rows are shared between etapas (absorbed + inherited\n  // orphans), so the last etapa to aggregate wins and reading it here can describe the wrong etapa.\n  // Derive the bucket from THIS mv instead. Summing rather than .find() because several out-municípios\n  // of the same group all collapse into __OUTROS__, and only the first was being counted.\n  const outOf=x=>!mv.geoFeat[mkey(x.municipio,x.uf)];\n  const cell=(m,g)=>mv.matrix.reduce((a,x)=>a+((((outOf(x)?'__OUTROS__':mkey(x.municipio,x.uf))===m)&&String(x.grupo)===g)?+x.cbs:0),0);\n  const mx=Math.max(...ms.flatMap(m=>mv.leaders.map(L=>cell(m,L.grupo))),1);\n  const short=L=>(L.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+L.grupo);\n  // No destination column/home dot here (unlike TOBE). This is the AS-IS matrix: every município in\n  // it still belongs to the closing sector, so showing where it is HEADED mixed the two states of\n  // the world in one table and made the ASIS view read like a plan. The destination lives on the\n  // map and in \"Municípios → setor destino\", which are about the to-be.\n  $('#matrix').innerHTML=`<thead><tr><th>Município | Grupo | Líder</th>${mv.leaders.map(L=>`<th title=\"${esc(L.lider||'')}\">${L.grupo}<br><span style=\"font-weight:400;font-size:10px;color:var(--mut)\">${esc(short(L))}</span></th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +ms.map(m=>{const tot=mv.munis[m].cbs; return `<tr><td>${m}</td>`\n     +mv.leaders.map(L=>{const v=cell(m,L.grupo);const pct=L.total?Math.round(v/L.total*100):0;return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v?`${v} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(tot)}</td></tr>`;}).join('')\n   +`<tr class=\"lider-foot\"><td>Municípios fora do setor</td>`\n     +mv.leaders.map(L=>{const v=(mv.foraDoSetorByGrupo&&mv.foraDoSetorByGrupo[L.grupo])||0;const pct=L.total?Math.round(v/L.total*100):0; return `<td title=\"CBs desta líder (dado do Databricks) em município(s) que a base de dominantes (dominantesCsvUrl) não atribui ao setor ${mv.sector}\">${v?`${fmt(v)} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n     +`<td></td></tr>`\n   +`</tbody>`;\n  renderXferBreakdown(mv,'matrixXfer','matrixXferBlock','matrixXferTitle');\n}\n// TOBE counterpart of the AS-IS matrix: the same município × grupo grid plus the two columns the AS-IS\n// view deliberately refuses to carry — where the município ends up, and under which líder.\n// \"Líder resultante\" is the líder holding the most CBs in that município. A município is served by several\n// grupos at once and the data names no single owner, so the dominant one is the honest answer and its\n// share is printed beside it instead of being hidden behind a single name.\nfunction renderMatrixTobe(){const mv=SESS.timeline[SESS.selected]; const el=$('#matrixTobe'); const nt=$('#tobeNote');\n  if(!el) return;\n  if(!mv||!mv.munis){ el.innerHTML=''; if(nt) nt.innerHTML=''; renderXferBreakdown(null,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle'); return; }\n  if((mv.level||'setor')!=='setor'){\n    // Mesma matriz município × unidade do ASIS (renderMatrix), mais o que o TOBE sempre acrescenta:\n    // pra onde cada linha/coluna vai. Aqui o destino é simples pelo motivo oposto do setor-level: não\n    // há \"líder dominante\" a inferir — cada unidade (coluna) já tem UM destino fixo (mv.assign[u]),\n    // então o próprio município herda esse destino de mv.unitOfMuni sem precisar de heurística nenhuma.\n    const lvl=LEVELS[mv.level];\n    const units=Object.keys(mv.unitMunis||{}).sort((a,b)=>(mv.munis[b]?.cbs||0)-(mv.munis[a]?.cbs||0));\n    const ms=Object.keys(mv.geoFeat||{}).sort((a,b)=>(mv.cbsByMuni[b]||0)-(mv.cbsByMuni[a]||0));\n    if(!units.length||!ms.length){ el.innerHTML=`<tr><td class=\"note\" style=\"padding:10px\">Nenhum ${lc(lvl.childLabel)} com município desenhado nesta etapa.</td></tr>`; if(nt) nt.innerHTML=''; renderXferBreakdown(null,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle'); return; }\n    const unitOfRow=r=>mv.level==='gv'?String(r.setor):(DOM.gvBySetor[String(r.setor)]||'');\n    const cellBy={};\n    mv.matrix.forEach(r=>{ const m=mkey(r.municipio,r.uf); if(!mv.geoFeat[m]) return; const u=unitOfRow(r); if(!u) return;\n      cellBy[m+'|'+u]=(cellBy[m+'|'+u]||0)+ +r.cbs; });\n    const cell=(m,u)=>cellBy[m+'|'+u]||0;\n    const mx=Math.max(...ms.flatMap(m=>units.map(u=>cell(m,u))),1);\n    el.innerHTML=`<thead><tr><th>Município</th>${units.map(u=>`<th>${lvl.childLabel} ${u}</th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n      +ms.map(m=>{const tot=mv.cbsByMuni[m]||0; const uOf=mv.unitOfMuni[m]; const dest=uOf?mv.assign[uOf]:null;\n        return `<tr><td>${dest?`<span class=\"home\" style=\"background:${mv.color[dest]}\"></span>`:''}${m}</td>`\n        +units.map(u=>{const v=cell(m,u);const pct=tot?Math.round(v/tot*100):0;return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v?`${fmt(v)} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n        +`<td style=\"font-weight:700\">${fmt(tot)}</td></tr>`;}).join('')\n      +`<tr class=\"lider-foot\"><td>Destino</td>`\n        +units.map(u=>{const dest=mv.assign[u]; return `<td>${dest?`<span class=\"home\" style=\"background:${mv.color[dest]}\"></span>${dest}`:'–'}</td>`;}).join('')\n        +`<td></td></tr>`\n      +`</tbody>`;\n    if(nt) nt.innerHTML=`<b>Destino</b> (rodapé) = pra onde vai cada ${lc(lvl.childLabel)} inteiro (coluna) — fixo pra todos os municípios dele, já que quem troca de dono é o ${lc(lvl.childLabel)}, não o município.`;\n    renderXferBreakdown(null,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle');\n    return;\n  }\n  const c=computeDerived(mv);\n  const homeOf={}; c.leaders.forEach(L=>{ homeOf[L.grupo]=L.home; });\n  const homeCbsOf={}; c.leaders.forEach(L=>{ homeCbsOf[L.grupo]=(L.home&&L.total)?Math.round((L.byS[L.home]||0)/L.total*100):0; });\n  const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>mv.munis[b].cbs-mv.munis[a].cbs);\n  // One pass over the matrix instead of a scan per cell: the AS-IS table can afford the naive version\n  // on six municípios, but this one asks for the dominant líder too, which multiplies the lookups.\n  const outOf=x=>!mv.geoFeat[mkey(x.municipio,x.uf)];\n  const cellBy={}; mv.matrix.forEach(x=>{ const k=(outOf(x)?'__OUTROS__':mkey(x.municipio,x.uf))+'|'+String(x.grupo);\n    cellBy[k]=(cellBy[k]||0)+ +x.cbs; });\n  const cell=(m,g)=>cellBy[m+'|'+g]||0;\n  const mx=Math.max(...ms.flatMap(m=>mv.leaders.map(L=>cell(m,L.grupo))),1);\n  const domLeader=m=>{ let best=null,bc=0;\n    mv.leaders.forEach(L=>{ const v=cell(m,L.grupo); if(v>bc){ bc=v; best=L; } });\n    return best?{L:best,cbs:bc}:null; };\n  const short=L=>(L.lider||'').split(' ').slice(0,2).join(' ')||('grupo '+L.grupo);\n  // NOT named `rows` — that is the module-level Databricks response helper, and shadowing it inside a\n  // render function is a trap for whoever adds a query here later.\n  // Off-mismatch ainda calculado pra alimentar a nota abaixo da tabela — só não vira mais coluna: o\n  // destino agora é \"Setor destino\" por GRUPO no rodapé, não mais por município na grade.\n  const recs=ms.map(m=>{ const d=mv.assign[m]||null, dl=domLeader(m);\n    const home=dl?(homeOf[dl.L.grupo]||null):null;\n    return {m:m,off:!!(dl&&home&&d&&home!==d)}; });\n  el.innerHTML=`<thead><tr><th>Município | Grupo | Líder</th>${mv.leaders.map(L=>`<th title=\"${esc(L.lider||'')}\">${L.grupo}<br><span style=\"font-weight:400;font-size:10px;color:var(--mut)\">${esc(short(L))}</span></th>`).join('')}<th>Tot</th></tr></thead><tbody>`\n   +ms.map(m=>`<tr><td><span class=\"home\" style=\"background:${mv.color[mv.assign[m]]||'#ccc'}\"></span>${m}</td>`\n     +mv.leaders.map(L=>{const v=cell(m,L.grupo);const pct=L.total?Math.round(v/L.total*100):0;return `<td class=\"cell\" style=\"${v?`background:rgba(229,181,63,${.08+v/mx*.5})`:''}\">${v?`${v} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n     +`<td style=\"font-weight:700\">${fmt(mv.munis[m].cbs)}</td></tr>`).join('')\n   // Um rótulo de destino por GRUPO (coluna), não por município (linha) — cada grupo tem UM home\n   // fixo (computeDerived já calculou), então ele mora uma vez no rodapé, igual o nome já morava.\n   +`<tr class=\"lider-foot\"><td>Municípios fora do setor</td>`\n     +mv.leaders.map(L=>{const v=(mv.foraDoSetorByGrupo&&mv.foraDoSetorByGrupo[L.grupo])||0;const pct=L.total?Math.round(v/L.total*100):0; return `<td title=\"CBs desta líder (dado do Databricks) em município(s) que a base de dominantes (dominantesCsvUrl) não atribui ao setor ${mv.sector}\">${v?`${fmt(v)} <span style=\"opacity:.6;font-size:10px\">(${pct}%)</span>`:'·'}</td>`;}).join('')\n     +`<td></td></tr>`\n   +`<tr class=\"lider-foot\"><td>Setor destino</td>`\n     +mv.leaders.map(L=>{const h=homeOf[L.grupo]; return `<td>${h?`<span class=\"home\" style=\"background:${mv.color[h]}\"></span>${h}`:'–'}</td>`;}).join('')\n     +`<td></td></tr>`\n   +`<tr class=\"lider-foot\"><td>CBs dentro do setor</td>`\n     +mv.leaders.map(L=>{const h=homeOf[L.grupo]; const v=homeCbsOf[L.grupo]||0; return `<td>${h?v+'%':'–'}</td>`;}).join('')\n     +`<td></td></tr>`     +`</tbody>`;\n  renderXferBreakdown(mv,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle');\n  if(nt){\n    const offN=recs.filter(r=>r.off).length;\n    nt.innerHTML=(mv.policy!=='fora')\n      ? `Política <b>quebrar relação</b>: as CBs deixam a líder atual e passam à estrutura do setor destino — o rodapé mostra pra onde cada grupo vai.`\n      : `<b>Setor destino</b> = o setor que recebe a maior parte das CBs de cada líder (rodapé, uma vez por grupo). `\n        +(offN?`<span style=\"color:var(--closing)\">${fmt(offN)} município(s) ⚠</span> têm a líder dominante indo para um setor diferente do dela — as CBs seguem a líder (é disso que o KPI “CBs fora do setor” é feito).`\n          :`Em todos os municípios a líder dominante termina no mesmo setor que recebe o território.`);\n  }\n}\nfunction renderPolicy(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  // O toggle fora-do-setor/quebra-de-relação é uma decisão de setor fechando — em GV/mercado a\n  // seção some inteira em vez de mostrar um controle que não corresponde a nada calculado.\n  const sec=$('#policySection'); if(sec) sec.classList.toggle('hidden',(mv.level||'setor')!=='setor');\n  if((mv.level||'setor')!=='setor') return;\n  $('#policyNote').innerHTML=mv.policy==='fora'\n    ?`<b style=\"color:var(--acc)\">Mantendo vínculo:</b> ${fmt(c.desloc)} CBs seguem com a líder mas ficam <b>fisicamente noutr${lvl.a==='à'?'a':'o'} ${lc(lvl.destLabel)}</b>. Preserva relação; suja geografia.`\n    :`<b style=\"color:var(--acc)\">Quebrando vínculo:</b> ${fmt(c.desloc)} CBs <b>re-vinculam a uma líder d${lvl.a==='à'?'a':'o'} ${lc(lvl.destLabel)} destino</b>. Geografia limpa; rompe relação.`;\n}\nfunction renderPlan(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor']; const ms=Object.keys(mv.munis).filter(m=>m!=='__OUTROS__').sort((a,b)=>a.localeCompare(b));\n  // GV/mercado: a chave \"m\" aqui é um código de setor/GV, não uma chave município/UF — mName/mUf\n  // parseariam lixo, então o CSV de plano exporta o código cru nesses níveis.\n  mv._plan=ms.map(m=>(mv.level&&mv.level!=='setor')?{municipio:m,uf:'',cbs:mv.munis[m].cbs,destino:mv.assign[m]}:{municipio:mName(m),uf:mUf(m),cbs:mv.munis[m].cbs,destino:mv.assign[m]});\n  $('#plan').innerHTML=`Etapa ${SESS.selected+1} · ${lvl.label} <b>${mv.sector}</b> → ${ms.map(m=>`${m}→<b style=\"color:${mv.color[mv.assign[m]]}\">${mv.assign[m]||'–'}</b>`).join(' · ')}<br>\n   Política: <b>${mv.policy==='fora'?`manter vínculo (CBs fora ${lvl.de} ${lc(lvl.destLabel)})`:'quebrar relação'}</b> · ${fmt(c.desloc)} CBs impactadas nesta etapa.`\n   +(c.orphanTotal?`<br>Fora da base de dominantes: ${fmt(c.orphanTotal)} CB(s) seguem suas líderes — ${mv.dest.filter(s=>c.orph[s]).map(s=>`${s} (${fmt(c.orph[s])})`).join(' · ')||'–'}${c.orphanStranded?` · <b style=\"color:var(--closing)\">${fmt(c.orphanStranded)} sem líder com destino</b>`:''}. Não entram no CSV, que é por município.`:'')\n   +(mv.kind==='transfer'?`<br><b style=\"color:var(--acc)\">Transferência manual</b>: ${mName(mv.xferMuni)} (${mUf(mv.xferMuni)}) — ${mv.xferFrom} → ${mv.xferTo} · ${fmt(mv.xferMetrics.cbs)} CBs`:'');\n}\nfunction renderReport(){const mv=SESS.timeline[SESS.selected]; if(!mv) return; const c=computeDerived(mv); const lvl=LEVELS[mv.level||'setor'];\n  mv.metric=mv.metric||'cbs';\n  const KEYS=METRICS_DEF.map(m=>m[0]);\n  const incBy={};mv.dest.forEach(s=>incBy[s]=Object.fromEntries(KEYS.map(k=>[k,0])));\n  Object.entries(mv.munis).forEach(([m,o])=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(!s||!incBy[s])return;KEYS.forEach(k=>incBy[s][k]+=o[k]||0);});\n  // orphan CBs land on their leader's home sector, not on a geographic destination\n  c.leaders.forEach(L=>{ const om=mv.orphanMetricsByGroup&&mv.orphanMetricsByGroup[L.grupo];\n    if(om&&L.home&&incBy[L.home]) KEYS.forEach(k=>incBy[L.home][k]+=om[k]||0); });\n  // mv.munis['__OUTROS__'] already holds EVERY row this sector has that never got drawn on the\n  // map — \"fora da base de dominantes\" (the one subset mv.orphanMetricsByGroup tracks) AND any\n  // other reason a município has no geoFeat (no shape in the loaded UFs, key mismatch — see\n  // missingMunis). Summing every mv.munis entry, __OUTROS__ included, is the exact same total\n  // sectorTotalCbs() already uses for the \"CBs no setor (antes desta etapa)\" KPI — this bar has to\n  // agree with that KPI, not undercount by whichever slice of __OUTROS__ isn't a tracked orphan\n  // group (a município with no shape at all silently vanished from this total before).\n  const closeTot={};KEYS.forEach(k=>closeTot[k]=Object.values(mv.munis).reduce((a,o)=>a+(o[k]||0),0));\n  const trend=mv.trend.slice(-6);\n  const chartHTML=mk=>{\n    const isM=['receita','rec_nat','rec_avon'].includes(mk); const fN=isM?money:fmt;\n    const data=mv.dest.map(s=>({label:s,color:mv.color[s],antes:+(mv.baseMetrics[s]?.[mk]||0),depois:+(mv.baseMetrics[s]?.[mk]||0)+(incBy[s]?.[mk]||0)}));\n    data.push({label:mv.sector+'✕',color:'#ff3b30',antes:closeTot[mk]||0,depois:0});\n    const max=Math.max(...data.flatMap(d=>[d.antes,d.depois]),1);\n    const W=720,H=290,base=238,top=42,maxH=base-top,n=data.length,gw=(W-16)/n;\n    let g='';\n    data.forEach((d,i)=>{const cx=8+gw*i+gw/2,bw=Math.min(30,gw*0.30);\n      const ah=d.antes/max*maxH,dh=d.depois/max*maxH,ax=cx-bw-2,dx=cx+2;\n      g+=`<rect x=\"${ax.toFixed(1)}\" y=\"${(base-ah).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${ah.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\" opacity=\"0.35\"/>`;\n      g+=`<rect x=\"${dx.toFixed(1)}\" y=\"${(base-dh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${dh.toFixed(1)}\" rx=\"2.5\" fill=\"${d.color}\"/>`;\n      g+=`<text x=\"${(ax+bw/2).toFixed(1)}\" y=\"${(base-ah-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9\" fill=\"#9a9aa8\">${fN(d.antes)}</text>`;\n      g+=`<text x=\"${(dx+bw/2).toFixed(1)}\" y=\"${(base-dh-4).toFixed(1)}\" text-anchor=\"middle\" font-size=\"9.5\" font-weight=\"700\" fill=\"${d.color}\">${fN(d.depois)}</text>`;\n      const dp=d.antes?((d.depois-d.antes)/d.antes*100):(d.depois?100:0);\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"16\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"${dp>=0?'#34c759':'#ff3b30'}\">${dp>0?'+':''}${dp.toFixed(0)}%</text>`;\n      g+=`<text x=\"${cx.toFixed(1)}\" y=\"${base+16}\" text-anchor=\"middle\" font-size=\"10.5\" font-weight=\"700\" fill=\"#1c1c2b\">${d.label}</text>`;});\n    return `<svg viewBox=\"0 0 ${W} ${H}\" style=\"width:100%\"><line x1=\"8\" y1=\"${base}\" x2=\"${W-8}\" y2=\"${base}\" stroke=\"#e7e7ef\"/>${g}</svg>`;\n  };\n  let h=`<div style=\"display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap\">\n      <h2 style=\"margin:0\">Impacto antes → depois desta etapa</h2>\n      <select id=\"metricSel\">${METRICS_DEF.map(m=>`<option value=\"${m[0]}\" ${m[0]===mv.metric?'selected':''}>${m[1]}</option>`).join('')}</select></div>\n    <div class=\"note\" style=\"margin:2px 0 4px\">Barra clara = <b>antes desta etapa</b> (já incluindo etapas anteriores) · barra cheia = <b>depois</b> · <b style=\"color:var(--acc)\">${mv.sector}✕</b> = ${lc(lvl.label)} fechad${lvl.a==='à'?'a':'o'} nesta etapa · % = variação</div>\n    ${chartHTML(mv.metric)}`;\n  h+=`<h2 style=\"margin-top:16px\">Antes → Depois por ${lc(lvl.destLabel)} receptor (snapshot congelado)</h2>\n    <div class=\"scroll grow\"><table><thead><tr><th>${lvl.destLabel}</th>${METRICS_DEF.map(m=>`<th>${m[1]}</th>`).join('')}</tr></thead><tbody>`;\n  mv.dest.forEach(s=>{const b=mv.baseMetrics[s]||{};const inc=incBy[s];\n    h+=`<tr><td><span class=\"home\" style=\"background:${mv.color[s]}\"></span>${s}</td>`+\n      METRICS_DEF.map(([k])=>{const bv=+b[k]||0,av=bv+(inc[k]||0);const d=bv?((av-bv)/bv*100):0;const f=METRICS_DEF.find(x=>x[0]===k)[2]==='$'?money:fmt;\n        return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\" title=\"${({cbs:'CBs',ativas:'CBs ativas',pedidos:'Pedidos',pontos:'Pontos',receita:'Receita',rec_nat:'Receita Natura',rec_avon:'Receita Avon'})[k]} ${({cbs:'recebidas',ativas:'recebidas',pedidos:'recebidos',pontos:'recebidos',receita:'recebida',rec_nat:'recebida',rec_avon:'recebida'})[k]}: +${f(inc[k]||0)}\\nAntes ${f(bv)} -> Depois ${f(av)}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;}).join('')+`</tr>`;});\n  h+=`<tr style=\"opacity:.8\"><td>${mv.sector} (sai)</td>`+METRICS_DEF.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(closeTot[k]||0)}</td>`).join('')+`</tr>`;\n  h+=`</tbody></table></div>`;\n  const incLvl={};mv.dest.forEach(s=>incLvl[s]={});\n  Object.keys(mv.munis).forEach(m=>{if(m==='__OUTROS__')return;const s=mv.assign[m];if(!s||!incLvl[s])return;const lv=mv.levelByMuni[m]||{};for(const nv in lv)incLvl[s][nv]=(incLvl[s][nv]||0)+lv[nv];});\n  c.leaders.forEach(L=>{ const ol=mv.orphanLevelByGroup&&mv.orphanLevelByGroup[L.grupo];\n    if(ol&&L.home&&incLvl[L.home]) Object.entries(ol).forEach(([nv,n])=>incLvl[L.home][nv]=(incLvl[L.home][nv]||0)+n); });\n  h+=`<h2 style=\"margin-top:18px\">Consultoras por nível — antes → depois por ${lc(lvl.destLabel)} receptor</h2>\n    <div class=\"scroll grow\"><table><thead><tr><th>${lvl.destLabel}</th>${NIVEIS.map(n=>`<th>${n}</th>`).join('')}<th>Total</th></tr></thead><tbody>`;\n  mv.dest.forEach(s=>{const base=mv.baseLevel[s]||{},inc=incLvl[s]||{};let tb=0,ta=0;\n    h+=`<tr><td><span class=\"home\" style=\"background:${mv.color[s]}\"></span>${s}</td>`+\n      NIVEIS.map(nv=>{const b=base[nv]||0,i=inc[nv]||0,a=b+i;tb+=b;ta+=a;\n        return `<td>${a?fmt(a):'·'}${i?`<div class=\"delta up\">+${fmt(i)}</div>`:''}</td>`;}).join('')+\n      `<td style=\"font-weight:700\">${fmt(ta)}${ta-tb?`<div class=\"delta up\">+${fmt(ta-tb)}</div>`:''}</td></tr>`;});\n  h+=`</tbody></table></div><div class=\"note\">Número = CBs por nível <b>depois</b> desta etapa · <span style=\"color:var(--ok)\">+N</span> = entrantes do ${mv.sector}.</div>`;\n  const spk=(key)=>{const vals=trend.map(t=>+t[key]||0);const mx=Math.max(...vals,1);\n    return `<div class=\"spark\">${vals.map(v=>`<div class=\"b\" style=\"height:${(v/mx*100).toFixed(0)}%\" title=\"${fmt(v)}\"></div>`).join('')}</div>\n      <div class=\"note\">${trend.map(t=>String(t.ciclo).slice(-2)).join(' · ')}</div>`;};\n  h+=`<h2 style=\"margin-top:18px\">Setor ${mv.sector} — últimos ${trend.length} ciclos até o snapshot congelado</h2>\n    <div class=\"note\" style=\"margin:-4px 0 8px\">Tendência histórica <b>do próprio setor</b> — não ajustada pela absorção hipotética de etapas anteriores (limitação conhecida: tendência é por setor, não decomponível por município).</div>\n    <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:10px\">\n    ${[['cbs','CBs'],['ativas','Ativas'],['receita','Receita'],['pontos','Pontos']].map(([k,l])=>`<div><div class=\"note\" style=\"margin:0 0 3px;font-weight:600;color:var(--ink)\">${l}</div>${spk(k)}</div>`).join('')}</div>`;\n  $('#tab-report').innerHTML=h;\n  const sel=$('#metricSel'); if(sel){ sel.onchange=()=>{mv.metric=sel.value; renderReport();}; initMacSelect(sel); }\n}\nfunction renderCumulative(){\n  const el=$('#tab-cumulative'); if(!el) return;\n  // Esta view soma a cadeia inteira via foldWorldState().muniOverride/sectorBaseCache — hoje só\n  // populados pelo fechamento de setor. GV/mercado têm o rastro equivalente (secOverride/gvOverride),\n  // mas essa agregação ainda não foi refeita nesse grão; melhor avisar do que mostrar tabela vazia.\n  const lockLvl=SESS.timeline.length?(SESS.timeline[0].level||'setor'):'setor';\n  if(lockLvl!=='setor'){ el.innerHTML=`<div class=\"note\">Impacto acumulado da cadeia ainda não disponível pra sessões de ${lc(LEVELS[lockLvl].label)} — use o relatório da etapa (aba anterior) etapa a etapa.</div>`; return; }\n  if(!SESS.timeline.length){ el.innerHTML='<div class=\"note\">Nenhuma etapa na timeline ainda.</div>'; return; }\n  const ready=SESS.timeline.every(mv=>mv.munis);\n  if(!ready){ el.innerHTML='<div class=\"note\">Aguardando etapas terminarem de calcular…</div>'; return; }\n  const closingMoves=SESS.timeline.filter(mv=>mv.kind!=='transfer');\n  const finalWorld=foldWorldState(SESS.timeline,SESS.timeline.length);\n  const openSectors=[...new Set(Object.values(finalWorld.muniOverride))];\n  let h=`<h2 style=\"margin:0 0 8px\">Linha do tempo — ${closingMoves.length} fechamento(s)</h2>\n    <div class=\"note\" style=\"margin:-2px 0 10px\">Setores fechados, em ordem: ${closingMoves.map(mv=>mv.sector).join(' → ')}. Cada linha abaixo soma o efeito de <b>toda a cadeia</b>, não apenas da última etapa.</div>`;\n  h+=`<div class=\"scroll grow\"><table><thead><tr><th>Setor</th><th>Situação</th>${METRICS_DEF.map(([,l])=>`<th>${l}</th>`).join('')}</tr></thead><tbody>`;\n  closingMoves.forEach(mv=>{\n    const i=SESS.timeline.indexOf(mv);\n    h+=`<tr style=\"opacity:.75\"><td>${mv.sector}</td><td>fechado (etapa ${i+1})</td>`+METRICS_DEF.map(([k,,t])=>`<td>${(t==='$'?money:fmt)(mv.pureOwnMetrics?.[k]||0)}</td>`).join('')+`</tr>`;\n  });\n  openSectors.forEach(s=>{\n    const base=SESS.sectorBaseCache[s]||{};\n    const infl=finalWorld.sectorInflow[s]||{};\n    h+=`<tr><td>${s}</td><td>recebeu território</td>`+METRICS_DEF.map(([k,,t])=>{\n      const bv=+base[k]||0, av=bv+(infl[k]||0); const d=bv?((av-bv)/bv*100):0; const f=(t==='$'?money:fmt);\n      return `<td>${f(av)}<div class=\"delta ${d>=0?'up':'dn'}\">${d>0?'+':''}${d.toFixed(0)}%</div></td>`;\n    }).join('')+`</tr>`;\n  });\n  h+=`</tbody></table></div><div class=\"note\">\"Antes\" de um setor receptor é o snapshot congelado (sem nenhum fechamento aplicado); \"depois\" soma tudo que a cadeia inteira jogou pra ele.</div>`;\n  el.innerHTML=h;\n}\nfunction project(feats){let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  const each=cb=>feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(p=>p.forEach(r=>r.forEach(cb)));});\n  each(p=>{mnx=Math.min(mnx,p[0]);mxx=Math.max(mxx,p[0]);mny=Math.min(mny,p[1]);mxy=Math.max(mxy,p[1]);});\n  const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=MAP_W,H=MAP_H,pad=22;\n  const gw=(mxx-mnx)*kx,gh=(mxy-mny),sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n  const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n  return p=>[ox+(p[0]-mnx)*kx*sc, oy+(mxy-p[1])*sc];\n}\nfunction pathFor(f,pr){const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  return ps.map(poly=>poly.map(r=>'M'+r.map(p=>{const q=pr(p);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');}\nfunction cxy(f,pr){let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(poly=>poly[0].forEach(p=>{const q=pr(p);x+=q[0];y+=q[1];n++;}));return[x/n,y/n];}\n// ---------- shared-município slicing ----------\n// A huge urban município (São Paulo etc.) can be the CSV's dominant município for MANY different\n// setores at once — Natura subdivides one big city into dozens of commercial sectors while IBGE's\n// shapefile only has ONE polygon for the whole city. Drawing that single polygon once per claiming\n// sector stacked identical copies on top of each other: only whichever sector was processed first\n// ever \"won\" the shape, and every other sector's label floated over territory it didn't actually own\n// (mv.muniOwners below records every claimant, not just the first). Slice the polygon into N\n// equal-width strips instead — one per claiming sector, ordered by sector code — along whichever\n// axis the shape is longer on, so each sector genuinely gets its own area instead of losing the race.\nfunction clipRingHalf(ring,axis,keepGe,val){\n  const out=[],n=ring.length; if(!n) return out;\n  const side=p=>keepGe?p[axis]>=val:p[axis]<=val;\n  for(let i=0;i<n;i++){\n    const cur=ring[i],prev=ring[(i-1+n)%n];\n    const curIn=side(cur),prevIn=side(prev);\n    if(curIn!==prevIn){\n      const t=(val-prev[axis])/(cur[axis]-prev[axis]);\n      const other=1-axis,pt=[0,0]; pt[axis]=val; pt[other]=prev[other]+t*(cur[other]-prev[other]);\n      out.push(pt);\n    }\n    if(curIn) out.push(cur);\n  }\n  return out;\n}\nfunction sliceFeatureEqual(feature,n){\n  if(n<=1) return [feature];\n  const g=feature.geometry,parts=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  const rings=[]; parts.forEach(poly=>poly.forEach(r=>{ if(r&&r.length>=4) rings.push(r); }));\n  let mnx=Infinity,mxx=-Infinity,mny=Infinity,mxy=-Infinity;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  if(!isFinite(mnx)) return Array.from({length:n},()=>({...feature,__empty:true}));\n  const wide=(mxx-mnx)>=(mxy-mny), axis=wide?0:1;\n  const lo=wide?mnx:mny, hi=wide?mxx:mxy, span=(hi-lo)||1;\n  const out=[];\n  for(let i=0;i<n;i++){\n    const a=lo+span*i/n, b=lo+span*(i+1)/n;\n    const polys=[];\n    rings.forEach(r=>{\n      let c=clipRingHalf(r,axis,true,a);\n      c=clipRingHalf(c,axis,false,b);\n      if(c.length>=3) polys.push([[...c,c[0]]]);\n    });\n    out.push(polys.length?{type:'Feature',properties:feature.properties,__uf:feature.__uf,geometry:{type:'MultiPolygon',coordinates:polys}}:{...feature,__empty:true});\n  }\n  return out;\n}\n// ---------- label placement ----------\n// Pole of inaccessibility: the interior point farthest from ANY boundary of the dissolved area. A\n// centroid is only a mean — on a concave sector, one split across a river, or a member whose vertex\n// average falls in a bay, it lands outside the very territory it is supposed to name, which is what\n// pushed the sector numbers off their own shapes. Even-odd across every ring of every member is a\n// correct union test here because municipalities tile rather than overlap.\n// The search box is clipped to the viewBox, so the result is inside the territory AND on screen.\n// Rings are decimated first — this is a label, not a measurement — which keeps a 20-município sector\n// to a few million ops, and the result is cached per stage: geometry never changes between renders,\n// only the fill does.\nfunction labelPointFor(fs,pr){\n  const rings=[];\n  (fs||[]).forEach(f=>{ if(!f||!f.geometry) return;\n    const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n    ps.forEach(poly=>poly.forEach(r=>{\n      if(!r||r.length<4) return;\n      const step=Math.max(1,Math.floor(r.length/120)),pts=[];\n      for(let i=0;i<r.length;i+=step) pts.push(pr(r[i]));\n      if(pts.length>3) rings.push(pts);\n    }));});\n  if(!rings.length) return null;\n  let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  mnx=Math.max(mnx,2); mny=Math.max(mny,2); mxx=Math.min(mxx,698); mxy=Math.min(mxy,498);\n  if(!(mxx>mnx&&mxy>mny)) return null;\n  const inside=(x,y)=>{ let c=false;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      if(((a[1]>y)!==(b[1]>y)) && (x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])) c=!c; }\n    return c; };\n  const edgeDist=(x,y)=>{ let m=Infinity;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      const dx=b[0]-a[0],dy=b[1]-a[1],L=dx*dx+dy*dy;\n      let t=L?(((x-a[0])*dx+(y-a[1])*dy)/L):0; t=t<0?0:(t>1?1:t);\n      const ex=a[0]+t*dx-x,ey=a[1]+t*dy-y,d=ex*ex+ey*ey; if(d<m)m=d; }\n    return Math.sqrt(m); };\n  const W=mxx-mnx,H=mxy-mny,N=26;\n  // Centroide (média simples dos vértices) do território inteiro — só usado pra desempatar A FAVOR\n  // do meio quando um ponto mais central tem folga quase tão boa quanto o campeão isolado. Um setor\n  // com um lóbulo bem mais gordo de um lado sempre ganhava o rótulo puxado pra esse lado, mesmo\n  // quando um ponto quase tão espaçoso e bem mais central também servia — o peso é pequeno de\n  // propósito, nunca vale mais que uma folga real melhor.\n  let ccx=0,ccy=0,cn=0;\n  rings.forEach(r=>r.forEach(p=>{ ccx+=p[0]; ccy+=p[1]; cn++; }));\n  ccx/=cn||1; ccy/=cn||1;\n  const CENTER_PULL=0.22;\n  const scoreAt=(x,y,d)=>d-CENTER_PULL*Math.hypot(x-ccx,y-ccy);\n  let best=null,bd=-1,bs=-Infinity;\n  for(let i=0;i<=N;i++) for(let j=0;j<=N;j++){\n    const x=mnx+W*i/N,y=mny+H*j/N;\n    if(!inside(x,y)) continue;\n    const d=edgeDist(x,y),s=scoreAt(x,y,d); if(s>bs){ bs=s; bd=d; best=[x,y]; }\n  }\n  if(!best) return null;\n  // Hill-climb off the coarse grid: the grid only has to land somewhere in the right lobe, the\n  // refinement finds the deep point inside it — halving the step whenever no neighbour improves.\n  let step=Math.max(W,H)/N/2;\n  for(let k=0;k<7;k++){\n    let moved=false;\n    for(const o of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]){\n      const x=best[0]+o[0]*step,y=best[1]+o[1]*step;\n      if(x<mnx||x>mxx||y<mny||y>mxy||!inside(x,y)) continue;\n      const d=edgeDist(x,y),s=scoreAt(x,y,d); if(s>bs){ bs=s; bd=d; best=[x,y]; moved=true; }\n    }\n    if(!moved) step/=2;\n  }\n  // The clearance travels with the point: it is the radius of the largest circle that fits inside the\n  // territory there, which is exactly the budget a caller needs to decide how much text will fit.\n  return {p:best,r:bd};\n}\nfunction labelPointOf(mv,key,fs,pr){\n  if(!mv._lbl) mv._lbl={};\n  if(!(key in mv._lbl)) mv._lbl[key]=labelPointFor(fs,pr);\n  return mv._lbl[key];\n}\n// ---------- map zoom/pan ----------\n// Zoom é feito manipulando o viewBox, nunca um transform CSS — assim rótulos e polígonos vivem no\n// MESMO sistema de coordenadas e escalam juntos automaticamente (texto tem font-size em unidades do\n// viewBox, então encolher o viewBox amplia tudo por igual, sem lógica extra pro tamanho da fonte).\nconst MAP_W=700; let MAP_H=500; const MAP_ZOOM_MAX=8;\nlet mapView={x:0,y:0,w:MAP_W,h:MAP_H};\n// MAP_H acompanha a proporção real do card na tela (MAP_W fica fixo) — sem isso o \"mundo\" de\n// desenho tinha sempre proporção 700x500 fixa, e o <svg> (que preserva essa proporção) ficava\n// menor que o card sempre que o card fosse bem mais alto que largo, sobrando um vão em branco\n// embaixo — a sensação de \"mapa preso num quadrado\" dentro de um espaço bem maior.\nfunction syncMapDims(){\n  const wrap=$('.map-wrap'); if(!wrap) return;\n  const cw=wrap.clientWidth, ch=wrap.clientHeight; if(!cw||!ch) return;\n  const newH=Math.round(MAP_W*(ch/cw));\n  if(Math.abs(newH-MAP_H)<2) return;\n  const fracW=mapView.w/MAP_W, cx=(mapView.x+mapView.w/2)/MAP_W, cy=(mapView.y+mapView.h/2)/MAP_H;\n  MAP_H=newH;\n  const w=Math.min(MAP_W,Math.max(MAP_W/MAP_ZOOM_MAX,MAP_W*fracW));\n  const h=Math.min(MAP_H,Math.max(MAP_H/MAP_ZOOM_MAX,MAP_H*fracW));\n  let x=cx*MAP_W-w/2, y=cy*MAP_H-h/2;\n  x=Math.min(MAP_W-w,Math.max(0,x)); y=Math.min(MAP_H-h,Math.max(0,y));\n  mapView={x,y,w,h};\n}\nfunction applyMapView(){\n  const svg=$('#map'); if(!svg) return;\n  svg.setAttribute('viewBox', mapView.x.toFixed(2)+' '+mapView.y.toFixed(2)+' '+mapView.w.toFixed(2)+' '+mapView.h.toFixed(2));\n  // Rótulo tem font-size em unidades do viewBox, então sem isso ele cresceria junto com o zoom até\n  // virar um borrão — encolhendo o font-size na mesma proporção que o viewBox encolhe cancela\n  // exatamente o zoom, e o texto fica do MESMO tamanho na tela em qualquer nível de zoom.\n  const fsScale=mapView.w/MAP_W;\n  svg.querySelectorAll('text[data-fs]').forEach(t=>{\n    const base=+t.getAttribute('data-fs')||8;\n    t.setAttribute('font-size',(base*fsScale).toFixed(2));\n  });\n  // Mesmo raciocínio pro halo do texto e pro fio que liga um rótulo deslocado ao seu município —\n  // os dois têm largura de traço fixa em unidades do viewBox, então sem reescalar iam virar uma\n  // mancha grossa (halo) ou uma barra (fio) no zoom alto em vez de ficarem do mesmo tamanho na tela.\n  svg.querySelectorAll('[data-sw]').forEach(el=>{\n    const base=+el.getAttribute('data-sw')||1;\n    el.style.strokeWidth=(base*fsScale).toFixed(3)+'px';\n  });\n  // Fio de rótulo deslocado é calculado UMA vez, no mundo inteiro de 700x500, pra evitar colisão\n  // com TODO outro rótulo — inclusive os que, no zoom atual, nem estão mais na tela. Quanto mais\n  // fundo o zoom, maior a fração da área visível que esse mesmo fio (comprimento fixo em unidades\n  // do mundo) passa a cobrir, virando um risco diagonal cortando o mapa até um rótulo que já saiu\n  // do enquadramento. Esconder o fio quando ele passa de uma fração do que está visível agora\n  // resolve isso sem precisar recalcular posição de rótulo a cada passo de zoom.\n  const viewDiag=Math.hypot(mapView.w,mapView.h);\n  svg.querySelectorAll('line.lead').forEach(ln=>{\n    const dx=(+ln.getAttribute('x2'))-(+ln.getAttribute('x1')), dy=(+ln.getAttribute('y2'))-(+ln.getAttribute('y1'));\n    ln.style.display=(Math.hypot(dx,dy)>viewDiag*0.35)?'none':'';\n  });\n}\nfunction zoomMapBy(factor){\n  const cx=mapView.x+mapView.w/2, cy=mapView.y+mapView.h/2;\n  let w=mapView.w/factor, h=mapView.h/factor;\n  w=Math.min(MAP_W, Math.max(MAP_W/MAP_ZOOM_MAX, w));\n  h=Math.min(MAP_H, Math.max(MAP_H/MAP_ZOOM_MAX, h));\n  let x=cx-w/2, y=cy-h/2;\n  x=Math.min(MAP_W-w, Math.max(0,x));\n  y=Math.min(MAP_H-h, Math.max(0,y));\n  mapView={x,y,w,h};\n  applyMapView();\n}\n// Mesma matemática do zoomMapBy, mas ancorada no cursor em vez do centro da view — o ponto do mapa\n// que está sob o mouse continua sob o mouse depois do zoom, o que faz o scroll parecer\n// contínuo/\"seamless\" em vez de recentralizar o mapa a cada girada da roda.\nfunction zoomMapAt(factor, clientX, clientY){\n  const svg=$('#map'); if(!svg) return;\n  const rect=svg.getBoundingClientRect(); if(!rect.width||!rect.height) return;\n  const fx=(clientX-rect.left)/rect.width, fy=(clientY-rect.top)/rect.height;\n  const wx=mapView.x+fx*mapView.w, wy=mapView.y+fy*mapView.h;\n  let w=mapView.w/factor, h=mapView.h/factor;\n  w=Math.min(MAP_W, Math.max(MAP_W/MAP_ZOOM_MAX, w));\n  h=Math.min(MAP_H, Math.max(MAP_H/MAP_ZOOM_MAX, h));\n  let x=wx-fx*w, y=wy-fy*h;\n  x=Math.min(MAP_W-w, Math.max(0,x));\n  y=Math.min(MAP_H-h, Math.max(0,y));\n  mapView={x,y,w,h};\n  applyMapView();\n}\n// Arrastar sempre fica DENTRO do canvas de 700x500 (clamp em [0, MAP_W-w] / [0, MAP_H-h]) — nunca\n// existe um x/y que deixa o viewBox parcialmente fora da área com conteúdo, então o mapa nunca\n// \"some\" da tela, só pode chegar até a borda do que existe pra ver.\nlet mapDrag=null, mapDidDrag=false;\nfunction mapPointerDown(e){\n  const svg=$('#map'); if(!svg) return;\n  mapDrag={x0:e.clientX,y0:e.clientY,vx0:mapView.x,vy0:mapView.y,rect:svg.getBoundingClientRect()};\n  mapDidDrag=false;\n  svg.classList.add('dragging');\n  // NÃO setPointerCapture aqui: capturar o ponteiro no <svg> reencaminha o click resultante pro\n  // próprio <svg> em vez do <path> do município embaixo do cursor — todo clique parava de chegar\n  // no onclick de reatribuição, não só depois de um arrasto de verdade. move/up escutam em `root`\n  // (nunca substituído por renderAll) em vez de no <svg>, então o arrasto continua funcionando\n  // mesmo se o ponteiro sair da área do mapa no meio do gesto.\n}\nfunction mapPointerMove(e){\n  if(!mapDrag) return;\n  const dxPix=e.clientX-mapDrag.x0, dyPix=e.clientY-mapDrag.y0;\n  if(!mapDidDrag && Math.hypot(dxPix,dyPix)>4) mapDidDrag=true;\n  if(!mapDrag.rect.width||!mapDrag.rect.height) return;\n  const sx=mapView.w/mapDrag.rect.width, sy=mapView.h/mapDrag.rect.height;\n  let x=mapDrag.vx0-dxPix*sx, y=mapDrag.vy0-dyPix*sy;\n  x=Math.min(MAP_W-mapView.w, Math.max(0,x));\n  y=Math.min(MAP_H-mapView.h, Math.max(0,y));\n  mapView={...mapView,x,y};\n  applyMapView();\n}\nfunction mapPointerUp(){\n  if(!mapDrag) return;\n  mapDrag=null;\n  const svg=$('#map'); if(svg) svg.classList.remove('dragging');\n}\n// ---------- OSM reference layers (rodovias/rios/picos) ----------\n// This map is a hand-projected SVG, not a Leaflet/Mapbox instance — there is no tile layer to drop a\n// basemap into. Vector geometry is the only shape that can ride the SAME pr() projection every\n// município shape already uses and land in the right spot. Scoped to the bbox of what mv.geoFeat +\n// mv.nbrFeat actually put on screen for THIS stage — never the whole country — because Overpass's\n// road/river graph for Brazil is enormous and the ask here is é \"what's around this closure\", not a\n// national basemap. Cached by (layer, bbox) rather than by session/stage index: two stages that\n// happen to look at the same patch of ground share one fetch, and switching sessions can never serve\n// a stale layer under a reused stage index.\n// UMA query Overpass pra Rodovias inteira (as 8 subclasses documentadas na wiki do Overpass API,\n// https://wiki.openstreetmap.org/wiki/Overpass_API, num regex só), não 8 requests separados — 8\n// chamadas de uma vez contra o mesmo servidor público é exatamente o padrão que dispara\n// rate-limit/504 \"servidor ocupado\" (foi o que quebrou \"secondary\"/\"secondary_link\"). Busca e cache\n// continuam por bbox, só que agora sob UMA chave ('rodovias'); a subclasse de cada trecho\n// (highway=motorway/trunk/primary/secondary + _link) já vem em el.tags.highway na resposta (\"out\n// geom\" inclui as tags do elemento por padrão) — cada checkbox individual só filtra o que já foi\n// baixado, sem request nova nenhuma.\nconst HIGHWAY_META={\n  motorway:{label:'Rodovia expressa',tag:'highway=motorway',color:'#777777',sw:0.5},\n  trunk:{label:'Via arterial',tag:'highway=trunk',color:'#777777',sw:0.5},\n  primary:{label:'Rodovia principal',tag:'highway=primary',color:'#777777',sw:0.5},\n  secondary:{label:'Rodovia secundária',tag:'highway=secondary',color:'#777777',sw:0.5},\n};\nconst HIGHWAY_TAG_TO_KEY={motorway:'motorway',trunk:'trunk',primary:'primary',secondary:'secondary'};\n// Menor pra maior — desenha secundária primeiro (embaixo) e motorway por último (em cima), pra uma via\n// maior nunca sumir sob uma menor só por causa da ordem arbitrária que o Overpass devolve.\nconst HIGHWAY_RANK={secondary:0,primary:1,trunk:2,motorway:3};\nconst HIGHWAY_KEYS=Object.keys(HIGHWAY_META);\nconst OSM_LAYERS=[\n  {key:'rodovias',label:'Rodovias',\n   ql:b=>`way[\"highway\"~\"^(motorway|trunk|primary|secondary)$\"](${b});`},\n  // Só rio PERMANENTE conta como barreira de verdade pra distância real entre municípios — riacho\n  // é atravessado por qualquer bueiro, e um rio \"intermitente\" (comum no semiárido nordestino) fica\n  // seco a maior parte do ano, então nenhum dos dois muda quanto tempo leva pra ir de um lugar ao\n  // outro.\n  {key:'rivers',label:'Rios',color:'#0a84ff',sw:0.5,\n   ql:b=>`way[\"waterway\"=\"river\"][\"intermittent\"!=\"yes\"](${b});`},\n  // Aeroporto/aeródromo (https://wiki.openstreetmap.org/wiki/Tag:aeroway%3Daerodrome) pode estar\n  // mapeado como nó único (campo pequeno) OU como área (way/relation, o terreno inteiro) — nwr pega\n  // os três, e \"out center\" devolve um ponto pronto pra qualquer um deles (lat/lon direto no nó,\n  // center.lat/lon calculado pro way/relation), sem geometria de contorno igual rodovia/rio porque\n  // aqui o que importa é a LOCALIZAÇÃO, não o desenho do pátio.\n  // Só aeroporto COMERCIAL: exige tag \"iata\" (código IATA de 3 letras só existe em aeródromo com voo\n  // comercial regular) — filtra fora pista de fazenda/campo de pouso privado/aeródromo militar sem\n  // codigo, que respondem à mesma query aeroway=aerodrome mas não são \"aeroporto\" no sentido do usuário.\n  {key:'airports',label:'Aeroportos',mode:'point',outMode:'center',color:'#000000',\n   ql:b=>`nwr[\"aeroway\"=\"aerodrome\"][\"iata\"](${b});`},\n];\nconst OSM_ON={motorway:false,trunk:false,primary:false,secondary:false,rivers:false,airports:false};\n// Contorno branco/colorido em volta de cada pedaço do que está fechando — ligado por padrão (era o\n// único comportamento antes deste toggle existir), desligar só tira o traço (stroke:none), nunca\n// muda preenchimento nem lógica de clique.\nlet SHOW_MUNI_OUTLINE=false;\nlet OSM_PANEL_OPEN=false; // page chrome only — deliberately not on SESS, never persisted/shared (mirrors OSM_ON/LEGEND_ON's own convention)\n// Legendas default ON — é o comportamento de sempre (rótulo em toda unidade/área de contexto);\n// os checkboxes só existem pra permitir DESLIGAR num mapa carregado, não pra ligar algo novo.\nconst LEGEND_ON={muni:false,ctx:false};\nconst CHILD_PLURAL={Município:'municípios',Setor:'setores',GV:'GVs'};\nconst OSM_CACHE={};\nconst OSM_MAX_SPAN=6; // graus — acima disso (fechamento de mercado muito grande) não busca sozinho\nfunction osmBboxOf(feats){\n  let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n    ps.forEach(poly=>poly.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; })));});\n  if(!isFinite(mnx)) return null;\n  return {south:mny,west:mnx,north:mxy,east:mxx};\n}\n// Calling overpass-api.de straight from the browser died with a bare \"Failed to fetch\" on the\n// operator's own network (proved: the exact same call succeeded from a different network) — a\n// corporate firewall/proxy blocking that third-party domain, most likely. Routing through Data2's own\n// Rush listener (osmOverpassProxy → HttpClient → overpass-api.de) moves the outbound hop to Data2's\n// backend, which never touches the operator's restricted network at all.\nasync function ensureOsmLayer(def,bbox,ck){\n  OSM_CACHE[ck]={status:'loading',data:null,error:null,attempt:0};\n  const b=bbox.south.toFixed(4)+','+bbox.west.toFixed(4)+','+bbox.north.toFixed(4)+','+bbox.east.toFixed(4);\n  const query='[out:json][timeout:25];('+def.ql(b)+');out'+(def.outMode==='center'?' center':' geom')+';';\n  const rec=OSM_CACHE[ck];\n  // The public Overpass instance fails under load often enough (504s, \"server busy\") that stopping\n  // after one try and forcing a manual re-click was more friction than the API itself — this keeps\n  // retrying with a capped backoff until it actually gets a response, instead of desmarking the\n  // checkbox(es) and giving up. stillWanted() is checked before AND after every attempt so a layer\n  // toggled off mid-retry stops hammering the server for something nobody wants anymore, rather than\n  // retrying forever in the background; drawOsmLayer's own `if(!rec)` guard is what stops this from\n  // ever running twice in parallel for the same bbox+layer, since `rec` stays present (status\n  // 'loading'/'retrying') for the whole life of this loop, never deleted on failure like before.\n  const stillWanted=()=>(def.key==='rodovias'?HIGHWAY_KEYS:[def.key]).some(k=>OSM_ON[k]);\n  for(let attempt=1;;attempt++){\n    if(!stillWanted()){ delete OSM_CACHE[ck]; return; }\n    rec.attempt=attempt; rec.status=attempt>1?'retrying':'loading';\n    try{\n      const res=await d2.rush('osmOverpassProxy').invoke({data:query});\n      const env=res&&res.data;\n      if(!env) throw new Error('proxy sem resposta');\n      if(!env.ok) throw new Error(env.error||'falha desconhecida no proxy');\n      rec.data=env.elements||[]; rec.status='ok';\n      drawMap();\n      return;\n    }catch(e){\n      rec.error=(e&&e.message)||'falha no proxy';\n      if(!stillWanted()){ delete OSM_CACHE[ck]; return; }\n      drawMap();\n      // Capped backoff (3s, 6s, 9s… up to 20s) — enough spacing to stay clear of the exact\n      // rate-limit pattern that already broke \"secondary\"/\"secondary_link\" once (see the comment\n      // above OSM_LAYERS), without making the operator wait an unreasonable amount between tries.\n      await new Promise(r=>setTimeout(r,Math.min(3000*attempt,20000)));\n    }\n  }\n}\n// ---- simplificação (RDP) + suavização (Catmull-Rom→Bézier) da geometria de via/rio do Overpass ----\n// Só pra linhas (rodovia/rio) — polígono de município/setor usa pathFor() e continua exato, sem\n// mexer, porque ali a fidelidade geométrica é o que importa (fronteira administrativa de verdade).\n// RDP_EPS em unidades do viewBox fixo (700x500 — ver comment em fitMapHeight/.map-wrap): pequeno o\n// bastante pra só remover ponto quase-colinear (zig-zag do GPS), não pra distorcer a curva real.\nconst RDP_EPS=0.6;\nfunction rdpSimplify(pts,eps){\n  if(pts.length<3) return pts;\n  const a=pts[0], b=pts[pts.length-1];\n  const dx=b[0]-a[0], dy=b[1]-a[1], len2=dx*dx+dy*dy;\n  let maxD=0,idx=0;\n  for(let i=1;i<pts.length-1;i++){\n    const p=pts[i];\n    let d;\n    if(len2===0){ d=Math.hypot(p[0]-a[0],p[1]-a[1]); }\n    else{\n      const t=((p[0]-a[0])*dx+(p[1]-a[1])*dy)/len2;\n      const px=a[0]+t*dx, py=a[1]+t*dy;\n      d=Math.hypot(p[0]-px,p[1]-py);\n    }\n    if(d>maxD){ maxD=d; idx=i; }\n  }\n  if(maxD>eps){\n    const left=rdpSimplify(pts.slice(0,idx+1),eps);\n    const right=rdpSimplify(pts.slice(idx),eps);\n    return left.slice(0,-1).concat(right);\n  }\n  return [a,b];\n}\n// Catmull-Rom → Bézier cúbica: passa exatamente pelos pontos simplificados (não aproxima, não corta\n// canto) mas troca o zig-zag reto do GPS por uma curva suave entre eles — o \"eles mentem sobre a\n// geometria\" descrito: pontos crus do OSM são só retas entre coordenadas, isto desenha uma curva de\n// verdade passando pelas mesmas coordenadas.\nfunction catmullRomPathD(pts){\n  if(pts.length<3) return 'M'+pts.map(p=>p[0].toFixed(1)+','+p[1].toFixed(1)).join('L');\n  let d=`M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;\n  for(let i=0;i<pts.length-1;i++){\n    const p0=pts[i-1]||pts[i], p1=pts[i], p2=pts[i+1], p3=pts[i+2]||p2;\n    const c1x=p1[0]+(p2[0]-p0[0])/6, c1y=p1[1]+(p2[1]-p0[1])/6;\n    const c2x=p2[0]-(p3[0]-p1[0])/6, c2y=p2[1]-(p3[1]-p1[1])/6;\n    d+=`C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;\n  }\n  return d;\n}\nfunction drawOsmLayer(svg,pr,feats,NS){\n  const st=$('#osmStatus');\n  const rodoviasOn=HIGHWAY_KEYS.some(k=>OSM_ON[k]);\n  const active=OSM_LAYERS.filter(l=>l.key==='rodovias'?rodoviasOn:OSM_ON[l.key]);\n  if(!active.length){ if(st) st.textContent=''; return; }\n  const bbox=osmBboxOf(feats); if(!bbox){ if(st) st.textContent=''; return; }\n  const tooBig=(bbox.north-bbox.south)>OSM_MAX_SPAN||(bbox.east-bbox.west)>OSM_MAX_SPAN;\n  const msgs=[];\n  active.forEach(def=>{\n    if(tooBig){ msgs.push(def.label+': área grande demais p/ carregar automaticamente'); return; }\n    const bk=bbox.south.toFixed(3)+'_'+bbox.west.toFixed(3)+'_'+bbox.north.toFixed(3)+'_'+bbox.east.toFixed(3);\n    const ck=def.key+'|'+bk;\n    let rec=OSM_CACHE[ck];\n    if(!rec){ ensureOsmLayer(def,bbox,ck); rec=OSM_CACHE[ck]; }\n    if(rec.status==='loading') msgs.push(def.label+': carregando…');\n    else if(rec.status==='retrying') msgs.push(def.label+': tentativa '+(rec.attempt||1)+'… (Overpass instável, repetindo automaticamente — última falha: '+(rec.error||'?')+')');\n    if(rec.status!=='ok') return;\n    if(def.mode==='point'){\n      // Localização, não contorno: nó usa lat/lon direto; way/relation usa o center que \"out\n      // center\" já calculou — nenhum dos dois carrega el.geometry, então essa camada nunca cai no\n      // desenho de linha abaixo.\n      const g=document.createElementNS(NS,'g');\n      rec.data.forEach(el=>{\n        const pt=el.type==='node'?{lon:el.lon,lat:el.lat}:(el.center?{lon:el.center.lon,lat:el.center.lat}:null);\n        if(!pt) return;\n        const q=pr([pt.lon,pt.lat]);\n        // Ícone de avião (glifo ✈, não um ponto azul cheio) — halo branco (paintOrder:stroke) pro símbolo\n        // ler em cima de qualquer cor de fundo do mapa, igual o halo já usado nos labels de município.\n        const ic=document.createElementNS(NS,'text');\n        ic.setAttribute('x',q[0].toFixed(1)); ic.setAttribute('y',q[1].toFixed(1));\n        ic.setAttribute('text-anchor','middle'); ic.setAttribute('dominant-baseline','central');\n        ic.setAttribute('font-size','13'); ic.setAttribute('fill',def.color);\n        ic.style.cursor='help'; ic.style.userSelect='none';\n        ic.textContent='✈';\n        const nm=(el.tags&&(el.tags.name||el.tags['name:pt']))||'Aeroporto';\n        ic.onmousemove=e=>showTip(e,`<b>${esc(nm)}</b>`);\n        ic.onmouseleave=()=>{$('#tip').style.opacity=0;};\n        g.appendChild(ic);\n      });\n      svg.appendChild(g);\n      return;\n    }\n    const g=document.createElementNS(NS,'g'); g.style.pointerEvents='none';\n    // Empilha do menos importante pro mais importante ANTES de desenhar — sem isto o Overpass devolve\n    // as vias em ordem arbitrária e uma via secundária pode acabar desenhada por cima de uma rodovia\n    // maior (o \"layer cake\" errado). Camadas sem subclasse (rios) mantêm a ordem que vieram.\n    const dataToRender=def.key==='rodovias'\n      ?[...rec.data].sort((a,b)=>(HIGHWAY_RANK[HIGHWAY_TAG_TO_KEY[a.tags&&a.tags.highway]]??-1)-(HIGHWAY_RANK[HIGHWAY_TAG_TO_KEY[b.tags&&b.tags.highway]]??-1))\n      :rec.data;\n    dataToRender.forEach(el=>{\n      if(!el.geometry||!el.geometry.length) return;\n      // Rodovias vem como UMA resposta com as 8 subclasses misturadas — cada trecho só desenha se\n      // a subclasse dele (el.tags.highway) estiver ligada agora; rios continua usando o próprio def.\n      let color,sw;\n      if(def.key==='rodovias'){\n        const hk=HIGHWAY_TAG_TO_KEY[el.tags&&el.tags.highway];\n        if(!hk||!OSM_ON[hk]) return;\n        const m=HIGHWAY_META[hk]; color=m.color; sw=m.sw;\n      } else { color=def.color; sw=def.sw; }\n      const rawPts=el.geometry.filter(Boolean).map(p=>pr([p.lon,p.lat]));\n      const d=catmullRomPathD(rdpSimplify(rawPts,RDP_EPS));\n      // Largura fixa em unidades do viewBox, sem contra-escala nenhuma (nada de data-sw) — estrada é\n      // geografia: encolhe sozinha quando o zoom mostra uma área enorme de uma vez (menos zoom =\n      // linha mais fina) e cresce normalmente ao aproximar, exatamente como o resto do mapa.\n      // Halo (contorno claro por baixo do traço real) só pra rios agora — em rodovia ficava lendo como\n      // um traço tracejado/serrilhado em vez de um contorno limpo, pedido explícito pra tirar.\n      if(def.key!=='rodovias'){\n        const halo=document.createElementNS(NS,'path'); halo.setAttribute('d',d); halo.setAttribute('fill','none');\n        halo.setAttribute('stroke','#fff'); halo.setAttribute('stroke-width',sw+1.3);\n        halo.style.strokeLinejoin='round'; halo.style.strokeLinecap='round';\n        g.appendChild(halo);\n      }\n      const p=document.createElementNS(NS,'path'); p.setAttribute('d',d); p.setAttribute('fill','none');\n      p.setAttribute('stroke',color); p.setAttribute('opacity', def.key==='rodovias' ? '1' : '0.72');\n      p.setAttribute('stroke-width',sw);\n      p.style.strokeLinejoin='round'; p.style.strokeLinecap='round';\n      g.appendChild(p);\n    });\n    svg.appendChild(g);\n  });\n  if(st) st.textContent=msgs.join(' · ');\n}\n// Mede o espaço de viewport que sobra abaixo do topo do card do mapa e usa isso como altura —\n// não um percentual fixo — porque o que existe ACIMA do mapa varia (trilho de etapas e toolbar só\n// aparecem quando a sessão já tem movimentações), então só medir de verdade para exatamente na\n// borda da tela em qualquer estado.\nfunction fitMapHeight(){\n  const wrap=$('.map-wrap'); if(!wrap) return;\n  const top=wrap.getBoundingClientRect().top;\n  if(top<=0) return;\n  // Margem generosa de propósito: sobra um pedaço visível do card seguinte (líderes/matriz) na\n  // borda inferior da tela, como sinal de que tem mais conteúdo abaixo pra rolar.\n  const margin=80;\n  wrap.style.height=Math.max(220, window.innerHeight-top-margin)+'px';\n}\nfunction drawMap(){\n  const mv=SESS.timeline[SESS.selected]; const svg=$('#map'); const ignNote=$('#mapIgnoredNote');\n  if(!mv){ svg.innerHTML=''; if(ignNote) ignNote.innerHTML=''; return; }\n  const level=mv.level||'setor';\n  const legendMuniLbl=$('#legendMuniLbl');\n  if(legendMuniLbl) legendMuniLbl.textContent = level==='setor' ? 'Nome de municípios' : `Código de ${CHILD_PLURAL[LEVELS[level].childLabel]||LEVELS[level].childLabel}`;\n  const muniOutlineLbl=$('#muniOutlineLbl');\n  if(muniOutlineLbl) muniOutlineLbl.textContent = `Contorno de ${level==='setor'?'municípios':(CHILD_PLURAL[LEVELS[level].childLabel]||LEVELS[level].childLabel)}`;\n  // GV/mercado fechando: o contorno de território agora é SEMPRE desenhado (unit-outline, mais\n  // abaixo em drawMap) — não sobra nada pra este checkbox ligar/desligar nesse nível, então ele\n  // some da toolbar em vez de virar um toggle morto que não muda mais nada na tela.\n  const muniOutlineRowEl=$('#muniOutline') && $('#muniOutline').closest('label');\n  if(muniOutlineRowEl) muniOutlineRowEl.classList.toggle('hidden', level!=='setor');\n  // setor fecha: a unidade É o município. GV/mercado fecham: cada município herda a atribuição do\n  // setor/GV a que pertence — municípios do mesmo setor sempre têm a MESMA cor e giram JUNTOS no\n  // clique, porque é o setor inteiro (não o município) que troca de dono nesses níveis.\n  const unitAssign=m=>level==='setor'?mv.assign[m]:mv.assign[(mv.unitOfMuni||{})[m]];\n  const c=computeDerived(mv);const NS='http://www.w3.org/2000/svg'; svg.innerHTML='';\n  const feats=[...mv.nbrFeat,...Object.values(mv.geoFeat)]; if(!feats.length) return;\n  // Hatch overlay flags a município whose full CSV sector list has >1 real sector, independent of\n  // how many of those sectors qualify as a valid destination target right now (isValidCtxTarget,\n  // below) — a município can collapse to one solid colour when only one owner qualifies, or to the\n  // flat __MULTI_NOVALID__ tone when none do, and either way the other real sectors it holds would\n  // otherwise be invisible. Dim by design (stroke-opacity .15): a hint to check the hover, not a\n  // second layer of colour competing with the destination fill.\n  const hatch=document.createElementNS(NS,'pattern');\n  hatch.setAttribute('id','multiSectorHatch'); hatch.setAttribute('width','7'); hatch.setAttribute('height','7');\n  hatch.setAttribute('patternUnits','userSpaceOnUse'); hatch.setAttribute('patternTransform','rotate(45)');\n  const hatchLine=document.createElementNS(NS,'line');\n  hatchLine.setAttribute('x1','0'); hatchLine.setAttribute('y1','0'); hatchLine.setAttribute('x2','0'); hatchLine.setAttribute('y2','7');\n  hatchLine.setAttribute('stroke','#1c1c1e'); hatchLine.setAttribute('stroke-width','0.6'); hatchLine.setAttribute('stroke-opacity','0.15');\n  hatch.appendChild(hatchLine);\n  const defs=document.createElementNS(NS,'defs'); defs.appendChild(hatch); svg.appendChild(defs);\n  syncMapDims();\n  const pr=project(feats);\n  const world=foldWorldState(SESS.timeline,SESS.selected);\n  // The context layer is ONE area per SECTOR, not one shape per município. The unit of this decision\n  // is the destination sector; a scatter of individually outlined municipalities carrying their own\n  // names invited exactly the wrong reading of the map. Members of a sector are filled AND stroked in\n  // the same colour, so the shared borders vanish and the group reads as a single dissolved\n  // territory — the IBGE shapes do not share vertices precisely enough to extract a clean union\n  // outline, and carrying a real polygon-clipping union for a context layer is not worth it.\n  // Labels are COLLECTED here and drawn last, after every path. Drawn inline they were buried by the\n  // next sector's fill, and placement cannot avoid a collision it has not seen yet.\n  const labels=[];\n  // Um destino \"de verdade\" pra este município compartilhado não exige ter sobrevivido ao corte de\n  // 6 do mv.dest — qualquer setor adjacente + da mesma GV/mercado + ainda não fechado nesta sessão já\n  // conta como candidato válido aqui (mv.validCandidates, a mesma malha usada como plainCandidates em\n  // computeMovement, sem o corte de top-6). Só se aplica a fechamento de setor — é aí que existe a\n  // noção de \"candidato descoberto por adjacência geográfica\"; GV/mercado mantém o comportamento\n  // anterior (todo setor real do município vira uma fatia, sem filtro de validade).\n  const isValidCtxTarget=s=>!!s&&(mv.dest.includes(s)||(mv.validCandidates&&mv.validCandidates.has(s)));\n  // Existence/closed-state at whatever grain THIS closure operates at — setor reads the live CRM\n  // sector list (as before); GV/mercado read the same dominantes-CSV hierarchy maps everything else\n  // at this level already trusts, since there is no live CRM listing of GVs/mercados to check against.\n  const levelExists=s=>level==='setor'?SESS.sectorsList.some(x=>x.setor===s)\n    :(level==='gv'?!!(DOM.sectorsByGv&&DOM.sectorsByGv[s]):!!(DOM.sectorsByMercado&&DOM.sectorsByMercado[s]));\n  const levelClosedSet=level==='setor'?world.closedSectors:(level==='gv'?world.closedGvs:world.closedMercados);\n\n  // A sibling sharing the CLOSING unit's own território (mv.sharedMuniSlices — São Paulo etc., setor\n  // level only today) is adjacent by definition, literally the same território — isValidCtxTarget's\n  // geographic-vote requirement (mv.dest/mv.validCandidates, built from OTHER municípios bordering the\n  // closing one) never applies to it, and wrongly failed every sibling that hadn't separately won that\n  // vote elsewhere. Same bar used everywhere else at this level: exists, not already closed, same\n  // parent as the unit closing — no geographic step, because none of it means anything for a\n  // co-located setor/GV.\n  const isValidSiblingTarget=s=>!!s&&levelExists(s)&&!(levelClosedSet&&levelClosedSet.has(s))&&parentOf(level,s)===mv.gv;\n  const muniTipHTML=(nm,owners)=>{\n    const lvl=LEVELS[level], fem=lvl.de==='da';\n    const destPl={Setor:'setores',GV:'GVs',Mercado:'mercados'}[lvl.destLabel]||lc(lvl.destLabel)+'s';\n    const siblingCtx=owners.includes(mv.sector);\n    const validFn=siblingCtx?isValidSiblingTarget:isValidCtxTarget;\n    const closing=owners.filter(o=>o===mv.sector);\n    const rest=owners.filter(o=>o!==mv.sector);\n    const valid=rest.filter(validFn);\n    const invalid=rest.filter(o=>!validFn(o));\n    // Grouped by WHY instead of listed one-per-código — a município that splits across a couple\n    // dozen setores (São Paulo etc.) otherwise buries the handful of real options under a wall of\n    // near-identical ✕ lines. The bucket key carries the specific detail (which OTHER gerência,\n    // say), so 3234/3236/3237 all rejected for the SAME gerência collapse into one line while a\n    // different gerência still gets its own — nothing gets merged that shouldn't be.\n    const bucketOf=o=>{\n      if(!levelExists(o)) return {key:'gone',label:'fora da base atual'};\n      if(levelClosedSet&&levelClosedSet.has(o)) return {key:'closed',label:`já fechad${fem?'as':'os'} em etapa anterior`};\n      if(lvl.parentLabel){\n        const p=parentOf(level,o);\n        if(mv.gv && p!==mv.gv) return {key:'parent:'+p,label:`é de outr${lvl.parentWord==='gerência'?'a':'o'} ${lvl.parentWord} <b>${esc(String(p||'?'))}</b>`};\n      }\n      return {key:'noborder',label:`não faz fronteira com ${lc(lvl.label)} sendo fechad${fem?'a':'o'}`};\n    };\n    const buckets=new Map();\n    invalid.forEach(o=>{\n      const b=bucketOf(o);\n      if(!buckets.has(b.key)) buckets.set(b.key,{label:b.label,codes:[]});\n      buckets.get(b.key).codes.push(o);\n    });\n    const row=(dot,label,codes)=>`<div class=\"tiprow\"><div class=\"tiprow-hd\"><span class=\"tipdot\">${dot}</span><span class=\"tiplbl\">${label}</span></div><span class=\"tipcodes\">${codes.join(', ')}</span></div>`;\n    const rowsHtml=[\n      closing.length&&row('●',`sendo fechad${fem?'a':'o'} nesta etapa`,closing),\n      valid.length&&row('✓','destino válido',valid),\n      ...[...buckets.values()].map(b=>row('✕',b.label,b.codes))\n    ].filter(Boolean).join('');\n    return `<div class=\"tipmuni\"><b>${mDisp(nm)}</b> — ${owners.length} ${destPl} neste município:${rowsHtml}</div>`;\n  };\n  const nbrBySec={};\n  // Border geometry for a genuinely multi-owner município is drawn ONCE, after every fill, from the\n  // município's FULL original shape — never from a per-slice fragment. sliceFeatureEqual band-cuts a\n  // real (often concave) shape into straight-edged pieces, and a real município can split into SEVERAL\n  // disjoint ring fragments per band; tracing every one of those individually produced a blocky,\n  // jagged mess instead of one clean outline. Keyed by município (nm) so it draws exactly once even\n  // though the município itself may contribute several fs entries across different destination keys.\n  const multiBorderFeats={};\n  mv.nbrFeat.forEach(f=>{\n    const nm=mkey(f.properties.name||f.properties.NOME||f.properties.nome||'',f.__uf);\n    // A município's real sector count is a CSV fact (DOM.setoresByMuni), never scoped to whichever\n    // destinations happen to be live candidates for THIS closure — mv.muniOwners only tracks the\n    // latter (top mv.dest picks), so a real multi-sector município whose other sectors lost the vote\n    // rendered as one flat colour instead of striped. Same source of truth mv.sharedMuniSlices\n    // already uses for the closing-sector side of this exact problem.\n    const csvOwners=DOM.setoresByMuni&&DOM.setoresByMuni[nm];\n    const owners=(csvOwners&&csvOwners.size?[...csvOwners]:(mv.muniOwners&&mv.muniOwners[nm]?[...new Set(mv.muniOwners[nm])]:[])).sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n    if(owners.length>1){\n      // Real ownership is always recorded at SETOR grain (owners, above) — a GV/mercado closure has\n      // to judge \"does this município actually split HERE\" at ITS OWN grain, not the raw setor one:\n      // translate every real setor owner to its GV/mercado (destUnitOfSector — world-aware, the same\n      // resolver the single-owner fallback below already relies on for cascade/override correctness)\n      // and dedupe. Two setores owning a município that both roll up into the SAME GV are not a split\n      // at the GV level — only genuinely different level-owners earn their own stripe. At setor level\n      // this is the identity (no translation), so setor-closing behaviour is unchanged.\n      // A level-owner already closed in an earlier etapa of THIS session has been absorbed into\n      // whatever it closed into — it is not a live, separate owner of this município anymore, so it\n      // must never count toward \"does this município still split\" nor show up in the tooltip list.\n      // Filtered here (once, before the >1 check) rather than downstream: dropping a closed owner\n      // can legitimately collapse a real 2-owner município down to a plain single-owner one, which\n      // is exactly the fallthrough this function already handles below.\n      const levelOwners = (level==='setor' ? owners\n        : [...new Set(owners.map(so=>destUnitOfSector(level,so,world)).filter(Boolean))])\n            .filter(u=>!(levelClosedSet&&levelClosedSet.has(u)))\n            .sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n      if(levelOwners.length>1){\n        multiBorderFeats[nm]=f;\n        // Only a VALID target earns its own stripe: a level-owner that's cross-parent, already closed,\n        // or not actually adjacent to this closure is treated as if it were not a real owner at all,\n        // and its share of the shape goes to the valid ones instead. Zero valid owners still stripes\n        // (so \"more than one owner here\" survives) but with one flat neutral tone, since there is no\n        // real destination colour to award any of them.\n        const valid=levelOwners.filter(isValidCtxTarget);\n        const sliceOwners=valid.length?valid:levelOwners;\n        // Zero valid owners: slice count is 1 (no cut at all), not levelOwners.length — there is no\n        // real destination colour to award any of the pieces, so a bbox-band cut here only carves the\n        // shape into arbitrary straight-edged fragments with nothing meaningful to show for it.\n        sliceFeatureEqual(f,valid.length?sliceOwners.length:1).forEach((slice,i)=>{\n          if(slice.__empty) return;\n          const key=valid.length?sliceOwners[i]:'__MULTI_NOVALID__';\n          (nbrBySec[key]=nbrBySec[key]||[]).push({feature:slice,muni:nm,owners:levelOwners});\n        });\n        return;\n      }\n      // Every real setor under this município rolled up into the same level-owner (or none) — no\n      // genuine split to show at this grain, so it falls through to the plain single-owner path below.\n    }\n    const s=String((mv.nbrCurrentSector&&mv.nbrCurrentSector[nm])||'');\n    (nbrBySec[s]=nbrBySec[s]||[]).push({feature:f,muni:nm});\n  });\n  // Same \"only a valid target earns its own stripe\" rule as the neighbour branch above — a sibling\n  // sector sharing this município with the one closing (São Paulo etc.) does not get its own wedge\n  // just for existing in the CSV; it needs to actually be a live destination candidate too. Grouped\n  // under the SAME '__MULTI_NOVALID__' bucket so an invalid sibling reads as flat neutral context\n  // instead of an arbitrary colour that looks like a real, selectable destination.\n  (mv.sharedMuniSlices||[]).forEach(sl=>{\n    const csvOwners=DOM.setoresByMuni&&DOM.setoresByMuni[sl.muni];\n    // Same closed-owner exclusion as the neighbour loop above — a sibling already closed earlier\n    // this session is gone, not just an invalid pick, so it drops out of the owner list entirely.\n    const owners=(csvOwners&&csvOwners.size?[...csvOwners]:[mv.sector,sl.sector])\n      .filter(o=>!(levelClosedSet&&levelClosedSet.has(o)))\n      .sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n    const key=isValidSiblingTarget(sl.sector)?sl.sector:'__MULTI_NOVALID__';\n    (nbrBySec[key]=nbrBySec[key]||[]).push({feature:sl.feature,muni:sl.muni,owners});\n  });\n  // A município shared with the CLOSING sector itself (São Paulo etc., not just a bordering\n  // neighbour) never passes through the mv.nbrFeat loop above (where multiBorderFeats is normally\n  // populated). This used to be rebuilt by concatenating mv.geoFeat's own slice with every\n  // mv.sharedMuniSlices piece into one MultiPolygon and stroking that — but sliceFeatureEqual\n  // band-cuts a município into straight fragments with no real polygon union, so stroking that\n  // naive concatenation traced every INTERNAL cut too, not just the true external edge (the stray\n  // straight gray lines cutting across a município like São Paulo). mv.muniFullShape[nm] — stashed\n  // during slicing, above, BEFORE mv.geoFeat[n] gets overwritten with just this sector's own slice —\n  // is the real, un-cut original polygon, so this draws exactly the same clean single-feature\n  // outline a bordering-neighbour shared município already gets, with none of the internal seams.\n  Object.keys(mv.muniFullShape||{}).forEach(nm=>{ if(!multiBorderFeats[nm]) multiBorderFeats[nm]=mv.muniFullShape[nm]; });\n  Object.keys(nbrBySec).forEach(s=>{\n    const fs=nbrBySec[s];\n    const isNovalid=s==='__MULTI_NOVALID__';\n    const isDest=!isNovalid&&!!s&&mv.dest.includes(s);\n    const isRejected=!isNovalid&&!!s&&mv.rejectedCodes&&mv.rejectedCodes.has(s);\n    const isUnresolved=!isNovalid&&!s;\n    // Um município com um código de setor real — mesmo não sendo candidato de destino NESTA etapa,\n    // mesmo sendo um setor já fechado numa etapa anterior — não deixa de existir nem de ter um\n    // destino de verdade; ele só não é candidato AGORA. Cor estável troca o cinza \"sem info\" por essa\n    // identidade real. Só rejected (lado errado da hierarquia), unresolved (sem shape/sem dado nenhum)\n    // e novalid (nenhum setor real deste município qualifica como destino) continuam sem cor de\n    // verdade — esses não têm \"pra onde foi\".\n    const col=isNovalid?null:(isDest?(mv.color[s]||null):(!isRejected&&!isUnresolved?colorForSector(s):null));\n    const rej=isRejected?(mv.rejectedInfo&&mv.rejectedInfo[s]):null;\n    // Sem união de polígono de verdade, a única forma de emular UM contorno em volta do setor\n    // inteiro é desenhar essa borda ANTES do preenchimento: cada aresta INTERNA (entre dois membros\n    // do mesmo setor) fica coberta pelo preenchimento opaco do vizinho por cima, e só a aresta\n    // EXTERNA (sem vizinho do mesmo setor do outro lado) continua visível — e, se dois setores\n    // \"inválidos\" diferentes se tocam, cada um desenha sua própria borda naquele limite, então a\n    // linha aparece do mesmo jeito, sem sumir.\n    if(isRejected) fs.forEach(fw=>{\n      const bp=document.createElementNS(NS,'path');\n      bp.setAttribute('d',pathFor(fw.feature,pr));\n      bp.setAttribute('class','sect-rejected-border');\n      svg.appendChild(bp);\n    });\n    fs.forEach(fw=>{\n      const f=fw.feature;\n      const p=document.createElementNS(NS,'path');\n      p.setAttribute('d',pathFor(f,pr));\n      // Stashed so discard-paint (further down) can re-slice the EXACT geometry this hitbox shows —\n      // for a município shared across several real sectors, `f` here is already just ONE owner's\n      // slice of the full shape, not the whole município; re-slicing the full shape instead of this\n      // would carve alternative-divisions bigger than the piece actually on screen, spilling the\n      // divisor lines out past this piece's real edge into a neighbouring owner's area.\n      p._geoFeature=f;\n      p.setAttribute('class','sect'+(isRejected?' rejected':(isUnresolved?' unresolved':(isNovalid?' plain':''))));\n      // Stamped so an external trigger (the span-rule fix card's hover, see moveWork) can look this\n      // piece up by município identity and toggle .munihl on it — same class the GV/mercado\n      // unit-hover group already uses, just driven from outside the map instead of from its own\n      // mouse events. Not every fw carries a muni (only pieces that came through the single- or\n      // multi-owner branches above do; a bare context feature with no CSV owner has none).\n      if(fw.muni) p.setAttribute('data-muni',fw.muni);\n      // Transfer-movement stage: highlight the município being retargeted permanently (not just on\n      // hover, unlike the fix-card's setPlanHl reuse of this same .munihl class) and let a click cycle\n      // its target sector — mv.view (not the not-yet-declared `asis` local) since this runs before that\n      // const is assigned further down this function.\n      if(mv.kind==='transfer' && fw.muni===mv.xferMuni){\n        if(mv.view!=='asis'){ p.style.cursor='pointer'; p.onclick=()=>cycleTransferTarget(); }\n      }\n      // isDest: NO stroke at all, fill only. Matching stroke to fill (even at the same opacity)\n      // still doubles up at every shared município edge — two adjacent members each paint their own\n      // translucent stroke on top of the shared line, so it composites darker than the interior even\n      // when both strokes are the exact fill colour. Only an absent stroke truly stops compositing.\n      if(col){ p.setAttribute('fill',col); }\n      if(pullModeOn(mv) && col && (!fw.owners || fw.owners.length<=1) && !isRejected && !isUnresolved && !isNovalid && fw.muni){\n        p.style.fillOpacity='1';\n        p.style.cursor='pointer';\n        p.onclick=()=>openPullConfirm(s, fw.muni);\n        const nmFull=(mUf(fw.muni)?mUf(fw.muni)+' - ':'')+mName(fw.muni);\n        const cbs=(mv.nbrCbsByMuni&&mv.nbrCbsByMuni[fw.muni])||0;\n        p.onmousemove=e=>showTip(e,`<b>${nmFull}</b><br>${fmt(cbs)} CBs`);\n        p.onmouseleave=()=>$('#tip').style.opacity=0;\n      }\n      const tipParts=[];\n      if(rej){\n        tipParts.push(level==='setor'\n          ? `${LEVELS.setor.destLabel} ${s} é da GV ${rej.parent||'?'} — a GV do setor fechando é a ${mv.gv||'?'}, então não entra como destino.`\n          : `${LEVELS.gv.destLabel} ${s} é do mercado ${rej.parent||'?'} — o mercado da GV fechando é o ${mv.gv||'?'}, então não entra como destino.`);\n      }\n      if(isUnresolved){\n        // Setor não encontrado pro município vizinho — mostra o NOME real do município no hover em vez\n        // de deixar em branco ou fingir um destino. Estado de debug de propósito: ajuda a achar rápido\n        // qual município ficou sem sector resolvido na base/consulta ao vivo.\n        const nmDisp=mDisp(mkey(f.properties.name||f.properties.NOME||f.properties.nome||'',f.__uf));\n        tipParts.push(`<b>${nmDisp}</b><br><span style=\"color:var(--mut)\">setor não encontrado — sem CBs neste snapshot ou não bateu com a base</span>`);\n      }\n      // Município com mais de um setor real: lista todos, válidos e não-válidos (com o motivo), numa\n      // seção própria do tooltip — mesmo quando este slice específico já tem um tooltip por outro\n      // motivo (rejected/unresolved), a lista fica anexada embaixo.\n      if(fw.owners&&fw.owners.length>1) tipParts.push(muniTipHTML(fw.muni,fw.owners));\n      if(tipParts.length){\n        p.onmousemove=e=>showTip(e,tipParts.join(''));\n        p.onmouseleave=()=>$('#tip').style.opacity=0;\n      }\n      svg.appendChild(p);\n      if(fw.owners&&fw.owners.length>1){\n        const hp=document.createElementNS(NS,'path');\n        hp.setAttribute('d',pathFor(f,pr));\n        hp.setAttribute('fill','url(#multiSectorHatch)');\n        hp.style.pointerEvents='none';\n        svg.appendChild(hp);\n      }\n    });\n    if(!s||isNovalid) return;\n    // One number per sector, at the pole of inaccessibility of its DISSOLVED area — the whole group\n    // of members at once, not a representative member, so the number sits in the visual middle of the\n    // territory the operator actually sees.\n    const lp=labelPointOf(mv,'s:'+s,fs.map(fw=>fw.feature),pr);\n    // Context areas always read LIGHT — .sect paints dest colours at fill-opacity .32 over white, and\n    // plain/closed-ctx are near-white greys — so a sector number never wants white ink, whatever colour\n    // the sector itself was assigned.\n    // A leader line is only worth drawing when there's real room to write into — otherwise a big\n    // multi-sector município (São Paulo etc., dozens of slivers) turns into a canvas-wide tangle of\n    // lines, one per sliver too thin to ever hold readable text. lp.r is the radius of the biggest\n    // circle that fits inside the slice; below this it isn't worth labelling at all.\n    if(LEGEND_ON.ctx && lp&&lp.p&&lp.r>=5) labels.push({anchor:lp.p,big:true,lines:[{t:s,size:11.5,weight:700}]});\n  });\n  const asis=mv.view==='asis';\n  if(level==='setor'){\n    // Setor fechando: a UNIDADE é o próprio município — cada um se comporta, colore e rotula\n    // individualmente, exatamente como sempre foi.\n    Object.entries(mv.geoFeat).forEach(([m,f])=>{const p=document.createElementNS(NS,'path');p.setAttribute('d',pathFor(f,pr));\n      const fillCol=asis?CLOSE_COLOR:(mv.color[unitAssign(m)]||'#444');\n      p.setAttribute('class','muni');p.setAttribute('fill',fillCol);\n      if(!SHOW_MUNI_OUTLINE) p.style.stroke='none';\n      if(pullModeOn(mv)) p.style.opacity='0.35';\n      if(!asis && !pullModeOn(mv)) p.onclick=()=>{editSelected(m2=>{\n        if(!m2.dest.length) return;\n        const cur=m2.assign[m];\n        m2.selMuni=m;\n        if(SESS.settings&&SESS.settings.forceContiguity){\n          const next=nextContigSafeDest(m2,m);\n          if(next===cur && m2.dest.length>1){\n            const d=$('#ds'); if(d){ d.textContent=`não dá pra mover ${mDisp(m)} — toda alternativa quebraria a continuidade de território de algum setor.`; d.classList.add('err'); }\n            return;\n          }\n          m2.assign[m]=next;\n        } else {\n          m2.assign[m]=m2.dest[(m2.dest.indexOf(cur)+1)%m2.dest.length];\n        }\n      });};\n      p.style.cursor=(asis||pullModeOn(mv))?'default':'pointer';\n      if(!pullModeOn(mv)){\n        p.onmousemove=e=>showTip(e,asis?`<b>${mDisp(m)}</b><br>${fmt(mv.cbsByMuni[m]||0)} CBs · setor ${mv.sector}`:`<b>${mDisp(m)}</b><br>${fmt(mv.cbsByMuni[m]||0)} CBs → ${unitAssign(m)||'–'}<br><span style=\"color:var(--mut)\">clique p/ trocar</span>`);\n        p.onmouseleave=()=>$('#tip').style.opacity=0;\n      }\n      svg.appendChild(p);\n      const lp=labelPointOf(mv,'m:'+m,[f],pr);\n      const at=(lp&&lp.p)||cxy(f,pr);\n      const nmFull=(mUf(m)?mUf(m)+' - ':'')+mName(m);\n      if(LEGEND_ON.muni) labels.push({anchor:at,cbs:+(mv.cbsByMuni[m]||0),\n        lines:[{t:nmFull,size:8.2,weight:700}]});\n    });\n  } else {\n    // GV/mercado fechando: a UNIDADE é o setor (ou a GV) inteiro, nunca o município isolado — vários\n    // municípios do MESMO setor têm de ler como UMA área só (stroke = fill inline, opaco, então não há\n    // o artefato de dupla-composição da camada de destino: aqui a cor é sólida, não translúcida).\n    // A fronteira que o operador precisa ver é setor-a-setor, não município-a-município, e isso já\n    // sai de graça da própria cor: setores com destinos diferentes contrastam sem precisar de traço\n    // algum. O único caso que fica sem contorno é dois setores vizinhos caindo no MESMO destino — sem\n    // union real de polígono (ver nota da camada de contexto, acima) não há como bordear só o setor\n    // sem também bordear cada município dentro dele, e a segunda opção é a bagunça pontilhada que essa\n    // troca veio corrigir. UM rótulo só por unidade, no ponto de inacessibilidade da área do grupo,\n    // nunca um rótulo por município.\n    Object.entries(mv.unitMunis||{}).forEach(([u,ms])=>{\n      const fillCol=asis?CLOSE_COLOR:(mv.color[mv.assign[u]]||'#444');\n      const feats=ms.map(m=>mv.geoFeat[m]).filter(Boolean);\n      // Sector-territory outline: ONE outline around this unit's whole dissolved territory, drawn\n      // before any fill of this unit — see the .unit-outline comment (CSS, above) for why order\n      // matters here. Always on for GV/mercado closing (not gated by SHOW_MUNI_OUTLINE, unlike the\n      // município-level toggle used at setor level) — telling one sector's territory apart from its\n      // neighbour's is the whole point of this view, not an optional extra the operator opts into.\n      ms.forEach(m=>{\n        const f=mv.geoFeat[m]; if(!f) return;\n        const bp=document.createElementNS(NS,'path');\n        bp.setAttribute('d',pathFor(f,pr));\n        bp.setAttribute('class','unit-outline');\n        svg.appendChild(bp);\n      });\n      // Todo município deste u é UMA unidade só na tela — passar o mouse sobre qualquer pedaço tem\n      // que acender o grupo inteiro, não só o pedaço que o cursor está tocando. :hover no CSS só\n      // sabe acender o próprio <path>; o grupo inteiro precisa de JS guardando os irmãos.\n      const els=[];\n      ms.forEach(m=>{\n        const f=mv.geoFeat[m]; if(!f) return;\n        const p=document.createElementNS(NS,'path');p.setAttribute('d',pathFor(f,pr));\n        // stroke SEMPRE igual ao fill (nunca 'none', mesmo com o toggle desligado) — dois\n        // municípios opacos da MESMA unidade ainda rasterizam com uma costura fina de antialiasing\n        // na borda compartilhada (mesmo fix já aplicado em .sect.plain/.sect.rejected); só um\n        // stroke da cor exata do fill fecha essa costura, o que é o que faz cada município desta\n        // unidade ler como UM território dissolvido só, em vez de uma malha de municípios\n        // individualmente contornados — exatamente o bug que motivou este comentário.\n        p.setAttribute('class','muni');p.setAttribute('fill',fillCol);p.style.stroke=fillCol;\n        els.push(p);\n        if(!asis) p.onclick=()=>{editSelected(m2=>{\n          if(!m2.dest.length) return;\n          const cur=m2.assign[u];\n          m2.selMuni=u;\n          if(SESS.settings&&SESS.settings.forceContiguity){\n            const next=nextContigSafeDest(m2,u);\n            if(next===cur && m2.dest.length>1){\n              const d=$('#ds'); if(d){ d.textContent=`não dá pra mover ${LEVELS[level].childLabel} ${u} — toda alternativa quebraria a continuidade de território de algum ${lc(LEVELS[level].destLabel)}.`; d.classList.add('err'); }\n              return;\n            }\n            m2.assign[u]=next;\n          } else {\n            m2.assign[u]=m2.dest[(m2.dest.indexOf(cur)+1)%m2.dest.length];\n          }\n        });};\n        p.style.cursor=asis?'default':'pointer';\n        p.onmouseenter=()=>{ els.forEach(e=>e.classList.add('munihl')); };\n        p.onmousemove=e=>showTip(e,asis?`<b>${LEVELS[level].childLabel} ${u}</b><br>${fmt(mv.munis[u]?.cbs||0)} CBs · ${LEVELS[level].label} ${mv.sector}`:`<b>${LEVELS[level].childLabel} ${u}</b><br>${fmt(mv.munis[u]?.cbs||0)} CBs → ${mv.assign[u]||'–'}<br><span style=\"color:var(--mut)\">clique p/ trocar</span>`);\n        p.onmouseleave=()=>{ els.forEach(e=>e.classList.remove('munihl')); $('#tip').style.opacity=0; };svg.appendChild(p);\n      });\n      const lp=labelPointOf(mv,'u:'+u,feats,pr);\n      const at=(lp&&lp.p)||(feats[0]&&cxy(feats[0],pr));\n      if(at && LEGEND_ON.muni){\n        labels.push({anchor:at,big:true,cbs:+(mv.munis[u]?.cbs||0),\n          lines:[{t:u,size:9,weight:700}]});\n      }\n    });\n  }\n  // One clean outline per genuinely multi-owner município, from its FULL original shape — see the\n  // comment where multiBorderFeats is built (above, in the population loop) for why this can't be\n  // done per-slice-fragment. Drawn LAST, after every fill (including the closing sector's own\n  // município fills above) — the shared município's own slice is opaque and painted on top of\n  // whatever came before it, so a border drawn earlier than that fill got silently erased along the\n  // stretch of perimeter running through the closing sector's own piece.\n  Object.values(multiBorderFeats).forEach(feat=>{\n    const mb=document.createElementNS(NS,'path');\n    mb.setAttribute('d',pathFor(feat,pr));\n    mb.setAttribute('class','sect-multi-border');\n    svg.appendChild(mb);\n  });\n  // Every label is drawn directly at its own labelPointOf anchor — already the point of maximum\n  // clearance inside its território — instead of hunting for open canvas space. No leader lines, no\n  // shrinking, no dropped labels: a name always sits on the shape it belongs to.\n  drawOsmLayer(svg,pr,feats,NS);\n  labels.forEach(L=>{\n    const g=document.createElementNS(NS,'g'); g.setAttribute('class','lbl'+(L.big?' big':''));\n    const hTot=L.lines.reduce((a,l)=>a+l.size*1.2,0);\n    let ty=L.anchor[1]-hTot/2+L.lines[0].size*0.86;\n    const ink='#fff', inkMut='rgba(255,255,255,.85)';\n    const halo='rgba(0,0,0,.72)';\n    const swBase=L.big?2.4:1.8;\n    L.lines.forEach(l=>{ const t=document.createElementNS(NS,'text');\n      t.setAttribute('x',L.anchor[0].toFixed(1)); t.setAttribute('y',ty.toFixed(1));\n      t.setAttribute('font-size',l.size); t.setAttribute('data-fs',l.size); t.setAttribute('font-weight',l.weight||600);\n      t.setAttribute('data-sw',swBase); t.style.strokeWidth=swBase+'px';\n      t.style.fill=l.mut?inkMut:ink; t.style.stroke=halo;\n      t.textContent=l.t; g.appendChild(t); ty+=l.size*1.2; });\n    svg.appendChild(g);\n  });\n  const rejectedLegend=(!asis && mv.gvRejected && mv.gvRejected.length)\n    ? `<span><b style=\"background:#e4e4ea;border:1px solid #9797a3\"></b>fora da hierarquia (${mv.gvRejected.length}) — passe o mouse no mapa</span>`\n    : '';\n  $('#legend').innerHTML=asis\n    ?`<span><b style=\"background:${CLOSE_COLOR}\"></b>${LEVELS[level].label} ${mv.sector} (estrutura antes desta etapa — ${level==='setor'?Object.keys(mv.geoFeat).length+' municípios':Object.keys(mv.unitMunis||{}).length+' '+LEVELS[level].childLabel.toLowerCase()+'(s)'})</span>`\n    // \"vizinhos (contexto)\"/\"setor já fechado antes\" (.sect.plain/.sect.closed-ctx) still never\n    // legend — those stay dead. \"rejected\" is the one gray key that's real again: it marks a\n    // geographically-touching setor/GV excluded for failing the hierarchy rule, appended below.\n    :mv.dest.map(s=>`<span><b style=\"background:${mv.color[s]}\"></b>${LEVELS[level].destLabel} ${s} — ${c.gained[s]} líder(es)</span>`).join('')+rejectedLegend;\n  // Municipalities outside the dominance base are deliberately NOT drawn — no shape, no hover, no\n  // legend entry. They are reported in the panel instead, so the map only ever shows territory that\n  // is actually being reallocated.\n  const aliasNote=MUNI_ALIAS_ERROR\n    ? `<div style=\"margin-top:4px\"><b style=\"color:var(--closing)\">⚠ tabela de correções de grafia de município não carregou:</b> ${esc(MUNI_ALIAS_ERROR)} — aliases cadastrados nela não estão em vigor nesta sessão.</div>`\n    : '';\n  if(ignNote) ignNote.innerHTML=((mv.excludedMunis&&mv.excludedMunis.length)\n    ? `<b style=\"color:var(--closing)\">${fmt(mv.excludedMunis.length)} município(s) / ${fmt(mv.excludedCbs||0)} CBs</b> fora da base de dominantes deste setor não são desenhados no mapa — as CBs seguem a líder do seu grupo (detalhe em “Municípios → setor destino”).`\n    : '')+aliasNote;\n  const legendNote=$('#mapLegendNote');\n  if(legendNote){\n    const lvl=LEVELS[level], fem=lvl.de==='da', childPl=CHILD_PLURAL[lvl.childLabel]||lc(lvl.childLabel);\n    legendNote.innerHTML=`Coloridos = ${childPl} ${lvl.de} ${lc(lvl.label)} fechad${fem?'a':'o'} nesta etapa (cor = destino). Ao redor, cada área é ${fem?'uma':'um'} <b>${lc(lvl.label)} vizinh${fem?'a':'o'} inteir${fem?'a':'o'}</b> com o código no meio — não ${childPl} soltos: cor = ${lc(lvl.label)} destino, cinza = contexto, cinza tracejado = ${lc(lvl.label)} já fechad${fem?'a':'o'} numa etapa anterior. Shapes reais IBGE/tbrugz.`;\n  }\n  applyMapView();\n}\nasync function runSql(){const sql=$('#sqlIn').value.trim();$('#sqlStatus').textContent='rodando…';$('#sqlStatus').classList.remove('err');\n  try{ await ensurePinned(); const resp=await q(sql);const rs=rows(resp);const cols=(resp.manifest?.schema?.columns||[]).map(c=>c.name);\n    $('#sqlOut').innerHTML=`<thead><tr>${cols.map(c=>`<th>${c}</th>`).join('')}</tr></thead><tbody>`+rs.slice(0,200).map(r=>`<tr>${cols.map(c=>`<td>${r[c]??'·'}</td>`).join('')}</tr>`).join('')+`</tbody>`;\n    $('#sqlStatus').textContent=rs.length+' linha(s)';}\n  catch(e){$('#sqlStatus').textContent='bloqueado/erro: '+e.message;$('#sqlStatus').classList.add('err');}\n}\nfunction renderAll(){\n  renderStage();\n  fitMapHeight();\n  renderTimeline(); renderSectorPicker();\n  const shb=$('#openShare'); if(shb) shb.disabled=!SESS.timeline.length;\n  const dpb=$('#dupSession'); if(dpb) dpb.disabled=!SESS.timeline.length;\n  const rnb=$('#renameSession'); if(rnb) rnb.disabled=!SESS.timeline.length;\n  const mv=SESS.timeline[SESS.selected];\n  // Display contract everywhere else in this file is \"never show snapshotRaw, only\n  // snapshotSyncedAt\" (see fmtSyncedAt's own comment) — deliberately broken ONLY for a historical\n  // pin, where the whole point of picking a date is confirming which one you're actually looking\n  // at; snapshotSyncedAt there would just say \"now\", telling you nothing about what you picked.\n  const pillEl=$('#liveState');\n  if(pillEl){\n    pillEl.classList.toggle('locked', !!SESS.locked);\n    // Snapshot date and ELO operational cycle are two independently-pinned things (see\n    // ensurePinned/queryEloCycle) — the pill only ever showed the first, leaving the second\n    // invisible even though every ativas/cbs number on screen depends on it just as much.\n    const cycleTxt = SESS.eloMaxCycle ? (' · ciclo ELO '+SESS.eloMaxCycle) : '';\n    if(SESS.locked){\n      pillEl.textContent='🔒 snapshot desta sessão: '+fmtSyncedAt(SESS.snapshotSyncedAt)+cycleTxt+' · clique para atualizar';\n      pillEl.title='Esta sessão foi carregada com os dados já calculados e congelados. Clique para atualizar o snapshot e liberar edição.';\n    } else {\n      pillEl.textContent = SESS.snapshotRaw\n        ? (SESS.snapshotPinnedDate ? '📌 snapshot fixado em '+fmtSyncedAt(SESS.snapshotRaw)+cycleTxt : 'snapshot atualizado em '+fmtSyncedAt(SESS.snapshotSyncedAt)+cycleTxt)\n        : '● iniciando';\n      pillEl.title='Clique pra atualizar o snapshot para o mais recente';\n    }\n  }\n  renderRuleNote(); renderSpanRule();\n  if(!mv){ ['#map','#matrix','#matrixTobe','#tobeNote','#leaders'].forEach(s=>{const el=$(s);if(el)el.innerHTML='';}); $('#kpis').innerHTML=''; $('#spans').innerHTML=''; $('#muniList').innerHTML=''; $('#plan').innerHTML=''; renderXferBreakdown(null,'matrixXfer','matrixXferBlock','matrixXferTitle'); renderXferBreakdown(null,'matrixTobeXfer','matrixTobeXferBlock','matrixTobeXferTitle'); renderXferLeaders(null); return; }\n  $('#viewToggle').querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.v===mv.view));\n  $('#policy').querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.p===mv.policy));\n  $('#optBadge').textContent=mv.optBadge||'';\n  drawMap(); renderContig(); renderSpanRule(); renderKpis(); renderSpans(); renderMuni(); renderLeaders(); renderMatrix(); renderMatrixTobe(); renderPolicy(); renderPlan(); renderReport(); renderCumulative();\n}\n\n// ---------- event wiring ----------\nwindow.addEventListener('resize', ()=>{ fitMapHeight(); drawMap(); });\n$('#confirmSnap').onclick=()=>{ SESS.stage='main'; renderAll(); };\n$('#eloCycleApply').onclick=async()=>{\n  const v=Math.round(+($('#eloCycleInput').value||''));\n  const s=$('#eloCycleStatus');\n  if(!v||v<=0){ if(s) s.textContent='informe um ciclo válido (número inteiro positivo).'; return; }\n  SESS.eloMaxCycle=v;\n  const btn=$('#eloCycleApply'); if(btn) btn.disabled=true;\n  loader(true,'congelando snapshot da sessão…');\n  try{ await ensurePinned(); }\n  catch(e){ if(s) s.textContent='erro ao congelar snapshot: '+e.message; }\n  finally{ if(btn) btn.disabled=false; loader(false); renderAll(); }\n};\n$('#eloCycleAuto').onclick=async()=>{\n  const btn=$('#eloCycleAuto'), s=$('#eloCycleStatus');\n  const origText=btn?btn.textContent:'';\n  if(btn){ btn.disabled=true; btn.textContent='buscando…'; }\n  loader(true,'congelando snapshot da sessão…');\n  try{\n    const [v]=await Promise.all([queryEloCycle(), ensurePinned()]);\n    if(!v) throw new Error('não foi possível determinar o ciclo mais recente');\n    SESS.eloMaxCycle=v;\n    const inp=$('#eloCycleInput'); if(inp) inp.value=v;\n  }catch(e){\n    if(s) s.textContent='erro: '+e.message;\n  }finally{\n    if(btn){ btn.disabled=false; btn.textContent=origText||'Usar o ciclo mais recente'; }\n    loader(false); renderAll();\n  }\n};\n$('#openMove').onclick=openMove;\n$('#moveClose').onclick=closeMove;\n$('#moveCancel').onclick=closeMove;\n$('#moveBg').addEventListener('click',e=>{ if(e.target.id==='moveBg') closeMove(); });\nroot.querySelectorAll('input[name=\"moveLevel\"]').forEach(r=>r.addEventListener('change',renderMoveForm));\n$('#moveRand').onclick=moveRandPick;\n$('#moveGo').onclick=submitMove;\ninitMacCombo($('#moveCode'), $('#moveCodeList'));\n$('#moveCode').addEventListener('keydown',e=>{ if(e.key==='Enter') submitMove(); });\n$('#confirmSessionPick').onchange=()=>loadSession($('#confirmSessionPick').value);\n$('#optim').onclick=()=>editSelected(mv=>{ mv.assign=optimizeFor(mv); });\n$('#autoTobe').onclick=()=>editSelected(mv=>{ mv.assign=autoAssignFor(mv); mv.optBadge='TOBE por adjacência'; });\n$('#viewToggle').querySelectorAll('button').forEach(b=>b.onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;mv.view=b.dataset.v;renderAll();});\n$('#mapZoomIn').onclick=()=>zoomMapBy(1.4);\n$('#mapZoomOut').onclick=()=>zoomMapBy(1/1.4);\n// Rodovias é só um atalho de \"marcar/desmarcar todos os níveis viários de uma vez\" — o estado real\n// de cada nível continua vivendo em OSM_ON por chave individual, então esconder um nível depois de\n// carregado é só desmarcar aquele checkbox, sem afetar os outros nem precisar recarregar nada.\nfunction syncRodoviasMaster(){\n  const master=$('#osmRodovias'); if(!master) return;\n  const on=HIGHWAY_KEYS.map(k=>!!OSM_ON[k]);\n  const allOn=on.every(Boolean), allOff=on.every(v=>!v);\n  master.checked=allOn; master.indeterminate=!allOn&&!allOff;\n}\n[...HIGHWAY_KEYS,'rivers','airports'].forEach(k=>{ const el=$('#osm'+k[0].toUpperCase()+k.slice(1)); if(el) el.onchange=()=>{ OSM_ON[k]=el.checked; syncRodoviasMaster(); drawMap(); }; });\nconst muniOutlineEl=$('#muniOutline');\nif(muniOutlineEl) muniOutlineEl.onchange=()=>{ SHOW_MUNI_OUTLINE=muniOutlineEl.checked; drawMap(); };\nconst osmRodoviasEl=$('#osmRodovias');\nif(osmRodoviasEl) osmRodoviasEl.onchange=()=>{\n  const on=osmRodoviasEl.checked;\n  HIGHWAY_KEYS.forEach(k=>{ OSM_ON[k]=on; const el=$('#osm'+k[0].toUpperCase()+k.slice(1)); if(el) el.checked=on; });\n  osmRodoviasEl.indeterminate=false;\n  drawMap();\n};\n['muni','ctx'].forEach(k=>{ const el=$('#legend'+(k==='muni'?'Muni':'Ctx')); if(el) el.onchange=()=>{ LEGEND_ON[k]=el.checked; drawMap(); }; });\nconst osmPanelEl=$('#osmPanel'), osmPanelIconEl=$('#osmPanelIcon'), osmPanelXEl=$('#osmPanelX');\nfunction syncOsmPanelOpen(){\n  if(osmPanelEl) osmPanelEl.classList.toggle('hidden', !OSM_PANEL_OPEN);\n  if(osmPanelIconEl) osmPanelIconEl.classList.toggle('hidden', OSM_PANEL_OPEN);\n}\nif(osmPanelIconEl) osmPanelIconEl.onclick=()=>{ OSM_PANEL_OPEN=true; syncOsmPanelOpen(); };\nif(osmPanelXEl) osmPanelXEl.onclick=()=>{ OSM_PANEL_OPEN=false; syncOsmPanelOpen(); };\nsyncOsmPanelOpen();\n$('#map').addEventListener('pointerdown',mapPointerDown);\n$('#map').addEventListener('wheel',e=>{ e.preventDefault(); zoomMapAt(e.deltaY<0?1.15:1/1.15, e.clientX, e.clientY); }, {passive:false});\nroot.addEventListener('pointermove',mapPointerMove);\nroot.addEventListener('pointerup',mapPointerUp);\nroot.addEventListener('pointerleave',mapPointerUp);\n// Arrastar não pode virar \"cliquei num município e troquei o destino sem querer\": se o ponteiro se\n// moveu além do limiar de arrasto, este clique (capture, antes de chegar no <path>) é engolido.\n$('#map').addEventListener('click',e=>{ if(mapDidDrag){ e.stopPropagation(); e.preventDefault(); mapDidDrag=false; } },true);\n$('#policy').querySelectorAll('button').forEach(b=>b.onclick=()=>{if(guardLocked())return;const mv=SESS.timeline[SESS.selected];if(!mv)return;mv.policy=b.dataset.p;\n  $('#policy').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b));\n  renderKpis();renderPolicy();renderPlan();});\nroot.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('on',x===b));\n  ['matrix','tobe','report','cumulative','sql'].forEach(t=>$('#tab-'+t).classList.toggle('hidden',t!==b.dataset.tab));});\n$('#runSql').onclick=runSql;\n$('#copyPlan').onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;const payload={setor:mv.sector,politica:mv.policy,plano:mv._plan};if(mv.kind==='transfer')payload.transferencia={municipio:mName(mv.xferMuni),uf:mUf(mv.xferMuni),from:mv.xferFrom,to:mv.xferTo,cbs:mv.xferMetrics.cbs};navigator.clipboard.writeText(JSON.stringify(payload,null,2));$('#copyPlan').textContent='copiado ✓';setTimeout(()=>$('#copyPlan').textContent='Copiar',1500);};\n$('#csvPlan').onclick=()=>{const mv=SESS.timeline[SESS.selected];if(!mv)return;const rowsCsv=mv._plan.map(p=>`${p.municipio},${p.uf||''},${p.cbs},${mv.sector},${p.destino},fechamento`);if(mv.kind==='transfer')rowsCsv.push(`${mName(mv.xferMuni)},${mUf(mv.xferMuni)||''},${mv.xferMetrics.cbs},${mv.xferFrom},${mv.xferTo},transferência`);const csv='municipio,uf,cbs,setor_origem,setor_destino,tipo\\n'+rowsCsv.join('\\n');\n  const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));a.download=`plano_migracao_setor_${mv.sector}.csv`;a.click();};\n$('#saveSession').onclick=saveSession;\n$('#dupSession').onclick=duplicateSession;\n$('#renameSession').onclick=renameSession;\n$('#sessionPick').onchange=()=>loadSession($('#sessionPick').value);\n$('#liveState').onclick=pillClick;\n$('#liveState').addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); pillClick(); } });\n$('#openOptions').onclick=openOptions;\n$('#optClose').onclick=closeOptions;\n$('#optCancel').onclick=closeOptions;\n$('#optApply').onclick=applyOptions;\n['setor','gv'].forEach(lvl=>['#optTargetSpan_','#optMaxSpan_','#optMinSpan_'].forEach(pfx=>$(pfx+lvl).addEventListener('input',renderOptPreview)));\n$('#optionsBg').addEventListener('click',e=>{ if(e.target.id==='optionsBg') closeOptions(); });\n$('#lockedClose').onclick=closeLockedSheet;\n$('#lockedCancel').onclick=closeLockedSheet;\n$('#lockedBg').addEventListener('click',e=>{ if(e.target.id==='lockedBg') closeLockedSheet(); });\n$('#lockedGo').onclick=async()=>{\n  const go=$('#lockedGo');\n  go.disabled=true; go.textContent='Atualizando…';\n  await refreshSnapshot();\n  closeLockedSheet();\n  go.disabled=false; go.textContent='Atualizar snapshot';\n};\nroot.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeOptions(); closeShare(); closeMove(); closeLockedSheet(); } });\n\n$('#openShare').onclick=openShare;\n$('#shareX').onclick=closeShare;\n$('#shareCancel').onclick=closeShare;\n$('#shareGo').onclick=doShare;\n$('#shareCopy').onclick=()=>{ if(!SHARE.url) return; navigator.clipboard.writeText(SHARE.url);\n  const b=$('#shareCopy'); if(b){ b.textContent='copiado ✓'; setTimeout(()=>{ const x=$('#shareCopy'); if(x) x.textContent='Copiar link'; },1500); } };\n$('#shareOpen').onclick=()=>{ if(SHARE.url) window.open(SHARE.url,'_blank'); };\n$('#shareBg').addEventListener('click',e=>{ if(e.target.id==='shareBg') closeShare(); });\n\n// ---------- init ----------\n(async()=>{\n  loader(true,'carregando…');\n  try{\n    // Snapshot pinning (ensurePinned) is deliberately NOT run here anymore — the operator picks the\n    // ELO operational cycle first (input + \"usar o mais recente\" on the confirm card), and only\n    // THAT action actually freezes the snapshot. Everything below is independent of snapLiteral.\n    await Promise.all([loadSectors(), refreshSessionPicker(), loadMuniAliases()]);\n    SESS.stage='confirm';\n  }catch(e){ setError('erro init: '+e.message); }\n  finally{ loader(false); renderAll(); const rd=$('#ready'); if(rd) rd.setAttribute('data-loaded','1'); }\n\n  // ---------- entrada vinda de /simulador/:session_id ----------\n  // O Router já validou o session_id no onEnter da rota (dataset \"Current SEC session\" —\n  // VmnSoVcVXtrYPHkY51pKw) e manda de volta para /simulador quando o id não existe. Chegando\n  // aqui o id na URL é confiável, então só extraímos do pathname e restauramos a sessão de\n  // trabalho — mesmo caminho de código do <select id=\"sessionPick\">, só que disparado pela URL\n  // em vez de um clique.\n  try{\n    const m=window.location.pathname.match(/\\/simulador\\/([^/?#]+)\\/?$/);\n    if(m && m[1]) await loadSession(decodeURIComponent(m[1]));\n  }catch(e){ setError('erro ao carregar sessão da URL: '+e.message); }\n\n  // ---------- entrada vinda da Home (/home) ----------\n  // Fase 13 (pedido do usuário — simplifica a Fase 12): a Home NÃO cria mais sessão nenhuma — só\n  // navega direto pra /simulador com `?apply=nivel:codigo` na URL, sem :session_id nenhum. Esta\n  // entrada roda sobre a sessão FRESCA do boot normal (SESS.stage já virou 'confirm' acima, sem\n  // loadSession nenhum de permeio) e é ELA que cria a sessão, no FINAL — depois de calcular a\n  // movimentação, exatamente como o clique manual em \"Salvar sessão\" já faz. A Home nunca grava\n  // nada em geo-timeline-sessions.\n  try{\n    const alvo=new URLSearchParams(window.location.search).get('apply');\n    if(alvo){\n      // Limpa o parâmetro da URL ANTES de agir. Sem isto, qualquer F5 — ou o restore de aba do\n      // navegador — refaz o fechamento numa timeline nova, em silêncio.\n      try{ history.replaceState({}, '', window.location.pathname); }catch(_){ }\n      const i=String(alvo).indexOf(':');\n      const nivel=i<0?'':String(alvo).slice(0,i).trim(), codigo=i<0?'':String(alvo).slice(i+1).trim();\n      // hasOwnProperty, e não LEVELS[nivel] direto: 'constructor', '__proto__' e 'toString'\n      // devolvem algo truthy do Object.prototype, passariam por este guard, e o usuário receberia\n      // \"Fechar undefined ainda não está disponível\" em vez da mensagem de parâmetro inválido que\n      // existe logo abaixo.\n      if(codigo && Object.prototype.hasOwnProperty.call(LEVELS,nivel)){\n        if(SESS.stage!=='confirm'){\n          // O init falhou — o catch acima já escreveu a causa em #ds e em #confirmStatus. Seguir\n          // daqui trocaria essa causa por um sintoma bem menos claro.\n          setError('a Home pediu para fechar '+nivel+' '+codigo+', mas esta tela não terminou de carregar — a causa é o erro acima. Recarregue depois de resolvê-lo.');\n        } else {\n          // Pula o card de confirmação de snapshot: quem veio da Home já escolheu o que fechar.\n          SESS.stage='main'; renderAll();\n          // Pedido do usuário: o loader tem que ficar visível do início ao fim desta sequência\n          // INTEIRA, sem apagar entre os passos. addMovement()/saveSession() ligam e desligam o\n          // loader cada um por conta própria (para o PRÓPRIO trabalho deles) — a lacuna entre um\n          // desligar e o próximo passo ligar de novo deixava a tela parecer \"pronta\" (0 etapas,\n          // sem spinner) enquanto a rede ainda trabalhava por trás.\n          loader(true,'aplicando movimentação pedida pela Home…');\n          try{\n            // MESMA lógica do botão \"Usar o ciclo mais recente\" (#eloCycleAuto), não uma versão\n            // simplificada dela: queryEloCycle() e ensurePinned() JUNTOS via Promise.all — é\n            // assim que o botão roda —, e um resultado falso INTERROMPE tudo, igual ao botão\n            // real. Sem isto, SESS.eloMaxCycle ficava null, o JOIN do ELO em computeMovement\n            // comparava operational_cycle=NULL (nunca verdadeiro em SQL) e CB/ativas saíam todos\n            // zerados sem erro nenhum sinalizando a causa.\n            if(!SESS.eloMaxCycle){\n              const [v]=await Promise.all([queryEloCycle(), ensurePinned()]);\n              if(!v) throw new Error('não foi possível determinar o ciclo ELO mais recente');\n              SESS.eloMaxCycle=v;\n            }\n            loader(true,'aplicando movimentação pedida pela Home…');\n            await addMovement(nivel,codigo);\n            // addMovement tem saídas silenciosas por desenho (catraca de hierarquia, código já\n            // fechado, setor inexistente) — pensadas pra um clique humano que já está olhando pra\n            // tela, não pra um chamador automático. Sem este teste, qualquer uma delas fazia esta\n            // entrada inteira não fazer NADA, sem uma palavra do porquê.\n            if(!SESS.timeline.length){\n              throw new Error('a movimentação não foi adicionada — a catraca de hierarquia ou outra checagem de addMovement bloqueou silenciosamente.');\n            }\n            // Fase 14 (pedido do usuário — remove o auto-save/redirect da Fase 13): a movimentação\n            // fica na timeline em memória, IGUAL a se o operador tivesse clicado em \"Adicionar\n            // movimentação\" e digitado o código à mão — nada é salvo, a URL continua em\n            // /simulador puro. Quem decide salvar (e quando) é o operador, clicando em \"Salvar\n            // sessão\" — a mesma regra de sempre (\"sessão é o que se SALVA no fim, não o que\n            // inicia o trabalho\") agora vale também pra esta entrada, não só pra Home.\n          }finally{ loader(false); }\n        }\n      } else {\n        setError('parâmetro `apply` inválido na URL: \"'+alvo+'\" — esperado algo como setor:3504');\n      }\n    }\n  }catch(e){ setError('não foi possível aplicar a movimentação pedida pela Home: '+((e&&e.message)||e)); }\n})();\n\n  }\n}\n"}},"sessionsDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"sessionsDataset"}}}},"dominantesCsvUrl":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"dominantesCsvUrl"}}}}}}}}}},{"_id":"6a9d1709a935b4022a99ca83","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"extends":"data2-core-primitives@Block","component":"OSM_Tile_Renderer","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"export default {\n  css: `:host{display:block;width:256px;height:256px;overflow:hidden;background:#f2f2f5}\n  svg{display:block;width:256px;height:256px}\n  path{fill:none}`,\n\n  render(d2) {\n    const st = d2.state || {};\n    const roads = st.roadsD || [];\n    const rivers = st.riversD || [];\n    const rd = roads.map(d => `<path d=\"${d}\" stroke=\"#b9b9c0\" stroke-width=\"1\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>`).join('');\n    const rv = rivers.map(d => `<path d=\"${d}\" stroke=\"#a9d3f2\" stroke-width=\"1.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>`).join('');\n    return `<svg viewBox=\"0 0 256 256\" xmlns=\"http://www.w3.org/2000/svg\">\n      <rect x=\"0\" y=\"0\" width=\"256\" height=\"256\" fill=\"#f2f2f5\"/>\n      ${rv}\n      ${rd}\n    </svg>`;\n  },\n\n  mounted(d2) {\n    (async () => {\n      const qs = new URLSearchParams(window.location.search);\n      const z = parseInt(qs.get('z'), 10);\n      const x = parseInt(qs.get('x'), 10);\n      const y = parseInt(qs.get('y'), 10);\n      if (!Number.isFinite(z) || !Number.isFinite(x) || !Number.isFinite(y)) {\n        d2.setState({ roadsD: [], riversD: [], ready: true, error: 'missing z/x/y' });\n        return;\n      }\n\n      const n = Math.pow(2, z);\n      const lonW = x / n * 360 - 180;\n      const lonE = (x + 1) / n * 360 - 180;\n      const latOfRow = (row) => Math.atan(Math.sinh(Math.PI * (1 - 2 * row / n))) * 180 / Math.PI;\n      const latN = latOfRow(y);\n      const latS = latOfRow(y + 1);\n\n      const proj = (lon, lat) => {\n        const worldX = (lon + 180) / 360 * 256 * n;\n        const sinLat = Math.sin(lat * Math.PI / 180);\n        const worldY = (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * 256 * n;\n        return [worldX - x * 256, worldY - y * 256];\n      };\n\n      const RDP_EPS = 1.0;\n      function rdpSimplify(pts, eps) {\n        if (pts.length < 3) return pts;\n        const a = pts[0], b = pts[pts.length - 1];\n        const dx = b[0] - a[0], dy = b[1] - a[1], len2 = dx * dx + dy * dy;\n        let maxD = 0, idx = 0;\n        for (let i = 1; i < pts.length - 1; i++) {\n          const p = pts[i];\n          let d;\n          if (len2 === 0) { d = Math.hypot(p[0] - a[0], p[1] - a[1]); }\n          else {\n            const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;\n            const px = a[0] + t * dx, py = a[1] + t * dy;\n            d = Math.hypot(p[0] - px, p[1] - py);\n          }\n          if (d > maxD) { maxD = d; idx = i; }\n        }\n        if (maxD > eps) {\n          const left = rdpSimplify(pts.slice(0, idx + 1), eps);\n          const right = rdpSimplify(pts.slice(idx), eps);\n          return left.slice(0, -1).concat(right);\n        }\n        return [a, b];\n      }\n\n      function catmullRomPathD(pts) {\n        if (pts.length < 3) return 'M' + pts.map(p => p[0].toFixed(1) + ',' + p[1].toFixed(1)).join('L');\n        let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;\n        for (let i = 0; i < pts.length - 1; i++) {\n          const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n          const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;\n          const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;\n          d += `C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;\n        }\n        return d;\n      }\n\n      const bbox = `${latS.toFixed(5)},${lonW.toFixed(5)},${latN.toFixed(5)},${lonE.toFixed(5)}`;\n      const query = `[out:json][timeout:25];(way[\"highway\"~\"^(motorway|trunk|primary|secondary)$\"](${bbox});way[\"waterway\"=\"river\"][\"intermittent\"!=\"yes\"](${bbox}););out geom;`;\n\n      try {\n        const res = await d2.rush('osmOverpassProxy').invoke({ data: query });\n        const env = res && res.data;\n        const elements = (env && env.elements) || [];\n        const roadsD = [];\n        const riversD = [];\n        elements.forEach(el => {\n          if (!el.geometry || !el.geometry.length) return;\n          const pts = el.geometry.filter(Boolean).map(p => proj(p.lon, p.lat));\n          const d = catmullRomPathD(rdpSimplify(pts, RDP_EPS));\n          if (el.tags && el.tags.waterway === 'river') riversD.push(d);\n          else roadsD.push(d);\n        });\n        d2.setState({ roadsD, riversD, ready: true });\n      } catch (e) {\n        d2.setState({ roadsD: [], riversD: [], ready: true, error: String(e && e.message || e) });\n      }\n      document.title = 'tile-ready';\n    })();\n  },\n}"}}}}}}}},{"_id":"6a9de846a931137cf2627ebd","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"extends":"data2-core-primitives@Block","component":"OSM_Roads_Importer","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"export default {\n  css: `\n  :host{display:block;height:100%;--bg:#f5f5f7;--panel:#ffffff;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--ok:#28cd41;--err:#ff3b30;--warn:#ff9500}\n  *{box-sizing:border-box}\n  .root{height:100%;overflow:auto;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;background:var(--bg);color:var(--ink);padding:20px}\n  h1{font-size:17px;margin:0 0 4px}\n  .sub{color:var(--mut);font-size:12px;margin:0 0 16px;max-width:640px}\n  .toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}\n  button{font:inherit;border-radius:8px;padding:8px 14px;cursor:pointer;border:1px solid var(--line);background:var(--panel);color:var(--ink);box-shadow:0 1px 2px rgba(0,0,0,.06)}\n  button.primary{background:var(--acc);color:#fff;border-color:var(--acc)}\n  button:disabled{opacity:.5;cursor:not-allowed}\n  .summary{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}\n  .pill{font-size:11px;padding:4px 10px;border-radius:99px;border:1px solid var(--line);background:var(--panel)}\n  .pill.pending{color:var(--mut)}.pill.in_progress{color:var(--acc);border-color:var(--acc)}\n  .pill.done{color:var(--ok);border-color:var(--ok)}.pill.error{color:var(--err);border-color:var(--err)}\n  .pill.split{color:var(--warn);border-color:var(--warn)}\n  .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin-bottom:14px}\n  .card h2{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);margin:0 0 8px;font-weight:600}\n  .log{font:11.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;max-height:260px;overflow:auto;background:#0f1613;color:#dce8e2;border-radius:8px;padding:10px}\n  .errlist{max-height:200px;overflow:auto;font-size:12px}\n  .errlist .row{padding:6px 0;border-bottom:1px solid var(--line)}\n  .errlist .row:last-child{border-bottom:none}\n  .errlist .row span{color:var(--mut);word-break:break-word}\n  .mut{color:var(--mut)}\n  @media (max-width:480px){\n    .root{padding:14px}\n    .toolbar{flex-direction:column;align-items:stretch}\n    .toolbar button, .toolbar label{width:100%}\n    button{padding:11px 14px;font-size:14px}\n    label select{flex:1}\n    .summary{gap:6px}\n  }\n  `,\n  render(){\n    return `\n    <div class=\"root\">\n      <h1>OSM Roads Brazil — importador manual</h1>\n      <p class=\"sub\">Roda a mesma busca por chunk (Overpass, um estado/classe por vez) direto neste navegador, autenticado como você — sem os bloqueios de auth/rede que a rotina em nuvem bateu. Deixe a aba aberta e use \"Executar automaticamente\", ou avance um chunk por vez.</p>\n      <div class=\"toolbar\">\n        <button id=\"btnNext\" class=\"primary\">Processar próximo chunk</button>\n        <button id=\"btnAuto\">▶ Executar automaticamente</button>\n        <label style=\"display:flex;align-items:center;gap:6px;color:var(--mut);font-size:12px\">Paralelismo\n          <select id=\"concurrency\">\n            <option value=\"1\">1</option>\n            <option value=\"2\">2</option>\n            <option value=\"3\" selected>3</option>\n            <option value=\"4\">4</option>\n            <option value=\"6\">6</option>\n          </select>\n        </label>\n        <button id=\"btnResetErrors\">Reiniciar erros</button>\n        <button id=\"btnRefresh\">↻ Atualizar</button>\n      </div>\n      <div class=\"summary\" id=\"summary\"></div>\n      <div class=\"card\">\n        <h2>Atividade</h2>\n        <div class=\"log\" id=\"log\"></div>\n      </div>\n      <div class=\"card\">\n        <h2>Chunks com erro</h2>\n        <div class=\"errlist\" id=\"errList\"></div>\n      </div>\n    </div>`;\n  },\n  mounted(d2, root){\n    const $=s=>root.querySelector(s);\n    const esc=s=>(s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\n    const nowIso=()=>new Date().toISOString();\n    const GRID_DEG=0.25;\n\n    const progressDs=()=>d2.data('progressDs');\n    const roadsDs=()=>d2.data('roadsDs');\n    // In-memory claim set: several concurrent workers each do a read-then-write on the pending\n    // queue with no server-side atomic claim, so between one worker's query and its in_progress\n    // write another worker could read the same row as still pending and grab it too. This closes\n    // that race for THIS tab's own workers (cross-tab/cross-session double-claims are still\n    // possible, same as before — acceptable for a manual single-operator import tool).\n    const claimed=new Set();\n    // Overpass's fair-use policy is explicit: \"No parallel running of multiple scripts.\" This tool's\n    // own Paralelismo setting runs N independent workers, each doing its own Overpass call — that's\n    // concurrent requests against the same server no matter how gentle each worker's own backoff is.\n    // Serialize the actual network call behind one shared chain so at most ONE Overpass request is\n    // ever in flight for the whole tab, regardless of how many workers are running; the DB-save half\n    // of each worker's cycle still overlaps freely, only the Overpass hit itself is one-at-a-time.\n    let overpassChain=Promise.resolve();\n    function runOverpassSerialized(fn){\n      const run=overpassChain.then(fn, fn);\n      overpassChain=run.then(()=>{}, ()=>{});\n      return run;\n    }\n    // The main instance documents itself as overloaded (\"be mindful... do not expect high\n    // reliability. Use alternatives if possible\") and publishes public mirrors. Rotating through\n    // them on sustained failure spreads load instead of continuing to hammer the one instance\n    // that's already refusing us.\n    const OVERPASS_ENDPOINTS=[\n      'https://overpass-api.de/api/interpreter',\n      'https://lz4.overpass-api.de/api/interpreter',\n      'https://overpass.kumi.systems/api/interpreter',\n    ];\n    // .query() is documented to resolve a plain array, but an unauthenticated/edge context can hand\n    // back something else (an error-shaped object, {data:[...]}, {records:[...]}) instead of\n    // throwing — normalize defensively so a shape mismatch degrades to \"treat as empty\", never a\n    // hard crash (\"all.forEach is not a function\") with no visible explanation in the UI.\n    function asArray(res){\n      if(Array.isArray(res)) return res;\n      if(res && Array.isArray(res.data)) return res.data;\n      if(res && Array.isArray(res.records)) return res.records;\n      return [];\n    }\n    // Both .update({_id,...}) and .save({_id,...}) turned out to route through a \"create\" request\n    // server-side (confirmed via network inspection — identical $method:\"create\" payload either\n    // way), which then conflicts on the already-existing _id (\"id: value already exists.\").\n    // select()+set()+save() (the cursor-based pattern) was ALSO unreliable in testing. What's\n    // proven reliable: a tiny Rush listener (osmProgressPatch) that does\n    // @osmroads-br-progressDataSet.saveRecord($body) server-side — saveRecord is a documented,\n    // widely-used partial merge-by-_id (verified: patching {_id,status} left chunkId/uf/classGroup/\n    // wayCount untouched). Routing the write through Rush sidesteps the code-component DataSet\n    // write-method ambiguity entirely.\n    async function patchProgress(id, fields){\n      await d2.rush('progressPatchRush').invoke({_id:id, ...fields});\n    }\n\n    const logEl=$('#log');\n    function log(msg){\n      logEl.textContent += (logEl.textContent?'\\n':'')+msg;\n      logEl.scrollTop=logEl.scrollHeight;\n    }\n\n    function filterQL(classGroup){\n      if(classGroup==='light') return '~\"^(motorway|trunk|primary|secondary)$\"';\n      if(classGroup==='tertiary') return '=\"tertiary\"';\n      return '=\"residential\"';\n    }\n    function parseBboxSuffix(chunkId){\n      const m=/_bbox_(-?[\\d.]+)_(-?[\\d.]+)_(-?[\\d.]+)_(-?[\\d.]+)$/.exec(chunkId);\n      if(!m) return null;\n      return {south:+m[1], west:+m[2], north:+m[3], east:+m[4]};\n    }\n    function buildQuery(chunk){\n      const bbox=parseBboxSuffix(chunk.chunkId);\n      const filt=filterQL(chunk.classGroup);\n      if(bbox) return `[out:json][timeout:180];way(${bbox.south},${bbox.west},${bbox.north},${bbox.east})[\"highway\"${filt}];out geom;`;\n      return `[out:json][timeout:180];area[\"ISO3166-2\"=\"BR-${chunk.uf}\"]->.a;way(area.a)[\"highway\"${filt}];out geom;`;\n    }\n    function bboxQueryFor(uf){\n      return `[out:json];area[\"ISO3166-2\"=\"BR-${uf}\"]->.a;out bb;`;\n    }\n\n    async function callOverpass(query, endpoint){\n      return runOverpassSerialized(async()=>{\n        const res=await d2.rush('overpassRush').invoke({data:query, endpoint});\n        return res && res.data;\n      });\n    }\n\n    // A chunk this size trips Overpass's own limits in ways that surface as a THROWN error\n    // (not a clean {ok:false} response): 504 gateway timeouts under load, or the proxy's HTTP\n    // client choking on a huge response body (observed: a hard ~17MiB response-buffer ceiling,\n    // \"offset is out of range\"). Both mean \"this chunk is too big\", not \"this chunk is broken\" —\n    // treat them like the existing too-big signal and quarter the area instead of dead-ending.\n    function looksLikeSizeOrTimeoutError(msg){\n      return /\\b504\\b|timeout|too busy|offset.*out of range|ETIMEDOUT|ECONNRESET/i.test(msg);\n    }\n\n    function sleep(ms){ return new Promise(r=>setTimeout(r, ms)); }\n    // \"This response is too big to ever fit in one call\" (the client's own ~17MiB response-buffer\n    // ceiling) is the ONE failure retrying can't fix — only splitChunk (via the outer catch's\n    // looksLikeSizeOrTimeoutError check) helps there. Everything else — 429 rate-limits, 504 \"too\n    // busy\", plain timeouts, connection resets, even an occasional 424 from the proxy itself — is\n    // transient shared-server load, so we just keep trying instead of ever marking the chunk\n    // 'error' for it.\n    function looksLikeUnretriableSizeError(msg){\n      return /offset.*out of range/i.test(msg);\n    }\n    // Per Overpass's own documented guidance: \"If you receive an HTTP error code such as 429 or\n    // 406, pause for 30 seconds before making a new request\" — NOT parallel retries, NOT a low\n    // fixed delay held forever. One request at a time, starting at their documented 30s and growing\n    // (capped at 5min) if it keeps failing, rotating across the main instance and its public\n    // mirrors so a sustained block on one doesn't just get hammered on repeat. No attempt cap — a\n    // chunk only ever advances (to done, or to split for the one unretriable case) once a request\n    // actually succeeds.\n    function backoffDelayMs(round){ return Math.min(30000*Math.pow(1.4, round), 5*60*1000); }\n    async function callOverpassUntilSuccess(query, tag){\n      for(let round=0; ; round++){\n        const endpoint=OVERPASS_ENDPOINTS[round % OVERPASS_ENDPOINTS.length];\n        try{\n          return await callOverpass(query, endpoint);\n        }catch(e){\n          const msg=String((e&&e.message)||e);\n          if(looksLikeUnretriableSizeError(msg)) throw e;\n          const wait=backoffDelayMs(round);\n          const host=endpoint.replace(/^https?:\\/\\//,'').split('/')[0];\n          log(`  ${tag}Overpass ocupado (${host}, rodada ${round+1}) — nova tentativa em ${wait<60000?Math.round(wait/1000)+'s':Math.round(wait/60000)+'min'}`);\n          await sleep(wait);\n        }\n      }\n    }\n\n    function parseWays(elements, uf){\n      const recs=[];\n      for(const el of (elements||[])){\n        if(!el.geometry || !el.geometry.length) continue;\n        const pts=el.geometry.filter(Boolean);\n        if(!pts.length) continue;\n        let minLon=Infinity,minLat=Infinity,maxLon=-Infinity,maxLat=-Infinity,sumLon=0,sumLat=0;\n        for(const p of pts){\n          if(p.lon<minLon) minLon=p.lon; if(p.lon>maxLon) maxLon=p.lon;\n          if(p.lat<minLat) minLat=p.lat; if(p.lat>maxLat) maxLat=p.lat;\n          sumLon+=p.lon; sumLat+=p.lat;\n        }\n        const cLon=sumLon/pts.length, cLat=sumLat/pts.length;\n        const gridCell=Math.floor(cLat/GRID_DEG)+'_'+Math.floor(cLon/GRID_DEG);\n        recs.push({\n          wayId: el.id,\n          highway: (el.tags&&el.tags.highway)||'',\n          name: (el.tags&&el.tags.name)||null,\n          uf, gridCell, minLon, minLat, maxLon, maxLat,\n          geometry: pts.map(p=>({lon:p.lon, lat:p.lat}))\n        });\n      }\n      return recs;\n    }\n\n    async function saveWaysConcurrent(recs, concurrency){\n      let i=0, saved=0;\n      async function worker(){\n        while(i<recs.length){\n          const idx=i++; const r=recs[idx];\n          try{ await roadsDs().create(r); saved++; }\n          catch(e){ /* one bad record shouldn't sink the whole chunk */ }\n          if(saved && saved%300===0) log(`  ${saved}/${recs.length} vias salvas…`);\n        }\n      }\n      const n=Math.max(1, Math.min(concurrency||6, recs.length||1));\n      await Promise.all(Array.from({length:n}, worker));\n      return saved;\n    }\n\n    // Splits a chunk that's too big for one Overpass call into 4 bbox quadrants.\n    // Recursive: an already-split (_bbox_-suffixed) chunk gets its OWN bbox quartered\n    // further, rather than re-deriving the whole state's bbox again.\n    async function splitChunk(chunk){\n      let bbox=parseBboxSuffix(chunk.chunkId);\n      if(!bbox){\n        const bb=await callOverpass(bboxQueryFor(chunk.uf));\n        const el=bb && bb.elements && bb.elements[0];\n        if(!el || !el.bounds) throw new Error('não foi possível obter bbox do estado (out bb; sem resultado)');\n        bbox={south:el.bounds.minlat, west:el.bounds.minlon, north:el.bounds.maxlat, east:el.bounds.maxlon};\n      }\n      const midLat=(bbox.south+bbox.north)/2, midLon=(bbox.west+bbox.east)/2;\n      const quads=[\n        {south:bbox.south, west:bbox.west, north:midLat, east:midLon},\n        {south:bbox.south, west:midLon, north:midLat, east:bbox.east},\n        {south:midLat, west:bbox.west, north:bbox.north, east:midLon},\n        {south:midLat, west:midLon, north:bbox.north, east:bbox.east},\n      ];\n      const r2=n=>Math.round(n*100)/100;\n      const base=chunk.chunkId.replace(/_bbox_.*/,'');\n      for(const q of quads){\n        const id=`${base}_bbox_${r2(q.south)}_${r2(q.west)}_${r2(q.north)}_${r2(q.east)}`;\n        await progressDs().create({chunkId:id, uf:chunk.uf, classGroup:chunk.classGroup, status:'pending', wayCount:0, attempts:0});\n      }\n      await patchProgress(chunk._id, {status:'split', lastError:'dividido em 4 sub-áreas', updatedAt:nowIso()});\n      log(`  dividido em 4 sub-áreas`);\n    }\n\n    // Overpass queries carry their own [timeout:180] — a chunk still 'in_progress' well past that\n    // never finished normally (tab closed, an uncaught error mid-write, etc). Reclaim those as if\n    // pending instead of leaving them stuck forever with no automatic retry.\n    const STALE_MS = 5*60*1000;\n    async function pickNextPending(){\n      let pending;\n      try{ pending=asArray(await progressDs().query({status:'pending'})); }\n      catch(e){ log(`✗ não foi possível ler a fila: ${(e&&e.message)||e}`); return null; }\n      let stale=[];\n      try{\n        const inProg=asArray(await progressDs().query({status:'in_progress'}));\n        const now=Date.now();\n        stale=inProg.filter(c=>c.updatedAt && (now-new Date(c.updatedAt).getTime())>STALE_MS);\n      }catch(e){ /* best-effort reclaim — a failure here just means we skip it this round */ }\n      const pool=pending.concat(stale).filter(c=>!claimed.has(c._id));\n      if(!pool.length) return null;\n      pool.sort((a,b)=> a.chunkId<b.chunkId?-1:a.chunkId>b.chunkId?1:0);\n      const chunk=pool[0];\n      claimed.add(chunk._id);\n      return chunk;\n    }\n\n    async function processChunk(chunk, label){\n      const tag=label?`[${label}] `:'';\n      log(`▶ ${tag}${chunk.chunkId} (${chunk.uf} · ${chunk.classGroup})`);\n      try{\n        await patchProgress(chunk._id, {status:'in_progress', updatedAt:nowIso()});\n        const q=buildQuery(chunk);\n        const data=await callOverpassUntilSuccess(q, tag);\n        if(!data || data.ok===false || !Array.isArray(data.elements)){\n          log(`  ${tag}falhou (${(data&&data.error)||'sem resposta'}) — dividindo`);\n          await splitChunk(chunk);\n          return;\n        }\n        const recs=parseWays(data.elements, chunk.uf);\n        const saved=await saveWaysConcurrent(recs);\n        await patchProgress(chunk._id, {status:'done', wayCount:saved, updatedAt:nowIso()});\n        log(`  ✓ ${tag}${saved} vias salvas`);\n      }catch(e){\n        const msg=String((e&&e.message)||e);\n        if(looksLikeSizeOrTimeoutError(msg)){\n          log(`  ${tag}falhou por tamanho/timeout (${msg.slice(0,140)}) — dividindo`);\n          try{ await splitChunk(chunk); return; }\n          catch(e2){ log(`  ✗ ${tag}não foi possível dividir: ${(e2&&e2.message)||e2}`); }\n        }\n        log(`  ✗ ${tag}erro: ${msg}`);\n        // This write can ALSO fail (e.g. the same transient issue that broke the call above). Guard\n        // it so a double-failure doesn't throw out of processChunk uncaught — an unguarded throw\n        // here previously left chunks stuck at 'in_progress' forever with no error recorded. Worst\n        // case now: the stale-in_progress reclaim in pickNextPending picks it up again later.\n        try{ await patchProgress(chunk._id, {status:'error', attempts:(chunk.attempts||0)+1, lastError:msg, updatedAt:nowIso()}); }\n        catch(e2){ log(`  ✗ ${tag}não foi possível registrar o erro (${(e2&&e2.message)||e2}) — será re-tentado automaticamente após ficar stale`); }\n      }\n    }\n\n    async function processOne(){\n      const chunk=await pickNextPending();\n      if(!chunk){ log('Nada pendente — importação completa!'); return false; }\n      await processChunk(chunk);\n      return true;\n    }\n\n    async function refreshSummary(){\n      let all;\n      try{ all=asArray(await progressDs().query({})); }\n      catch(e){ log(`✗ não foi possível ler o progresso: ${(e&&e.message)||e}`); return; }\n      const counts={pending:0,in_progress:0,done:0,error:0,split:0};\n      all.forEach(r=>{ counts[r.status]=(counts[r.status]||0)+1; });\n      $('#summary').innerHTML=Object.entries(counts).map(([k,v])=>`<span class=\"pill ${k}\">${k}: ${v}</span>`).join('');\n      const errs=all.filter(r=>r.status==='error');\n      // Overpass error bodies come back as raw XML/HTML dumps hundreds of chars long — unreadable\n      // noise that's especially disruptive on a small screen. Clip for scanability; the full text\n      // is still there in the title attribute for anyone who needs to read the whole thing.\n      const clip=s=>{ s=String(s||''); return s.length>160 ? s.slice(0,160)+'…' : s; };\n      $('#errList').innerHTML = errs.length\n        ? errs.map(r=>`<div class=\"row\"><b>${esc(r.chunkId)}</b> — <span title=\"${esc(r.lastError||'')}\">${esc(clip(r.lastError))}</span></div>`).join('')\n        : '<div class=\"mut\">nenhum erro</div>';\n    }\n\n    let autoRunning=false;\n    // Concurrent workers instead of one sequential loop — each pulls its own chunk via\n    // pickNextPending (claim-guarded, see above) so N Overpass calls are in flight at once.\n    // Kept modest (max 6, user-selectable) since these still go through the shared overpassRush\n    // proxy — too much parallelism risks tripping Overpass's own fair-use throttling for everyone.\n    async function autoLoop(){\n      autoRunning=true;\n      $('#btnAuto').textContent='⏸ Parar';\n      $('#btnNext').disabled=true;\n      const n=Math.max(1, Math.min(6, +($('#concurrency').value)||3));\n      async function worker(idx){\n        while(autoRunning){\n          const chunk=await pickNextPending();\n          if(!chunk) break;\n          await processChunk(chunk, 'w'+idx);\n          await refreshSummary();\n        }\n      }\n      await Promise.all(Array.from({length:n}, (_,i)=>worker(i+1)));\n      autoRunning=false;\n      $('#btnAuto').textContent='▶ Executar automaticamente';\n      $('#btnNext').disabled=false;\n      await refreshSummary();\n      log('Fila esgotada ou parado.');\n    }\n\n    $('#btnNext').onclick=async()=>{\n      $('#btnNext').disabled=true;\n      try{ await processOne(); await refreshSummary(); }\n      finally{ $('#btnNext').disabled=false; }\n    };\n    $('#btnAuto').onclick=()=>{\n      if(autoRunning){ autoRunning=false; }\n      else{ autoLoop(); }\n    };\n    $('#btnResetErrors').onclick=async()=>{\n      let errs;\n      try{ errs=asArray(await progressDs().query({status:'error'})); }\n      catch(e){ log(`✗ não foi possível ler erros: ${(e&&e.message)||e}`); return; }\n      for(const r of errs){ await patchProgress(r._id, {status:'pending', attempts:0, lastError:null}); claimed.delete(r._id); }\n      log(`${errs.length} chunk(s) com erro voltaram pra fila`);\n      await refreshSummary();\n    };\n    $('#btnRefresh').onclick=()=>refreshSummary();\n\n    refreshSummary();\n  }\n};"}},"progressDs":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"progressDs"}}}},"roadsDs":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"roadsDs"}}}},"overpassRush":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"overpassRush"}}}},"progressPatchRush":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"progressPatchRush"}}}}}}}}}},{"_id":"6aa25ddde59a897a22de2601","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"extends":"data2-core-primitives@Block","component":"Mapa_de_Territorio","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"// Param contracts (no separate description field on code-component params, so documented here):\n// units:          [{id, name?, uf?, geometry}]  — the closing territory's own polygons (GeoJSON Polygon/MultiPolygon)\n// neighbors:      [{id?, name?, uf?, sectorCode, geometry, owners?}] — neighboring/context polygons;\n//                 owners (destination codes) present when this shape is split among several destinations\n// destinations:   [destCode, ...] — valid, cyclable destination codes\n// assignments:    {unitOrGroupKey: destCode} — initial assignment; if groupBy is set, keys MUST be group ids, not unit ids\n// colors:         {destCode: '#hex'} — optional override; unset codes get a stable hash color\n// rejected:       [{code, reason}] — geographically touching destinations that are invalid, with why (tooltip)\n// closedContext:  [destCode, ...] — destinations already closed in a prior stage (dashed grey, non-interactive)\n// groupBy:        {unitId: groupId} — optional; when set, every unit sharing a groupId fills/clicks/hovers as one\n// view:           'asis' | 'tobe' — 'asis' = uniform closingColor, no interaction; 'tobe' = per-destination colors + clicking\n// interactive/enforceContiguity/showOsmPanel/legendEnabled: booleans\n// unitLabel/destinationLabel/closingColor: display copy + the neutral fill for 'asis'\n// osmProxy: rush — a rush listener that proxies Overpass (way/waterway/aeroway queries) for the optional road/river/airport layers\nconst PALETTE=['#5856d6','#ff9500','#28cd41','#af52de','#ff2d55','#a2845e'];\nconst MAP_ZOOM_MAX=8;\nconst RDP_EPS=0.6;\n\n// ---------- pure geometry helpers (stateless — safe to share across instances) ----------\nfunction project(feats,mapW,mapH){\n  let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  const each=cb=>feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(p=>p.forEach(r=>r.forEach(cb)));});\n  each(p=>{mnx=Math.min(mnx,p[0]);mxx=Math.max(mxx,p[0]);mny=Math.min(mny,p[1]);mxy=Math.max(mxy,p[1]);});\n  const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=mapW,H=mapH,pad=22;\n  const gw=(mxx-mnx)*kx||1,gh=(mxy-mny)||1,sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n  const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n  return p=>[ox+(p[0]-mnx)*kx*sc, oy+(mxy-p[1])*sc];\n}\nfunction pathFor(f,pr){\n  const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  return ps.map(poly=>poly.map(r=>'M'+r.map(p=>{const q=pr(p);return q[0].toFixed(1)+','+q[1].toFixed(1);}).join('L')+'Z').join('')).join('');\n}\nfunction cxy(f,pr){\n  let x=0,y=0,n=0;const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  ps.forEach(poly=>poly[0].forEach(p=>{const q=pr(p);x+=q[0];y+=q[1];n++;}));\n  return [x/(n||1),y/(n||1)];\n}\nfunction clipRingHalf(ring,axis,keepGe,val){\n  const out=[],n=ring.length; if(!n) return out;\n  const side=p=>keepGe?p[axis]>=val:p[axis]<=val;\n  for(let i=0;i<n;i++){\n    const cur=ring[i],prev=ring[(i-1+n)%n];\n    const curIn=side(cur),prevIn=side(prev);\n    if(curIn!==prevIn){\n      const t=(val-prev[axis])/(cur[axis]-prev[axis]);\n      const other=1-axis,pt=[0,0]; pt[axis]=val; pt[other]=prev[other]+t*(cur[other]-prev[other]);\n      out.push(pt);\n    }\n    if(curIn) out.push(cur);\n  }\n  return out;\n}\n// Slices a shared feature (a território claimed by >1 destination) into N equal-width strips along\n// whichever axis it's longer on — mirrors how the source app splits a município owned by several\n// commercial setores at once, so each owner gets real area instead of losing a z-order race.\nfunction sliceFeatureEqual(feature,n){\n  if(n<=1) return [feature];\n  const g=feature.geometry,parts=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n  const rings=[]; parts.forEach(poly=>poly.forEach(r=>{ if(r&&r.length>=4) rings.push(r); }));\n  let mnx=Infinity,mxx=-Infinity,mny=Infinity,mxy=-Infinity;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  if(!isFinite(mnx)) return Array.from({length:n},()=>({...feature,__empty:true}));\n  const wide=(mxx-mnx)>=(mxy-mny), axis=wide?0:1;\n  const lo=wide?mnx:mny, hi=wide?mxx:mxy, span=(hi-lo)||1;\n  const out=[];\n  for(let i=0;i<n;i++){\n    const a=lo+span*i/n, b=lo+span*(i+1)/n;\n    const polys=[];\n    rings.forEach(r=>{\n      let c=clipRingHalf(r,axis,true,a);\n      c=clipRingHalf(c,axis,false,b);\n      if(c.length>=3) polys.push([[...c,c[0]]]);\n    });\n    out.push(polys.length?{type:'Feature',properties:feature.properties||{},id:feature.id,geometry:{type:'MultiPolygon',coordinates:polys}}:{...feature,__empty:true});\n  }\n  return out;\n}\n// Pole of inaccessibility: interior point farthest from any boundary of the dissolved area, found by\n// coarse grid search + hill-climb refinement. A plain centroid can land outside a concave shape (a\n// split território, a shape wrapped around a bay); this never does, and its radius `r` doubles as a\n// \"is there room to write here at all\" budget for the caller.\nfunction labelPointFor(fs,pr,mapW,mapH){\n  const rings=[];\n  (fs||[]).forEach(f=>{ if(!f||!f.geometry) return;\n    const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n    ps.forEach(poly=>poly.forEach(r=>{\n      if(!r||r.length<4) return;\n      const step=Math.max(1,Math.floor(r.length/120)),pts=[];\n      for(let i=0;i<r.length;i+=step) pts.push(pr(r[i]));\n      if(pts.length>3) rings.push(pts);\n    }));});\n  if(!rings.length) return null;\n  let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n  mnx=Math.max(mnx,2); mny=Math.max(mny,2); mxx=Math.min(mxx,mapW-2); mxy=Math.min(mxy,mapH-2);\n  if(!(mxx>mnx&&mxy>mny)) return null;\n  const inside=(x,y)=>{ let c=false;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      if(((a[1]>y)!==(b[1]>y)) && (x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])) c=!c; }\n    return c; };\n  const edgeDist=(x,y)=>{ let m=Infinity;\n    for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n      const dx=b[0]-a[0],dy=b[1]-a[1],L=dx*dx+dy*dy;\n      let t=L?(((x-a[0])*dx+(y-a[1])*dy)/L):0; t=t<0?0:(t>1?1:t);\n      const ex=a[0]+t*dx-x,ey=a[1]+t*dy-y,d=ex*ex+ey*ey; if(d<m)m=d; }\n    return Math.sqrt(m); };\n  const W=mxx-mnx,H=mxy-mny,N=26;\n  let ccx=0,ccy=0,cn=0;\n  rings.forEach(r=>r.forEach(p=>{ ccx+=p[0]; ccy+=p[1]; cn++; }));\n  ccx/=cn||1; ccy/=cn||1;\n  const CENTER_PULL=0.22;\n  const scoreAt=(x,y,d)=>d-CENTER_PULL*Math.hypot(x-ccx,y-ccy);\n  let best=null,bd=-1,bs=-Infinity;\n  for(let i=0;i<=N;i++) for(let j=0;j<=N;j++){\n    const x=mnx+W*i/N,y=mny+H*j/N;\n    if(!inside(x,y)) continue;\n    const d=edgeDist(x,y),s=scoreAt(x,y,d); if(s>bs){ bs=s; bd=d; best=[x,y]; }\n  }\n  if(!best) return null;\n  let step=Math.max(W,H)/N/2;\n  for(let k=0;k<7;k++){\n    let moved=false;\n    for(const o of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]]){\n      const x=best[0]+o[0]*step,y=best[1]+o[1]*step;\n      if(x<mnx||x>mxx||y<mny||y>mxy||!inside(x,y)) continue;\n      const d=edgeDist(x,y),s=scoreAt(x,y,d); if(s>bs){ bs=s; bd=d; best=[x,y]; moved=true; }\n    }\n    if(!moved) step/=2;\n  }\n  return {p:best,r:bd};\n}\nfunction colorForCode(code,overrides,cache){\n  if(overrides&&overrides[code]) return overrides[code];\n  const k=String(code);\n  if(cache[k]) return cache[k];\n  let h=2166136261>>>0;\n  for(let i=0;i<k.length;i++) h=Math.imul(h^k.charCodeAt(i),16777619);\n  const col=PALETTE[(h>>>0)%PALETTE.length];\n  cache[k]=col;\n  return col;\n}\n// Shared-vertex adjacency: two features touch when they share a coordinate (rounded to kill float\n// jitter) — same technique the source app uses within one state's shapefile.\nfunction buildAdjacency(feats){\n  const vmap={};\n  feats.forEach(f=>{\n    const g=f.geometry, polys=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n    polys.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>{\n      const k=p[0].toFixed(4)+','+p[1].toFixed(4);\n      (vmap[k]=vmap[k]||new Set()).add(f.id);\n    })));\n  });\n  const adj={}; feats.forEach(f=>adj[f.id]=new Set());\n  Object.values(vmap).forEach(idSet=>{\n    if(idSet.size<2) return;\n    const ids=[...idSet];\n    for(let i=0;i<ids.length;i++) for(let j=i+1;j<ids.length;j++){ adj[ids[i]].add(ids[j]); adj[ids[j]].add(ids[i]); }\n  });\n  return adj;\n}\nfunction deriveKeyAdjacency(unitAdj,idFor){\n  const out={};\n  Object.keys(unitAdj).forEach(uid=>{ const k=idFor(uid); out[k]=out[k]||new Set(); });\n  Object.entries(unitAdj).forEach(([uid,nbrs])=>{\n    const kid=idFor(uid);\n    nbrs.forEach(nid=>{ const kn=idFor(nid); if(kn!==kid) out[kid].add(kn); });\n  });\n  return out;\n}\nfunction destGroupConnected(destCode,assign,adj,excludeId,includeId){\n  const ids=Object.keys(assign).filter(id=>assign[id]===destCode&&id!==excludeId);\n  if(includeId&&!ids.includes(includeId)) ids.push(includeId);\n  if(ids.length<=1) return true;\n  const set=new Set(ids); const seen=new Set([ids[0]]); const q=[ids[0]];\n  while(q.length){ const cur=q.pop(); (adj[cur]||new Set()).forEach(n=>{ if(set.has(n)&&!seen.has(n)){ seen.add(n); q.push(n); } }); }\n  return seen.size===ids.length;\n}\nfunction contigBadFor(key,newDest,assign,adj){\n  const oldDest=assign[key];\n  if(oldDest===newDest) return false;\n  const okOld=destGroupConnected(oldDest,assign,adj,key,null);\n  const okNew=destGroupConnected(newDest,assign,adj,key,key);\n  return !(okOld&&okNew);\n}\nfunction nextSafeDest(key,assign,adj,destinations,enforce){\n  const cur=assign[key]; const idx=destinations.indexOf(cur);\n  if(!enforce) return destinations[(idx+1+destinations.length)%destinations.length];\n  for(let k=1;k<=destinations.length;k++){\n    const cand=destinations[(idx+k)%destinations.length];\n    if(!contigBadFor(key,cand,assign,adj)) return cand;\n  }\n  return cur;\n}\nfunction rdpSimplify(pts,eps){\n  if(pts.length<3) return pts;\n  const a=pts[0], b=pts[pts.length-1];\n  const dx=b[0]-a[0], dy=b[1]-a[1], len2=dx*dx+dy*dy;\n  let maxD=0,idx=0;\n  for(let i=1;i<pts.length-1;i++){\n    const p=pts[i]; let d;\n    if(len2===0){ d=Math.hypot(p[0]-a[0],p[1]-a[1]); }\n    else{ const t=((p[0]-a[0])*dx+(p[1]-a[1])*dy)/len2; const px=a[0]+t*dx, py=a[1]+t*dy; d=Math.hypot(p[0]-px,p[1]-py); }\n    if(d>maxD){ maxD=d; idx=i; }\n  }\n  if(maxD>eps){\n    const left=rdpSimplify(pts.slice(0,idx+1),eps);\n    const right=rdpSimplify(pts.slice(idx),eps);\n    return left.slice(0,-1).concat(right);\n  }\n  return [a,b];\n}\nfunction catmullRomPathD(pts){\n  if(pts.length<3) return 'M'+pts.map(p=>p[0].toFixed(1)+','+p[1].toFixed(1)).join('L');\n  let d=`M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;\n  for(let i=0;i<pts.length-1;i++){\n    const p0=pts[i-1]||pts[i], p1=pts[i], p2=pts[i+1], p3=pts[i+2]||p2;\n    const c1x=p1[0]+(p2[0]-p0[0])/6, c1y=p1[1]+(p2[1]-p0[1])/6;\n    const c2x=p2[0]-(p3[0]-p1[0])/6, c2y=p2[1]-(p3[1]-p1[1])/6;\n    d+=`C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;\n  }\n  return d;\n}\nconst OSM_LAYERS=[\n  {key:'rodovias',label:'Rodovias',color:'#777777',sw:0.5,ql:b=>`way[\"highway\"~\"^(motorway|trunk|primary|secondary)$\"](${b});`},\n  {key:'rivers',label:'Rios',color:'#0a84ff',sw:0.5,ql:b=>`way[\"waterway\"=\"river\"][\"intermittent\"!=\"yes\"](${b});`},\n  {key:'airports',label:'Aeroportos',mode:'point',outMode:'center',color:'#000000',ql:b=>`nwr[\"aeroway\"=\"aerodrome\"][\"iata\"](${b});`},\n];\nconst OSM_MAX_SPAN=6;\nfunction osmBboxOf(feats){\n  let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n  feats.forEach(f=>{const g=f.geometry,ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n    ps.forEach(poly=>poly.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; })));});\n  if(!isFinite(mnx)) return null;\n  return {south:mny,west:mnx,north:mxy,east:mxx};\n}\n\nexport default {\n  css: `\n  :host{display:block;height:100%;--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--closing:#ff3b30;--radius-ctrl:6px;--shadow-ctrl:0 1px 3px rgba(0,0,0,.15)}\n  *{box-sizing:border-box}\n  .maproot{position:relative;height:100%;display:flex;flex-direction:column;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;background:var(--bg);color:var(--ink)}\n  .toolbar{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line);background:var(--panel)}\n  .mac-spacer{flex:1}\n  .toggle{display:flex;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:2px;gap:1px;font-size:12px}\n  .toggle button{background:transparent;border:0;border-radius:6px;color:var(--mut);padding:5px 10px;cursor:pointer;font-weight:600}\n  .toggle button.on{background:#fff;color:var(--ink);box-shadow:var(--shadow-ctrl)}\n  .badge{font-size:10px;padding:2px 7px;border-radius:5px;background:var(--panel2);border:1px solid var(--line);color:var(--mut)}\n  .map-wrap{position:relative;flex:1;min-height:220px;display:flex;justify-content:center;overflow:hidden}\n  svg{width:100%;height:auto;display:block;user-select:none;-webkit-user-select:none}\n  #map{cursor:grab;touch-action:none;width:100%;height:100%}\n  #map.dragging{cursor:grabbing}\n  .zoom-pill{position:absolute;left:10px;bottom:10px;display:flex;background:var(--panel);border:1px solid var(--line);border-radius:99px;box-shadow:0 4px 14px rgba(0,0,0,.16);overflow:hidden;z-index:3}\n  .zoom-pill button{width:30px;height:30px;border:0;background:transparent;color:var(--ink);font-size:16px;font-weight:600;cursor:pointer}\n  .zoom-pill button:hover{background:var(--panel2)}\n  .zoom-div{width:1px;background:var(--line)}\n  .osm-panel-icon{position:absolute;left:10px;top:10px;width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 4px 14px rgba(0,0,0,.16);cursor:pointer;z-index:3;font-size:15px}\n  .osm-panel-icon.hidden,.osm-panel.hidden{display:none}\n  .osm-panel{position:absolute;left:10px;top:10px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 4px 14px rgba(0,0,0,.16);padding:8px 10px;font-size:11.5px;z-index:3;min-width:120px}\n  .osm-row{display:flex;align-items:center;gap:6px;padding:2px 0;cursor:pointer}\n  .osm-row input{accent-color:var(--acc);margin:0}\n  .osm-hd{display:flex;justify-content:space-between;align-items:center;font-size:10px;text-transform:uppercase;color:var(--mut);font-weight:600;margin-bottom:4px}\n  .osm-hd button{background:transparent;border:0;color:var(--mut);cursor:pointer;font-size:12px}\n  .osm-status{margin-top:6px;font-size:10px;color:var(--mut);max-width:150px;line-height:1.4}\n  .legend{display:flex;gap:12px;flex-wrap:wrap;padding:8px 12px;font-size:12px;border-top:1px solid var(--line);background:var(--panel)}\n  .legend b{display:inline-block;width:11px;height:11px;border-radius:4px;margin-right:5px;vertical-align:-1px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}\n  .note{padding:0 12px 8px;font-size:10.5px;color:var(--mut);line-height:1.5}\n  .muni{stroke:#fff;stroke-width:1.1;stroke-linejoin:round;stroke-linecap:round;transition:filter .1s}\n  .muni:hover,.muni.munihl{filter:brightness(1.15)}\n  .unit-outline{fill:none;stroke:#fff;stroke-width:1.1;stroke-linejoin:round;pointer-events:none}\n  .sect{stroke-width:1;stroke-linejoin:round;fill-opacity:.32;stroke-opacity:.6}\n  .sect.rejected{fill:#e4e4ea;stroke:#e4e4ea;stroke-width:.6;fill-opacity:1;cursor:help}\n  .sect.unresolved{fill:#fbeceb;stroke:#e8a29c;stroke-dasharray:2 2;fill-opacity:1;cursor:help}\n  .sect.closed-ctx{fill:#e2e2e7;stroke:#aeaeb2;stroke-dasharray:3 2;fill-opacity:1}\n  .sect-rejected-border{fill:none;stroke:#b6b6bd;stroke-width:1;stroke-opacity:.6;pointer-events:none}\n  .sect-multi-border{fill:none;stroke:#1c1c1e;stroke-width:.6;stroke-opacity:.4;pointer-events:none}\n  .lbl{pointer-events:none;filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.16))}\n  .lbl text{text-anchor:middle;paint-order:stroke fill;stroke-linejoin:round;stroke-linecap:round;font-family:-apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif}\n  .tip{position:fixed;pointer-events:none;background:#1c1c1e;color:#fff;border-radius:8px;padding:7px 9px;font-size:11px;opacity:0;transition:opacity .1s;z-index:9;max-width:230px;box-shadow:0 2px 8px rgba(0,0,0,.28)}\n`,\n  render(d2){\n    // Reads registered here (unused otherwise) so the framework tracks these params and calls\n    // updated() when a host rebinds them — the actual work happens in updated/mounted, imperatively.\n    void [d2.param('units'),d2.param('neighbors'),d2.param('assignments'),d2.param('view'),d2.param('destinations'),d2.param('colors'),d2.param('groupBy'),d2.param('rejected'),d2.param('closedContext')];\n    return `<div class=\"maproot\">\n  <div class=\"toolbar\">\n    <div class=\"toggle\" id=\"viewToggle\"><button data-v=\"asis\">ASIS</button><button data-v=\"tobe\" class=\"on\">TOBE</button></div>\n    <div class=\"mac-spacer\"></div>\n  </div>\n  <div class=\"map-wrap\">\n    <svg id=\"map\" viewBox=\"0 0 700 500\" preserveAspectRatio=\"xMidYMid meet\"></svg>\n    <button class=\"osm-panel-icon hidden\" id=\"osmPanelIcon\" title=\"Camadas\">🗺</button>\n    <div class=\"osm-panel hidden\" id=\"osmPanel\">\n      <div class=\"osm-hd\">Camadas<button id=\"osmPanelX\">✕</button></div>\n      <label class=\"osm-row\"><input type=\"checkbox\" id=\"osmRodovias\"><span>Rodovias</span></label>\n      <label class=\"osm-row\"><input type=\"checkbox\" id=\"osmRivers\"><span>Rios</span></label>\n      <label class=\"osm-row\"><input type=\"checkbox\" id=\"osmAirports\"><span>Aeroportos</span></label>\n      <label class=\"osm-row\"><input type=\"checkbox\" id=\"muniOutline\"><span>Contorno</span></label>\n      <label class=\"osm-row\"><input type=\"checkbox\" id=\"legendMuni\" checked><span>Nomes</span></label>\n      <div class=\"osm-status\" id=\"osmStatus\"></div>\n    </div>\n    <div class=\"zoom-pill\">\n      <button type=\"button\" id=\"zoomOut\">−</button><span class=\"zoom-div\"></span><button type=\"button\" id=\"zoomIn\">+</button>\n    </div>\n  </div>\n  <div class=\"legend\" id=\"legend\"></div>\n</div>\n<div class=\"tip\" id=\"tip\"></div>`;\n  },\n  mounted(d2, root){\n    const $=s=>root.querySelector(s);\n    const NS='http://www.w3.org/2000/svg';\n    const esc=s=>String(s??'').replace(/[&<>\"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]));\n    const colorCache={};\n    const lblCache={};\n    const OSM_CACHE={};\n\n    // ---------- per-instance mutable state (never module-level — this component may be instanced N times) ----------\n    const ST={ mapW:700, mapH:500, view:'tobe', assign:{}, showOutline:false, legendMuniOn:true, legendCtxOn:false,\n      osmOn:{rodovias:false,rivers:false,airports:false}, osmPanelOpen:false, mapView:{x:0,y:0,w:700,h:500} };\n    let P={}, adj={}, keyAdj={}, idFor=u=>u;\n\n    function readParams(){\n      P={\n        units:d2.param('units',[])||[],\n        neighbors:d2.param('neighbors',[])||[],\n        destinations:d2.param('destinations',[])||[],\n        colors:d2.param('colors',{})||{},\n        rejected:d2.param('rejected',[])||[],\n        closedContext:d2.param('closedContext',[])||[],\n        groupBy:d2.param('groupBy',null),\n        unitLabel:d2.param('unitLabel','Unidade'),\n        destinationLabel:d2.param('destinationLabel','Destino'),\n        closingColor:d2.param('closingColor','#8e8e93'),\n        interactive:d2.param('interactive',true),\n        enforceContiguity:d2.param('enforceContiguity',true),\n        showOsmPanel:d2.param('showOsmPanel',true),\n        legendEnabled:d2.param('legendEnabled',true),\n      };\n      idFor=uid=>(P.groupBy&&P.groupBy[uid])||uid;\n      const unitAdj=buildAdjacency(P.units);\n      adj=unitAdj; keyAdj=deriveKeyAdjacency(unitAdj,idFor);\n    }\n    function seedAssignments(){\n      const src=d2.param('assignments',{})||{};\n      ST.assign={...src};\n      ST.view=d2.param('view','tobe')||'tobe';\n    }\n    readParams(); seedAssignments();\n\n    // ---------- pan / zoom ----------\n    function syncMapDims(){\n      const wrap=$('.map-wrap'); if(!wrap) return;\n      const cw=wrap.clientWidth, ch=wrap.clientHeight; if(!cw||!ch) return;\n      const newH=Math.round(700*(ch/cw));\n      if(Math.abs(newH-ST.mapH)<2) return;\n      const fracW=ST.mapView.w/ST.mapW, cx=(ST.mapView.x+ST.mapView.w/2)/ST.mapW, cy=(ST.mapView.y+ST.mapView.h/2)/ST.mapH;\n      ST.mapH=newH;\n      const w=Math.min(ST.mapW,Math.max(ST.mapW/MAP_ZOOM_MAX,ST.mapW*fracW));\n      const h=Math.min(ST.mapH,Math.max(ST.mapH/MAP_ZOOM_MAX,ST.mapH*fracW));\n      let x=cx*ST.mapW-w/2, y=cy*ST.mapH-h/2;\n      x=Math.min(ST.mapW-w,Math.max(0,x)); y=Math.min(ST.mapH-h,Math.max(0,y));\n      ST.mapView={x,y,w,h};\n    }\n    function applyMapView(){\n      const svg=$('#map'); if(!svg) return;\n      svg.setAttribute('viewBox', ST.mapView.x.toFixed(2)+' '+ST.mapView.y.toFixed(2)+' '+ST.mapView.w.toFixed(2)+' '+ST.mapView.h.toFixed(2));\n      const fsScale=ST.mapView.w/ST.mapW;\n      svg.querySelectorAll('text[data-fs]').forEach(t=>{ const base=+t.getAttribute('data-fs')||8; t.setAttribute('font-size',(base*fsScale).toFixed(2)); });\n      svg.querySelectorAll('[data-sw]').forEach(el=>{ const base=+el.getAttribute('data-sw')||1; el.style.strokeWidth=(base*fsScale).toFixed(3)+'px'; });\n    }\n    function zoomMapBy(factor){\n      const cx=ST.mapView.x+ST.mapView.w/2, cy=ST.mapView.y+ST.mapView.h/2;\n      let w=ST.mapView.w/factor, h=ST.mapView.h/factor;\n      w=Math.min(ST.mapW, Math.max(ST.mapW/MAP_ZOOM_MAX, w));\n      h=Math.min(ST.mapH, Math.max(ST.mapH/MAP_ZOOM_MAX, h));\n      let x=cx-w/2, y=cy-h/2;\n      x=Math.min(ST.mapW-w, Math.max(0,x)); y=Math.min(ST.mapH-h, Math.max(0,y));\n      ST.mapView={x,y,w,h}; applyMapView();\n    }\n    function zoomMapAt(factor, clientX, clientY){\n      const svg=$('#map'); if(!svg) return;\n      const rect=svg.getBoundingClientRect(); if(!rect.width||!rect.height) return;\n      const fx=(clientX-rect.left)/rect.width, fy=(clientY-rect.top)/rect.height;\n      const wx=ST.mapView.x+fx*ST.mapView.w, wy=ST.mapView.y+fy*ST.mapView.h;\n      let w=ST.mapView.w/factor, h=ST.mapView.h/factor;\n      w=Math.min(ST.mapW, Math.max(ST.mapW/MAP_ZOOM_MAX, w));\n      h=Math.min(ST.mapH, Math.max(ST.mapH/MAP_ZOOM_MAX, h));\n      let x=wx-fx*w, y=wy-fy*h;\n      x=Math.min(ST.mapW-w, Math.max(0,x)); y=Math.min(ST.mapH-h, Math.max(0,y));\n      ST.mapView={x,y,w,h}; applyMapView();\n    }\n    let mapDrag=null, mapDidDrag=false;\n    function mapPointerDown(e){\n      const svg=$('#map'); if(!svg) return;\n      mapDrag={x0:e.clientX,y0:e.clientY,vx0:ST.mapView.x,vy0:ST.mapView.y,rect:svg.getBoundingClientRect()};\n      mapDidDrag=false; svg.classList.add('dragging');\n      // No setPointerCapture on the svg itself: that would reroute the resulting click to the\n      // svg instead of the <path> under the cursor, breaking click-to-reassign after every drag.\n    }\n    function mapPointerMove(e){\n      if(!mapDrag) return;\n      const dxPix=e.clientX-mapDrag.x0, dyPix=e.clientY-mapDrag.y0;\n      if(!mapDidDrag && Math.hypot(dxPix,dyPix)>4) mapDidDrag=true;\n      if(!mapDrag.rect.width||!mapDrag.rect.height) return;\n      const sx=ST.mapView.w/mapDrag.rect.width, sy=ST.mapView.h/mapDrag.rect.height;\n      let x=mapDrag.vx0-dxPix*sx, y=mapDrag.vy0-dyPix*sy;\n      x=Math.min(ST.mapW-ST.mapView.w, Math.max(0,x)); y=Math.min(ST.mapH-ST.mapView.h, Math.max(0,y));\n      ST.mapView={...ST.mapView,x,y}; applyMapView();\n    }\n    function mapPointerUp(){\n      if(!mapDrag) return; mapDrag=null;\n      const svg=$('#map'); if(svg) svg.classList.remove('dragging');\n    }\n    function fitMapHeight(){\n      const wrap=$('.map-wrap'); if(!wrap) return;\n      const top=wrap.getBoundingClientRect().top; if(top<0) return;\n    }\n\n    // ---------- OSM reference layers ----------\n    function stillWanted(def){ return def.key==='rodovias'?ST.osmOn.rodovias:ST.osmOn[def.key]; }\n    async function ensureOsmLayer(def,bbox,ck){\n      OSM_CACHE[ck]={status:'loading',data:null,error:null,attempt:0};\n      const b=bbox.south.toFixed(4)+','+bbox.west.toFixed(4)+','+bbox.north.toFixed(4)+','+bbox.east.toFixed(4);\n      const query='[out:json][timeout:25];('+def.ql(b)+');out'+(def.outMode==='center'?' center':' geom')+';';\n      const rec=OSM_CACHE[ck];\n      for(let attempt=1;;attempt++){\n        if(!stillWanted(def)){ delete OSM_CACHE[ck]; return; }\n        rec.attempt=attempt; rec.status=attempt>1?'retrying':'loading';\n        try{\n          const res=await d2.rush('osmProxy').invoke({data:query});\n          const env=res&&res.data;\n          if(!env) throw new Error('proxy sem resposta');\n          if(!env.ok) throw new Error(env.error||'falha desconhecida no proxy');\n          rec.data=env.elements||[]; rec.status='ok';\n          drawMap();\n          return;\n        }catch(e){\n          rec.error=(e&&e.message)||'falha no proxy';\n          if(!stillWanted(def)){ delete OSM_CACHE[ck]; return; }\n          drawMap();\n          await new Promise(r=>setTimeout(r,Math.min(3000*attempt,20000)));\n        }\n      }\n    }\n    function drawOsmLayer(svg,pr,feats){\n      const st=$('#osmStatus');\n      const active=OSM_LAYERS.filter(l=>ST.osmOn[l.key]);\n      if(!active.length){ if(st) st.textContent=''; return; }\n      const bbox=osmBboxOf(feats); if(!bbox){ if(st) st.textContent=''; return; }\n      const tooBig=(bbox.north-bbox.south)>OSM_MAX_SPAN||(bbox.east-bbox.west)>OSM_MAX_SPAN;\n      const msgs=[];\n      active.forEach(def=>{\n        if(tooBig){ msgs.push(def.label+': área grande demais'); return; }\n        const bk=bbox.south.toFixed(3)+'_'+bbox.west.toFixed(3)+'_'+bbox.north.toFixed(3)+'_'+bbox.east.toFixed(3);\n        const ck=def.key+'|'+bk;\n        let rec=OSM_CACHE[ck];\n        if(!rec){ ensureOsmLayer(def,bbox,ck); rec=OSM_CACHE[ck]; }\n        if(rec.status==='loading') msgs.push(def.label+': carregando…');\n        else if(rec.status==='retrying') msgs.push(def.label+': tentativa '+(rec.attempt||1)+'…');\n        if(rec.status!=='ok') return;\n        if(def.mode==='point'){\n          const g=document.createElementNS(NS,'g');\n          rec.data.forEach(el=>{\n            const pt=el.type==='node'?{lon:el.lon,lat:el.lat}:(el.center?{lon:el.center.lon,lat:el.center.lat}:null);\n            if(!pt) return;\n            const q=pr([pt.lon,pt.lat]);\n            const ic=document.createElementNS(NS,'text');\n            ic.setAttribute('x',q[0].toFixed(1)); ic.setAttribute('y',q[1].toFixed(1));\n            ic.setAttribute('text-anchor','middle'); ic.setAttribute('font-size','13'); ic.setAttribute('fill',def.color);\n            ic.textContent='✈'; g.appendChild(ic);\n          });\n          svg.appendChild(g); return;\n        }\n        const g=document.createElementNS(NS,'g'); g.style.pointerEvents='none';\n        rec.data.forEach(el=>{\n          if(!el.geometry||!el.geometry.length) return;\n          const rawPts=el.geometry.filter(Boolean).map(p=>pr([p.lon,p.lat]));\n          const d=catmullRomPathD(rdpSimplify(rawPts,RDP_EPS));\n          const p=document.createElementNS(NS,'path'); p.setAttribute('d',d); p.setAttribute('fill','none');\n          p.setAttribute('stroke',def.color); p.setAttribute('stroke-width',def.sw); p.setAttribute('opacity','0.85');\n          p.style.strokeLinejoin='round'; p.style.strokeLinecap='round';\n          g.appendChild(p);\n        });\n        svg.appendChild(g);\n      });\n      if(st) st.textContent=msgs.join(' · ');\n    }\n\n    // ---------- legend ----------\n    function renderLegend(){\n      const el=$('#legend'); if(!el||!P.legendEnabled) { if(el) el.innerHTML=''; return; }\n      const asis=ST.view==='asis';\n      el.innerHTML=asis\n        ? `<span><b style=\"background:${P.closingColor}\"></b>${esc(P.unitLabel)} (estrutura antes)</span>`\n        : P.destinations.map(s=>`<span><b style=\"background:${colorForCode(s,P.colors,colorCache)}\"></b>${esc(P.destinationLabel)} ${esc(s)}</span>`).join('');\n    }\n\n    // ---------- core draw ----------\n    function drawMap(){\n      const svg=$('#map'); if(!svg) return;\n      svg.innerHTML='';\n      const defs=document.createElementNS(NS,'defs');\n      const hatch=document.createElementNS(NS,'pattern');\n      hatch.setAttribute('id','multiOwnerHatch'); hatch.setAttribute('width','7'); hatch.setAttribute('height','7');\n      hatch.setAttribute('patternUnits','userSpaceOnUse'); hatch.setAttribute('patternTransform','rotate(45)');\n      const hl=document.createElementNS(NS,'line'); hl.setAttribute('x1','0'); hl.setAttribute('y1','0'); hl.setAttribute('x2','0'); hl.setAttribute('y2','7');\n      hl.setAttribute('stroke','#1c1c1e'); hl.setAttribute('stroke-width','0.6'); hl.setAttribute('stroke-opacity','0.15');\n      hatch.appendChild(hl); defs.appendChild(hatch); svg.appendChild(defs);\n\n      const feats=[...P.neighbors,...P.units];\n      if(!feats.length) return;\n      syncMapDims();\n      const pr=project(feats,ST.mapW,ST.mapH);\n      const labels=[];\n      const asis=ST.view==='asis';\n\n      // context layer, grouped by destination code (or by raw sectorCode when not a live destination)\n      const bySec={};\n      P.neighbors.forEach(f=>{\n        const owners=f.owners&&f.owners.length>1?f.owners:[f.sectorCode];\n        if(owners.length>1){\n          sliceFeatureEqual(f,owners.length).forEach((slice,i)=>{\n            if(slice.__empty) return;\n            (bySec[owners[i]]=bySec[owners[i]]||[]).push({feature:slice,src:f,owners});\n          });\n        } else {\n          (bySec[f.sectorCode]=bySec[f.sectorCode]||[]).push({feature:f,src:f,owners});\n        }\n      });\n      const rejectedMap={}; (P.rejected||[]).forEach(r=>rejectedMap[r.code]=r.reason||'');\n      const closedSet=new Set(P.closedContext||[]);\n      const multiBorder={};\n      Object.keys(bySec).forEach(s=>{\n        const fs=bySec[s];\n        const isDest=P.destinations.includes(s);\n        const isRejected=!!rejectedMap[s];\n        const isClosed=closedSet.has(s);\n        const isUnresolved=!s||s==='undefined';\n        const col=(!isRejected&&!isUnresolved&&!isClosed)?colorForCode(s,P.colors,colorCache):null;\n        if(isRejected) fs.forEach(fw=>{\n          const bp=document.createElementNS(NS,'path'); bp.setAttribute('d',pathFor(fw.feature,pr));\n          bp.setAttribute('class','sect-rejected-border'); svg.appendChild(bp);\n        });\n        fs.forEach(fw=>{\n          const p=document.createElementNS(NS,'path'); p.setAttribute('d',pathFor(fw.feature,pr));\n          p.setAttribute('class','sect'+(isRejected?' rejected':isClosed?' closed-ctx':isUnresolved?' unresolved':''));\n          if(col) p.setAttribute('fill',col);\n          const tip=[];\n          if(isRejected) tip.push(`<b>${esc(s)}</b> — ${esc(rejectedMap[s])}`);\n          if(fw.owners.length>1) tip.push(`<div>${esc(fw.src.name||fw.src.id||'')} — ${fw.owners.length} ${esc(P.destinationLabel)}(s): ${fw.owners.map(esc).join(', ')}</div>`);\n          if(tip.length){\n            p.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';t.innerHTML=tip.join('');};\n            p.onmouseleave=()=>{$('#tip').style.opacity=0;};\n          }\n          svg.appendChild(p);\n          if(fw.owners.length>1){\n            const hp=document.createElementNS(NS,'path'); hp.setAttribute('d',pathFor(fw.feature,pr));\n            hp.setAttribute('fill','url(#multiOwnerHatch)'); hp.style.pointerEvents='none';\n            svg.appendChild(hp);\n            const nm=fw.src.name||fw.src.id; if(nm&&!multiBorder[nm]) multiBorder[nm]=fw.src;\n          }\n        });\n        if(s&&isDest){\n          const key='s:'+s;\n          if(!(key in lblCache)) lblCache[key]=labelPointFor(fs.map(fw=>fw.feature),pr,ST.mapW,ST.mapH);\n          const lp=lblCache[key];\n          if(ST.legendCtxOn&&lp&&lp.p&&lp.r>=5) labels.push({anchor:lp.p,big:true,lines:[{t:s,size:11.5,weight:700}]});\n        }\n      });\n      Object.values(multiBorder).forEach(f=>{\n        const mb=document.createElementNS(NS,'path'); mb.setAttribute('d',pathFor(f,pr));\n        mb.setAttribute('class','sect-multi-border'); svg.appendChild(mb);\n      });\n\n      // closing units layer — grouped by P.groupBy when given, else one unit = one clickable shape\n      if(!P.groupBy){\n        P.units.forEach(u=>{\n          const key=u.id, dest=ST.assign[key];\n          const p=document.createElementNS(NS,'path'); p.setAttribute('d',pathFor(u,pr));\n          const fillCol=asis?P.closingColor:(colorForCode(dest,P.colors,colorCache)||'#444');\n          p.setAttribute('class','muni'); p.setAttribute('fill',fillCol);\n          if(!ST.showOutline) p.style.stroke='none';\n          const clickable=P.interactive&&!asis&&P.destinations.length;\n          p.style.cursor=clickable?'pointer':'default';\n          if(clickable) p.onclick=()=>{\n            const next=nextSafeDest(key,ST.assign,keyAdj,P.destinations,P.enforceContiguity);\n            ST.assign[key]=next; drawMap();\n            d2.emit('assignmentChange',{unitId:key,destination:next,assignments:{...ST.assign}});\n          };\n          p.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';\n            t.innerHTML=`<b>${esc(u.name||u.id)}</b>${dest?`<br>→ ${esc(dest)}`:''}`;};\n          p.onmouseleave=()=>{$('#tip').style.opacity=0;};\n          svg.appendChild(p);\n          if(!(('u:'+key) in lblCache)) lblCache['u:'+key]=labelPointFor([u],pr,ST.mapW,ST.mapH);\n          const lp=lblCache['u:'+key];\n          const at=(lp&&lp.p)||cxy(u,pr);\n          if(ST.legendMuniOn) labels.push({anchor:at,lines:[{t:u.name||u.id,size:8.2,weight:700}]});\n        });\n      } else {\n        const groups={};\n        P.units.forEach(u=>{ const g=idFor(u.id); (groups[g]=groups[g]||[]).push(u); });\n        Object.entries(groups).forEach(([g,us])=>{\n          const dest=ST.assign[g];\n          const fillCol=asis?P.closingColor:(colorForCode(dest,P.colors,colorCache)||'#444');\n          us.forEach(u=>{\n            const bp=document.createElementNS(NS,'path'); bp.setAttribute('d',pathFor(u,pr));\n            bp.setAttribute('class','unit-outline'); svg.appendChild(bp);\n          });\n          const els=[];\n          const clickable=P.interactive&&!asis&&P.destinations.length;\n          us.forEach(u=>{\n            const p=document.createElementNS(NS,'path'); p.setAttribute('d',pathFor(u,pr));\n            p.setAttribute('class','muni'); p.setAttribute('fill',fillCol); p.style.stroke=fillCol;\n            els.push(p);\n            if(clickable) p.onclick=()=>{\n              const next=nextSafeDest(g,ST.assign,keyAdj,P.destinations,P.enforceContiguity);\n              ST.assign[g]=next; drawMap();\n              d2.emit('assignmentChange',{unitId:g,destination:next,assignments:{...ST.assign}});\n            };\n            p.style.cursor=clickable?'pointer':'default';\n            p.onmouseenter=()=>els.forEach(e=>e.classList.add('munihl'));\n            p.onmousemove=e=>{const t=$('#tip');t.style.opacity=1;t.style.left=(e.clientX+12)+'px';t.style.top=(e.clientY+12)+'px';\n              t.innerHTML=`<b>${esc(g)}</b>${dest?`<br>→ ${esc(dest)}`:''}`;};\n            p.onmouseleave=()=>{els.forEach(e=>e.classList.remove('munihl'));$('#tip').style.opacity=0;};\n            svg.appendChild(p);\n          });\n          if(!(('g:'+g) in lblCache)) lblCache['g:'+g]=labelPointFor(us,pr,ST.mapW,ST.mapH);\n          const lp=lblCache['g:'+g];\n          const at=(lp&&lp.p)||(us[0]&&cxy(us[0],pr));\n          if(at&&ST.legendMuniOn) labels.push({anchor:at,big:true,lines:[{t:g,size:9,weight:700}]});\n        });\n      }\n\n      drawOsmLayer(svg,pr,feats);\n\n      labels.forEach(L=>{\n        const g=document.createElementNS(NS,'g'); g.setAttribute('class','lbl'+(L.big?' big':''));\n        const hTot=L.lines.reduce((a,l)=>a+l.size*1.2,0);\n        let ty=L.anchor[1]-hTot/2+L.lines[0].size*0.86;\n        const swBase=L.big?2.4:1.8;\n        L.lines.forEach(l=>{\n          const t=document.createElementNS(NS,'text');\n          t.setAttribute('x',L.anchor[0].toFixed(1)); t.setAttribute('y',ty.toFixed(1));\n          t.setAttribute('font-size',l.size); t.setAttribute('data-fs',l.size); t.setAttribute('font-weight',l.weight||600);\n          t.setAttribute('data-sw',swBase); t.style.strokeWidth=swBase+'px';\n          t.style.fill='#fff'; t.style.stroke='rgba(0,0,0,.72)';\n          t.textContent=l.t; g.appendChild(t); ty+=l.size*1.2;\n        });\n        svg.appendChild(g);\n      });\n\n      renderLegend();\n      applyMapView();\n    }\n\n    // ---------- event wiring ----------\n    $('#viewToggle').querySelectorAll('button').forEach(b=>b.onclick=()=>{\n      ST.view=b.dataset.v;\n      $('#viewToggle').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b));\n      drawMap();\n    });\n    $('#zoomIn').onclick=()=>zoomMapBy(1.4);\n    $('#zoomOut').onclick=()=>zoomMapBy(1/1.4);\n    $('#map').addEventListener('pointerdown',mapPointerDown);\n    $('#map').addEventListener('wheel',e=>{ e.preventDefault(); zoomMapAt(e.deltaY<0?1.15:1/1.15, e.clientX, e.clientY); }, {passive:false});\n    root.addEventListener('pointermove',mapPointerMove);\n    root.addEventListener('pointerup',mapPointerUp);\n    root.addEventListener('pointerleave',mapPointerUp);\n    $('#map').addEventListener('click',e=>{ if(mapDidDrag){ e.stopPropagation(); e.preventDefault(); mapDidDrag=false; } },true);\n\n    const osmIcon=$('#osmPanelIcon'), osmPanel=$('#osmPanel'), osmX=$('#osmPanelX');\n    if(P.showOsmPanel){ osmIcon.classList.remove('hidden'); }\n    function syncOsmPanelOpen(){ osmPanel.classList.toggle('hidden', !ST.osmPanelOpen); osmIcon.classList.toggle('hidden', ST.osmPanelOpen||!P.showOsmPanel); }\n    osmIcon.onclick=()=>{ ST.osmPanelOpen=true; syncOsmPanelOpen(); };\n    osmX.onclick=()=>{ ST.osmPanelOpen=false; syncOsmPanelOpen(); };\n    syncOsmPanelOpen();\n    ['rodovias','rivers','airports'].forEach(k=>{\n      const el=$('#osm'+k[0].toUpperCase()+k.slice(1));\n      el.onchange=()=>{ ST.osmOn[k]=el.checked; drawMap(); };\n    });\n    $('#muniOutline').onchange=()=>{ ST.showOutline=$('#muniOutline').checked; drawMap(); };\n    $('#legendMuni').onchange=()=>{ ST.legendMuniOn=$('#legendMuni').checked; drawMap(); };\n\n    d2.on(window,'resize',()=>{ fitMapHeight(); drawMap(); });\n\n    fitMapHeight(); drawMap();\n\n    // updated(d2,root) calls this when a bound param changes — only re-seed local edit state\n    // (assign/view) when the incoming data actually differs, so an in-progress click-to-reassign\n    // isn't clobbered by an unrelated re-render.\n    let lastUnitsJSON=JSON.stringify(P.units), lastAssignJSON=JSON.stringify(ST.assign);\n    root.__refresh=function(){\n      readParams();\n      const uj=JSON.stringify(P.units);\n      if(uj!==lastUnitsJSON){ lastUnitsJSON=uj; for(const k in lblCache) delete lblCache[k]; }\n      const aj=JSON.stringify(d2.param('assignments',{})||{});\n      if(aj!==lastAssignJSON){ lastAssignJSON=aj; seedAssignments(); }\n      drawMap();\n    };\n  },\n  updated(d2, root){\n    if(root.__refresh) root.__refresh();\n  },\n}\n"}},"units":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"units"}}}},"neighbors":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"neighbors"}}}},"destinations":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"destinations"}}}},"assignments":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"assignments"}}}},"colors":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"colors"}}}},"rejected":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rejected"}}}},"closedContext":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"closedContext"}}}},"groupBy":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"groupBy"}}}},"unitLabel":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"unitLabel"}}}},"destinationLabel":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"destinationLabel"}}}},"closingColor":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"closingColor"}}}},"interactive":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"interactive"}}}},"enforceContiguity":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"enforceContiguity"}}}},"showOsmPanel":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"showOsmPanel"}}}},"legendEnabled":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"legendEnabled"}}}},"osmProxy":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"osmProxy"}}}}}}}}}},{"_id":"6aa25e3dfa5e2b2b2993e2c0","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"extends":"data2-core-primitives@Block","component":"SEC_Home_Visao_Geral","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"export default {\n  css: `\n  :host{display:block;height:100%;--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--acc2:#0060df;--closing:#ff3b30;--ok:#28cd41;\n    --radius-ctrl:6px;--shadow-ctrl:0 1px 3px rgba(0,0,0,.15)}\n  *{box-sizing:border-box}\n  .simroot{position:relative;height:100%;overflow:hidden;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}\n  select,input,textarea,button{font:inherit}\n  button.act{background:var(--acc);color:#fff;border:1px solid var(--acc2);border-radius:var(--radius-ctrl);padding:6px 13px;font-weight:600;cursor:pointer;box-shadow:var(--shadow-ctrl);transition:background .1s}\n  button.act:hover{background:var(--acc2)}\n  button.act:disabled{opacity:.4;cursor:not-allowed;box-shadow:none}\n  svg{width:100%;height:auto;display:block;user-select:none;-webkit-user-select:none}\n  /* Fase 9 (pedido do usuário): a barra superior saiu inteira — busca, escopo e alternadores agora\n     vivem num painel fixo à ESQUERDA; o painel de detalhes (.sidebar) passou para a DIREITA. */\n  /* Painel transparente, a pedido: sem cartão por baixo, texto/ícones sem fundo próprio levam\n     halo (text-shadow branco + sombra escura sutil) e o diamante leva o mesmo halo via box-shadow\n     — a mesma ideia do halo branco dos rótulos do mapa (.lbl text), pra continuarem legíveis em\n     cima de qualquer cor de território, sem um cartão opaco brigando visualmente com o mapa. */\n  .leftpanel{position:absolute;left:0;top:0;bottom:0;width:300px;box-sizing:border-box;background:transparent;\n    display:flex;flex-direction:column;gap:20px;\n    padding:14px 14px 16px;overflow:auto;z-index:2;pointer-events:none}\n  .leftpanel > *{pointer-events:auto}\n  .scopelist{display:flex;flex-direction:column;gap:16px;margin:auto 0}\n  .scope-item{display:flex;align-items:center;gap:11px;border:0;background:transparent;padding:0;\n    cursor:pointer;font-size:14px;color:var(--mut);font-weight:600;text-align:left;font-family:inherit;\n    text-shadow:0 0 5px #fff,0 0 5px #fff,0 1px 2px rgba(0,0,0,.25)}\n  .scope-dot{width:9px;height:9px;border-radius:2px;background:#bcd6f5;flex:0 0 auto;\n    transform:rotate(45deg);transition:background .12s,width .12s,height .12s;\n    box-shadow:0 0 0 2px rgba(255,255,255,.9),0 1px 2px rgba(0,0,0,.25)}\n  .scope-item.on{color:var(--ink)}\n  .scope-item.on .scope-dot{width:13px;height:13px;background:var(--acc)}\n  .lefttoggles{display:flex;align-items:center;gap:10px;padding-top:10px}\n  .lp-icon-btn{border:0;background:transparent;color:var(--mut);cursor:pointer;padding:6px;\n    border-radius:8px;display:flex;align-items:center;justify-content:center;width:30px;height:30px}\n  .lp-icon-btn:hover{background:rgba(255,255,255,.55)}\n  .lp-icon-btn.on{color:var(--acc)}\n  .lp-icon-btn svg{width:18px;height:18px}\n  .lp-icon-btn.labelbtn{font-size:15px;font-weight:700;font-family:Georgia,\"Times New Roman\",serif;\n    text-shadow:0 0 5px #fff,0 0 5px #fff,0 1px 2px rgba(0,0,0,.25)}\n  .map-wrap{position:relative;width:100%;height:100%;display:flex;justify-content:center;overflow:hidden}\n  /* Duas camadas no mesmo retângulo. O <canvas> é a GPU: preenchimentos e contornos, e é quem recebe\n     o mouse. O <svg> por cima só carrega os rótulos (~50 textos) e não pega evento nenhum — tudo o\n     que é pesado saiu dele. */\n  #gl{position:absolute;left:0;top:0;width:100%;height:100%;display:block;cursor:default;touch-action:none}\n  #gl.hoverable{cursor:pointer}\n  #gl.dragging{cursor:grabbing}\n  #map{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}\n  .hidden{display:none!important}\n  /* Preenchimentos, contornos de unidade, seleção e o apagado da GV não são mais CSS: vivem no\n     <canvas> (Fase 7). O <svg> só tem rótulos. */\n  /* Sem drop-shadow: é um filtro SVG sobre o grupo inteiro de rótulos, re-rasterizado a cada mudança\n     de viewBox (todo passo de zoom e todo quadro de arrasto). O halo branco do paint-order já dá a\n     legibilidade que a sombra dava. */\n  .lbl{pointer-events:none}\n  /* stroke-width e letter-spacing NÃO ficam aqui, de propósito. Em SVG, \"2px\" numa folha de estilo é\n     2 UNIDADES DO VIEWBOX, não 2 pixels de tela: a 20× de zoom o halo virava ~80 px e o texto uma\n     bolha branca, e o -0,1 de letter-spacing virava -4 px esmagando as letras (\"DENTEDELEAO\").\n     Os dois são escritos por estilizarRotulos (chamado em applyMapView e em drawLabels), escalados\n     pelo MESMO fator que o font-size — e herdam do <g> para os <text>, como o font-size. */\n  .lbl text{text-anchor:middle;paint-order:stroke fill;stroke:#fff;fill:#1c1c1e;stroke-linejoin:round;stroke-linecap:round;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif}\n  /* Fase 9: embutida no painel esquerdo fixo, não mais flutuante sobre o mapa — só o dropdown de\n     resultados (.searchres) continua absoluto, pra não empurrar o resto do painel ao abrir. */\n  .searchbox{position:relative;width:100%;flex:0 0 auto;\n    background:var(--panel);border:1px solid var(--line);border-radius:10px;\n    box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08)}\n  .searchbox-row{display:flex;align-items:center;gap:7px;padding:7px 10px}\n  .searchbox-ico{color:var(--mut);font-size:13px;line-height:1;flex:0 0 auto}\n  .searchbox input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:var(--ink);\n    font-size:13px;padding:2px 0}\n  .searchbox input::placeholder{color:var(--mut)}\n  .searchbox-x{border:0;background:transparent;color:var(--mut);cursor:pointer;font-size:11px;\n    padding:3px 5px;border-radius:5px;flex:0 0 auto}\n  .searchbox-x:hover{background:var(--panel2);color:var(--ink)}\n  .searchres{position:absolute;left:0;right:0;top:100%;margin-top:6px;max-height:320px;overflow:auto;\n    background:var(--panel);border:1px solid var(--line);border-radius:10px;\n    box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);padding:4px;z-index:6}\n  .sr-grp{font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);\n    font-weight:600;padding:7px 8px 3px}\n  .sr-item{display:flex;align-items:baseline;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer}\n  .sr-item:hover,.sr-item.on{background:var(--panel2)}\n  .sr-item .sr-nm{font-size:12.5px;color:var(--ink);font-weight:600;overflow:hidden;\n    text-overflow:ellipsis;white-space:nowrap}\n  .sr-item .sr-sub{font-size:11px;color:var(--mut);margin-left:auto;white-space:nowrap;flex:0 0 auto}\n  .sr-vazio{padding:10px 8px;font-size:12px;color:var(--mut)}\n  /* Painel lateral. Sobreposto ao mapa, e não empurrando-o: empurrar mudaria a largura do\n     container, o que dispara syncMapDims, invalida a projeção e força um redesenho da malha\n     inteira a cada abrir e fechar. */\n  .sidebar{position:absolute;right:10px;top:10px;bottom:10px;width:min(360px,calc(100% - 20px));\n    z-index:3;background:var(--panel);border:1px solid var(--line);border-radius:10px;\n    box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);\n    display:flex;flex-direction:column;overflow:hidden}\n  .sb-hd{padding:12px 34px 10px 14px;border-bottom:1px solid var(--line)}\n  .sb-kind{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);font-weight:600}\n  .sb-nome{font-size:16px;font-weight:600;letter-spacing:-.2px;margin-top:2px;line-height:1.25}\n  .sb-cod{font-size:11.5px;color:var(--mut);margin-top:3px}\n  .sb-x{position:absolute;right:8px;top:8px;border:0;background:transparent;color:var(--mut);\n    cursor:pointer;font-size:11px;padding:5px 7px;border-radius:6px}\n  .sb-x:hover{background:var(--panel2);color:var(--ink)}\n  .sb-bd{flex:1;overflow:auto;padding:12px 14px}\n  .sb-caminho{font-size:11.5px;color:var(--mut);line-height:1.6;margin-bottom:12px}\n  .sb-caminho b{color:var(--ink);font-weight:600}\n  .sb-crumb{cursor:pointer;text-decoration:underline;text-decoration-color:transparent;transition:text-decoration-color .12s}\n  .sb-crumb:hover{text-decoration-color:currentColor}\n  .sb-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}\n  .sb-kpi{background:var(--panel2);border-radius:8px;padding:8px 10px}\n  .sb-kpi .n{font-size:17px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.2}\n  .sb-kpi .l{font-size:10.5px;color:var(--mut);margin-top:2px}\n  .sb-sec{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);\n    font-weight:600;margin:16px 0 7px}\n  .sb-banda{background:var(--panel2);border-radius:8px;padding:10px 12px}\n  .sb-barra{position:relative;height:8px;background:#e0e0e6;border-radius:4px;margin:10px 0 7px}\n  .sb-faixa{position:absolute;top:0;bottom:0;background:#bdeecb;border-radius:4px}\n  /* translateX pra marca ficar CENTRADA no valor: com left:100% ela saía inteira para fora da\n     barra, e o clamp escondia a magnitude justamente no caso extremo. */\n  .sb-marca{position:absolute;top:-4px;bottom:-4px;width:2px;background:var(--ink);border-radius:1px;transform:translateX(-50%)}\n  .sb-alvo{position:absolute;top:-2px;bottom:-2px;width:1px;background:var(--mut)}\n  .sb-veredito{font-size:12.5px;font-weight:600}\n  /* Tons escurecidos para passar contraste AA sobre o cinza do cartão. O verde do sistema\n     (#28cd41) dá ~1,9:1 aqui — e é justamente o estado mais comum. */\n  .sb-veredito.ok{color:#1a8c2e} .sb-veredito.baixo{color:#8a5200} .sb-veredito.alto{color:#c4231a}\n  .sb-nota{font-size:11px;color:var(--mut);line-height:1.55;margin-top:7px}\n  .sb-ft{padding:10px 12px;border-top:1px solid var(--line);background:var(--panel2)}\n  .sb-ft button{width:100%}\n  .tip{position:fixed;pointer-events:none;background:#1c1c1e;color:#fff;border-radius:8px;padding:7px 9px;font-size:11px;opacity:0;transition:opacity .1s;z-index:9;max-width:230px;box-shadow:0 2px 8px rgba(0,0,0,.28)}\n  .tip.on{opacity:1}\n  .note{color:var(--mut);font-size:10.5px}\n  `,\n  render(d2){\n    return `\n    <div class=\"simroot\">\n      <div class=\"leftpanel\">\n        <div class=\"searchbox\" id=\"searchBox\">\n          <div class=\"searchbox-row\">\n            <span class=\"searchbox-ico\" aria-hidden=\"true\">&#9906;</span>\n            <input id=\"searchInput\" type=\"text\" autocomplete=\"off\" spellcheck=\"false\"\n                   placeholder=\"Buscar setor, GV, mercado ou município\" aria-label=\"Buscar unidade\">\n            <button class=\"searchbox-x hidden\" id=\"searchClear\" title=\"Limpar\" aria-label=\"Limpar busca\">&#10005;</button>\n          </div>\n          <div class=\"searchres hidden\" id=\"searchRes\" role=\"listbox\"></div>\n        </div>\n        <div class=\"scopelist\" id=\"levelToggle\" role=\"radiogroup\" aria-label=\"Escopo do mapa\">\n          <button type=\"button\" data-level=\"mercado\" class=\"scope-item on\"><span class=\"scope-dot\"></span>Mercado</button>\n          <button type=\"button\" data-level=\"gv\" class=\"scope-item\"><span class=\"scope-dot\"></span>GV</button>\n          <button type=\"button\" data-level=\"setor\" class=\"scope-item\"><span class=\"scope-dot\"></span>Setor</button>\n        </div>\n        <div class=\"lefttoggles\">\n          <button type=\"button\" id=\"toggleLabels\" class=\"lp-icon-btn labelbtn\" title=\"Mostrar/ocultar rótulos\" aria-label=\"Mostrar/ocultar rótulos\">A</button>\n        </div>\n      </div>\n      <div class=\"map-wrap\">\n        <canvas id=\"gl\"></canvas>\n        <svg id=\"map\" viewBox=\"0 0 700 500\" preserveAspectRatio=\"xMidYMid meet\"></svg>\n        <div class=\"sidebar hidden\" id=\"sidebar\" role=\"region\" aria-label=\"Detalhes da unidade selecionada\">\n          <div class=\"sb-hd\" id=\"sbHead\"></div>\n          <button class=\"sb-x\" id=\"sbClose\" title=\"Fechar painel\" aria-label=\"Fechar painel\">&#10005;</button>\n          <div class=\"sb-bd\" id=\"sbBody\"></div>\n          <div class=\"sb-ft hidden\" id=\"sbFoot\">\n            <button class=\"act\" id=\"sbFechar\">Simular fechamento</button>\n          </div>\n        </div>\n        <div class=\"tip\" id=\"tip\"></div>\n      </div>\n    </div>\n    `;\n  },\n  mounted(d2, root){\n    const $=s=>root.querySelector(s);\n    const $$=s=>[...root.querySelectorAll(s)];\n    const NS='http://www.w3.org/2000/svg';\n    const AUTH_REF='timelineSimAuthFinal';\n\n    function setStatus(msg,err){ const el=$('#status'); if(!el) return; el.textContent=msg||''; el.classList.toggle('err',!!err); }\n\n    // ---------- nomes e números das unidades (Databricks) ----------\n    // DIVISÃO DE FONTES, decidida em 15/09/2026 e deliberada:\n    //   • o CSV de dominantes governa HIERARQUIA e POSSE (a que GV/mercado um setor pertence, e\n    //     quais municípios são dele). É o que o MOTOR usa — a regra dura `sameParent`, que decide\n    //     quais setores podem receber território, roda sobre o CSV. Se a Home agrupasse pelo CRM e\n    //     as duas fontes discordassem, o usuário selecionaria um setor que a Home diz ser da GV X e\n    //     o motor só ofereceria destinos da GV Y, sem nada na tela explicando por quê.\n    //   • o CRM (Databricks) dá só NOME e NÚMERO. É a única fonte deles, e o CSV não os tem.\n    // Uma consulta por montagem do componente; GV e mercado saem por agregação no cliente. Depois\n    // disto nenhuma seleção na Home toca a rede.\n    const DBX_T='product.perfil_cb.crm_perfil_cb_diaria_historico';\n    const DBX_ELO='product.elo_modelo_combinado.commercial_consultant_index';\n    // Referência LITERAL do listener, e não um parâmetro declarado. Os parâmetros deste componente\n    // NÃO são derivados do uso no código: acrescentar `d2.param('dbxProxy')` ao fonte deixa o schema\n    // com os mesmos 4 params de antes, e um param fora do schema é silenciosamente ignorado em\n    // runtime — compila, liga no bloco, e falha calado. O simulador resolve isto do mesmo jeito. É\n    // o MESMO listener que o simulador usa, não o irmão \"new token\": dois listeners diferentes\n    // significariam duas cadeias que ninguém garantiu equivalentes.\n    const DBX_PROXY='AwW_11Nkg-dKksP7ic8V8';\n    // Mesma expressão do resolveSnapshotPin() do simulador: prefere o PENÚLTIMO DT_HISTORICO e só\n    // cai no mais recente quando não existe um anterior. Hoje a tabela guarda uma única data, então\n    // o COALESCE cai no MAX e as duas telas leem exatamente o mesmo dia — é inócuo AGORA. Está aqui\n    // para o dia em que a tabela passar a acumular histórico: nesse dia, sem isto, a Home passaria a\n    // anunciar um total de CBs que nenhuma tela do simulador calcula, sem nada sinalizando a troca.\n    const SNAP_EXPR=`COALESCE((SELECT MAX(DT_HISTORICO) FROM ${DBX_T} WHERE CD_PAIS=1 AND DT_HISTORICO<(SELECT MAX(DT_HISTORICO) FROM ${DBX_T} WHERE CD_PAIS=1)),(SELECT MAX(DT_HISTORICO) FROM ${DBX_T} WHERE CD_PAIS=1))`;\n    // `cbs` replica o loadSectors() do simulador caractere a caractere. `ativas` usa o MESMO\n    // indicador do ELO que o simulador usa (active_bc_ind) e NÃO a flag ST_FEZ_PED_CICLO_ATUAL do\n    // CRM: as duas contam coisas diferentes e divergem ~10% na prática (setor 3184: 437 pela flag,\n    // 481 pelo ELO). Agregação por COUNT(DISTINCT) e não SUM — medido, o LEFT JOIN hoje não duplica\n    // linha nenhuma (linhas do join == CBs distintas nos setores testados), mas DISTINCT custa o\n    // mesmo e continua correto caso o ELO um dia ganhe mais de uma linha por pessoa e ciclo.\n    // Municípios NÃO são contados aqui: DISTINCT DS_MUNICIPIO é \"onde as CBs estão\", ignora a UF\n    // (funde homônimos) e perde município do setor que esteja sem CB no ciclo. Posse vem do CSV.\n    const UNIT_SQL=`SELECT t.CD_SETOR setor, MAX(t.DS_SETOR) nm_setor,\n        MAX(t.CD_GV) gv_crm, MAX(t.DS_GV) nm_gv, MAX(t.CD_GM) gm_crm, MAX(t.DS_GM) nm_gm,\n        COUNT(DISTINCT t.CD_GRUPO) grupos,\n        COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs,\n        COUNT(DISTINCT CASE WHEN e.active_bc_ind=1 THEN t.CD_CONSULTORA END) ativas,\n        MAX(t.DT_HISTORICO) snapshot\n      FROM ${DBX_T} t\n      LEFT JOIN ${DBX_ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=t.CD_CICLO_ATUAL AND e.country_code=1\n      WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR>0\n        AND t.DT_HISTORICO=${SNAP_EXPR}\n      GROUP BY t.CD_SETOR ORDER BY t.CD_SETOR`;\n    const UNITS={setor:{},gv:{},mercado:{},order:{setor:[],gv:[],mercado:[]},\n                 snapshot:null,loaded:false,error:null,semCrm:0,soCrm:0};\n    const n0=v=>{const n=+v;return isFinite(n)?n:0;};\n    const txt=v=>String(v==null?'':v).trim();\n    // Código de setor/GV vem de DOIS lugares — texto cru do CSV e serialização do Databricks. Um\n    // zero à esquerda ou um \"3184.0\" de um lado só faria o cruzamento falhar em silêncio, perdendo\n    // o nome sem ninguém notar. Canoniza os dois lados pelo número quando for numérico.\n    const secKey=v=>{const s=txt(v); if(!s) return ''; const n=Number(s); return isFinite(n)&&s!==''?String(n):s;};\n    async function dbxQuery(sql){\n      let res;\n      try{ res=await d2.rush(DBX_PROXY).invoke({sql:sql}); }\n      catch(e){\n        // Mesmo diagnóstico de sessão que ensureDominantes já monta para o CSV. O listener é gated\n        // pela MESMA estratégia de autenticação, então \"deslogado\" é a causa mais provável de uma\n        // falha aqui — e sem dizer isso o operador lê \"indisponível\" e abre chamado de infra.\n        let sess='';\n        try{ const a=d2.auth(AUTH_REF); if(!(a&&a.isAuthed)) sess=' — você NÃO está logado na estratégia '+AUTH_REF; }catch(_){ }\n        throw new Error('sonda Databricks indisponível'+sess+': '+((e&&e.message)||e));\n      }\n      const payload=res&&res.data;\n      if(!payload) throw new Error('a sonda não devolveu corpo — listener fora do ar?');\n      // O listener responde 200 com {sql} e SEM `result` quando o upstream estoura o tempo: o\n      // handler monta `result: $result.data`, então um upstream vazio simplesmente não cria a\n      // chave. Ler direto até result.data dava \"Cannot read properties of undefined\", que lê como\n      // bug deste arquivo e manda a investigação para o lugar errado.\n      if(!payload.result) throw new Error('a sonda respondeu 200 sem `result` — normalmente timeout do upstream; o SQL não chegou a rodar');\n      // O upstream também pode responder 200 com um envelope de ERRO ({code,message}) no lugar do\n      // resultado. Sem este teste isso caía em \"respondeu sem dados\" e a mensagem útil (\"invalid\n      // token\") era jogada fora exatamente no caso em que ela resolvia o problema sozinha.\n      if(payload.result.code&&+payload.result.code>=400) throw new Error('sonda Databricks '+payload.result.code+': '+(payload.result.message||'sem detalhe'));\n      const data=payload.result.data;\n      if(!data) throw new Error('a sonda respondeu sem dados');\n      // PENDING/RUNNING não é erro, é \"ainda rodando\" — e esta consulta é mais pesada que a que ela\n      // imita, então está mais perto desse penhasco. Dizer \"erro desconhecido\" aqui faria procurar\n      // defeito onde só faltou espera.\n      if(data.state==='PENDING'||data.state==='RUNNING') throw new Error('a consulta ainda rodava no Databricks quando a sonda parou de esperar ('+data.state+') — recarregue para tentar de novo');\n      if(data.state&&data.state!=='SUCCEEDED'){\n        // `data.error` é o objeto `status` INTEIRO do Databricks: {state, error:{error_code,message}}.\n        // Ler data.error.message devolve undefined SEMPRE, e engoliria a causa de toda falha de SQL\n        // — justo aqui, onde a consulta usa colunas que nenhuma outra query do sistema toca. Procura\n        // nos dois níveis antes de recorrer ao stringify, que é o fallback que salva o simulador.\n        const err=data.error;\n        const msg=(err&&(err.message||(err.error&&err.error.message)))||(err?JSON.stringify(err):'')||'erro desconhecido';\n        throw new Error('Databricks '+data.state+': '+msg);\n      }\n      return data;\n    }\n    function dbxRows(resp){\n      const cols=(resp.columns||[]).map(c=>c.name);\n      return (resp.rows||[]).map(a=>Object.fromEntries(cols.map((k,i)=>[k,a[i]])));\n    }\n    // Memoizado no mesmo padrão do DOM: a promessa é guardada, então duas chamadas concorrentes\n    // compartilham UMA consulta, e uma falha limpa o cache para permitir nova tentativa. Sem isto,\n    // \"uma consulta por sessão\" viraria uma por chamada.\n    let UNIT_ROWS_PROMISE=null;\n    function fetchUnitRows(){\n      if(!UNIT_ROWS_PROMISE) UNIT_ROWS_PROMISE=dbxQuery(UNIT_SQL).then(dbxRows)\n        .catch(e=>{ UNIT_ROWS_PROMISE=null; throw e; });\n      return UNIT_ROWS_PROMISE;\n    }\n    // Exige DOM.bySetor já carregado: a hierarquia sai de lá, não das linhas do CRM.\n    function buildUnitIndex(rs){\n      UNITS.setor={}; UNITS.gv={}; UNITS.mercado={};\n      UNITS.order={setor:[],gv:[],mercado:[]};\n      UNITS.snapshot=null; UNITS.semCrm=0; UNITS.soCrm=0;\n      const crm={},gvNome={};\n      (rs||[]).forEach(r=>{\n        const sc=secKey(r.setor); if(!sc) return;\n        if(!UNITS.snapshot&&r.snapshot) UNITS.snapshot=txt(r.snapshot);\n        crm[sc]={nome:txt(r.nm_setor),grupos:n0(r.grupos),cbs:n0(r.cbs),ativas:n0(r.ativas)};\n        const gc=secKey(r.gv_crm); if(gc&&!gvNome[gc]) gvNome[gc]=txt(r.nm_gv);\n      });\n      const bySetor=DOM.bySetor||{}, gvBy=DOM.gvBySetor||{}, mkBy=DOM.mercadoBySetor||{};\n      const vistos={};\n      Object.keys(bySetor).forEach(raw=>{\n        const sc=secKey(raw); if(!sc) return;\n        vistos[sc]=1;\n        const gc=secKey(gvBy[raw]), mk=txt(mkBy[raw]);\n        const c=crm[sc]||{};\n        if(!crm[sc]) UNITS.semCrm++;\n        const munis=(bySetor[raw]&&bySetor[raw].size)||0;\n        const cbs=n0(c.cbs), ativas=n0(c.ativas), grupos=n0(c.grupos);\n        // `raw` guardado junto: o mapa resolve setor por DOM.setorByMuni, que devolve a chave crua\n        // do CSV. Sem ela, cruzar o desenho com este índice exigiria recanonizar a cada comparação.\n        UNITS.setor[sc]={code:sc,raw:String(raw),nome:c.nome||'',gv:gc,nomeGv:gvNome[gc]||'',\n                         mercado:mk,munis:munis,grupos:grupos,cbs:cbs,ativas:ativas,semCrm:!crm[sc]};\n        if(gc){\n          const g=UNITS.gv[gc]||(UNITS.gv[gc]={code:gc,nome:gvNome[gc]||'',mercado:mk,setores:[],\n                                               munisSoma:0,grupos:0,cbs:0,ativas:0});\n          g.setores.push(sc); g.munisSoma+=munis; g.grupos+=grupos; g.cbs+=cbs; g.ativas+=ativas;\n        }\n        if(mk){\n          const m=UNITS.mercado[mk]||(UNITS.mercado[mk]={code:mk,nome:mk,gvs:[],setores:[],\n                                                         munisSoma:0,grupos:0,cbs:0,ativas:0});\n          m.setores.push(sc); m.munisSoma+=munis; m.grupos+=grupos; m.cbs+=cbs; m.ativas+=ativas;\n          if(gc&&m.gvs.indexOf(gc)<0) m.gvs.push(gc);\n        }\n      });\n      // Setor que existe no CRM e não na base de posse nunca aparece no mapa (o mapa é pintado pelo\n      // CSV), então não entra no índice — mas é contado, porque \"a base está defasada\" é exatamente\n      // o tipo de coisa que precisa ser visível em vez de deduzida.\n      Object.keys(crm).forEach(sc=>{ if(!vistos[sc]) UNITS.soCrm++; });\n      // munisSoma, e não munis: somar os municípios dos setores de uma GV NÃO dá o número de\n      // municípios distintos dela — uma capital é repartida entre dezenas de setores. O nome do\n      // campo diz o que o número realmente é, para nenhuma tela adiante o exibir como \"municípios\".\n      const byCode=(a,b)=>(+a-+b)||(a<b?-1:a>b?1:0);\n      UNITS.order.setor=Object.keys(UNITS.setor).sort(byCode);\n      UNITS.order.gv=Object.keys(UNITS.gv).sort(byCode);\n      UNITS.order.mercado=Object.keys(UNITS.mercado).sort((a,b)=>a<b?-1:a>b?1:0);\n      // Sem isto, um retorno com nomes de coluna inesperados produziria um índice vazio e a tela\n      // exibiria \"0 setores · 0 GVs\" com ar de sucesso.\n      if(!UNITS.order.setor.length) throw new Error('nenhum setor válido saiu do cruzamento entre a base de dominantes e o CRM');\n      UNITS.loaded=true; UNITS.error=null;\n      return UNITS;\n    }\n\n    // ---------- índice de busca ----------\n    // Montado uma vez, depois que UNITS e a base de dominantes existirem. Guarda o texto já\n    // normalizado, para o casamento não precisar normalizar nada a cada tecla digitada.\n    const SEARCH=[];\n    function buildSearchIndex(){\n      SEARCH.length=0;\n      const push=(kind,code,label,sub,hay)=>SEARCH.push({kind:kind,code:code,label:label,sub:sub,norm:norm(hay)});\n      // NOME antes do código no campo de casamento. Com o código na frente, o nome nunca casa na\n      // posição 0, perde o bônus de prefixo, e um município de nome parecido passa na frente do\n      // próprio setor procurado — o oposto do que o comentário de `buscar` promete. Com o nome na\n      // frente, digitar o nome casa em 0 e digitar o código continua casando (só que no meio).\n      UNITS.order.setor.forEach(c=>{const u=UNITS.setor[c];\n        push('setor',c,u.nome||('Setor '+c),'Setor '+c+(u.nomeGv?' · '+u.nomeGv:''),u.nome+' '+c);});\n      UNITS.order.gv.forEach(c=>{const u=UNITS.gv[c];\n        push('gv',c,u.nome||('GV '+c),'GV '+c+(u.mercado?' · '+u.mercado:''),u.nome+' '+c);});\n      UNITS.order.mercado.forEach(c=>{const u=UNITS.mercado[c];\n        push('mercado',c,u.nome,'Mercado',c);});\n      // Município entra pela base de dominantes, não pelo CRM: a pergunta que a busca responde é\n      // \"que setor ATENDE esta cidade\", e posse de território é o conceito certo para isso.\n      // O RÓTULO vem de DOM.muniLabel (a grafia original do arquivo) e não da chave: a chave passou\n      // por norm(), que remove acento e sobe tudo para maiúsculas — \"SAO PAULO\", \"SANTA BARBARA D\n      // OESTE\". Serve para casar, não para ler. O campo `norm` continua igual porque norm() é\n      // idempotente sobre uma string já normalizada.\n      const lbl=DOM.muniLabel||{};\n      if(DOM.setorByMuni) Object.keys(DOM.setorByMuni).forEach(m=>{\n        const sc=secKey(DOM.setorByMuni[m]), u=UNITS.setor[sc];\n        const uf=mUf(m), nome=lbl[m]||mNome(m);\n        // O nome vai no rótulo com a UF entre parênteses porque, com homônimos, \"Bom Jesus\" sozinho\n        // não distingue seis municípios diferentes. O campo de casamento leva chave E nome, então\n        // digitar o nome acha e digitar a sigla também.\n        // O sub usa o MESMO formato do setor (\"Setor <código> · <nome>\"), e não \"Município · <nome>\":\n        // os codinomes de setor (PEDRAS BONITAS, TULIPA BRANCA…) soam como topônimo, e sem a\n        // palavra \"Setor\" na frente pareciam só outro lugar qualquer — confuso justamente na\n        // categoria que existe para responder \"que setor atende esta cidade\".\n        push('municipio',sc,nome+(uf?' ('+uf+')':''),'Setor '+sc+(u&&u.nome?' · '+u.nome:''),m+' '+nome);\n      });\n      return SEARCH.length;\n    }\n\n    const fmtN=n=>(+n||0).toLocaleString('pt-BR');\n    function fmtSnapshot(s){\n      if(!s) return '—';\n      const str=String(s);\n      // Data pura (\"2026-09-15\") é parseada por new Date() como meia-noite UTC, e em America/\n      // Sao_Paulo volta como o dia ANTERIOR às 21h. Numa coluna diária, errar o dia no selo de\n      // confiança do cabeçalho é pior do que não mostrar nada. Hoje a coluna é TIMESTAMP e este\n      // ramo não dispara, mas ele custa uma linha e cobre a mudança de tipo.\n      if(/^\\d{4}-\\d{2}-\\d{2}$/.test(str)) return str.slice(8,10)+'/'+str.slice(5,7)+'/'+str.slice(0,4);\n      const d=new Date(str);\n      return isNaN(d.getTime())?str.slice(0,16).replace('T',' ')\n        :d.toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});\n    }\n    // Resumo de uma linha no cabeçalho: é o critério de aceite visível da camada de dados. Sem ele,\n    // esta fase não muda nada na tela e não há como conferir se carregou de verdade.\n    function unitSummary(){\n      if(!UNITS.loaded) return '';\n      const cbs=UNITS.order.setor.reduce((a,c)=>a+UNITS.setor[c].cbs,0);\n      const bits=[fmtN(UNITS.order.setor.length)+' setores',\n                  fmtN(UNITS.order.gv.length)+' GVs',\n                  fmtN(UNITS.order.mercado.length)+' mercados',\n                  fmtN(cbs)+' CBs',\n                  'snapshot '+fmtSnapshot(UNITS.snapshot),\n                  fmtN(SEARCH.length)+' itens buscáveis'];\n      // Município desenhado que a base não atribui a setor nenhum sai cinza no mapa. Sem esta\n      // contagem ele some em silêncio, e \"sumir em silêncio\" é o modo de falha mais caro que esta\n      // tela tem: a malha do IBGE tem 5.564 municípios e o CRM registra 7.193 grafias distintas.\n      if(MAP_STATS.semDominancia) bits.push(fmtN(MAP_STATS.semDominancia)+' município(s) cinza no mapa'\n        +(MAP_STATS.semHierarquia?' ('+fmtN(MAP_STATS.semHierarquia)+' por setor sem hierarquia na base)':''));\n      if(OUTER_CONFLITOS[LEVEL]) bits.push(fmtN(OUTER_CONFLITOS[LEVEL])+' unidade(s) sem tom livre no nível externo — paleta esgotada');\n      if(INNER_CONFLITOS[LEVEL]) bits.push(fmtN(INNER_CONFLITOS[LEVEL])+' unidade(s) sem tom livre no nível interno — paleta esgotada');\n      if(MAP_STATS.semMercadoCor) bits.push(fmtN(MAP_STATS.semMercadoCor)+' setor(es) sem mercado na base — coloridos no azul padrão');\n      // O modo de chaveamento muda o resultado do mapa inteiro. No modo bom fica calado; no modo\n      // degradado avisa — porque aí homônimos de estados diferentes viram um município só.\n      if(DOM.hasUf===false) bits.push('⚠ base sem coluna de UF — municípios homônimos colapsam num setor só');\n      // Qual coluna foi escolhida importa: a lista de candidatos aceita nomes frouxos ('SIGLA',\n      // 'ESTADO', 'CD UF'), e uma coluna errada cujos valores PAREÇAM UF pinta o mapa quase todo de\n      // cinza sem disparar nenhum dos contadores. Dizer qual foi usada é a única pista barata.\n      else if(DOM.ufCol) bits.push('UF pela coluna “'+DOM.ufCol+'”');\n      if(DOM.badUf) bits.push(fmtN(DOM.badUf)+' linha(s) com UF irreconhecível');\n      if(DOM.semUf) bits.push(fmtN(DOM.semUf)+' linha(s) sem UF — esses municípios não casam com a malha');\n      if(UNITS.semCrm) bits.push(fmtN(UNITS.semCrm)+' setor(es) da base sem dado no CRM');\n      if(UNITS.soCrm) bits.push(fmtN(UNITS.soCrm)+' setor(es) do CRM fora da base');\n      // Critério de aceite do cache da malha: sem isto, ligar ou desligar o cache não muda nada\n      // visível na tela e não há como conferir se ele está funcionando.\n      if(GEO_FROM_CACHE) bits.push('malha: '+fmtN(GEO_FROM_CACHE)+'/27 UFs do cache local');\n      if(GEO_FAILED_UFS.length) bits.unshift('⚠ malha não carregou pra '+GEO_FAILED_UFS.join(', ')+' — recarregue a página');\n      // Erro do motor de desenho entra NO resumo, e na frente: sem isto o toggle de nível e o botão\n      // limpar reescreviam \"622 setores · … CBs\" por cima de \"este navegador não tem WebGL\", e a\n      // tela ficava em branco com ar de sucesso.\n      if(GL_ERRO) bits.unshift('⚠ mapa não desenhado: '+GL_ERRO);\n      return bits.join(' · ');\n    }\n\n    const norm=s=>(s||'').normalize('NFKD').replace(/[̀-ͯ]/g,'').toUpperCase().replace(/[^A-Z0-9]+/g,' ').trim();\n    let MUNI_ALIAS={BRASOPOLIS:'BRAZOPOLIS','DONA EUSEBIA':'DONA EUZEBIA'};\n    const muniN=s=>{const n=norm(s);return MUNI_ALIAS[n]||n;};\n\n    async function loadMuniAliases(){\n      let ds=null;\n      for(let attempt=0; attempt<5 && !ds; attempt++){\n        if(attempt) await new Promise(r=>setTimeout(r,1200));\n        ds=d2.data('muniAliasDataset');\n      }\n      if(!ds) return;\n      try{\n        await ds.refresh();\n        const recs=await ds.loadAll();\n        recs.forEach(r=>{ const de=norm(r.de), pa=norm(r.para); if(de&&pa) MUNI_ALIAS[de]=pa; });\n      }catch(e){ /* non-fatal */ }\n    }\n\n    const UF_CODE={RO:'11',AC:'12',AM:'13',RR:'14',PA:'15',AP:'16',TO:'17',MA:'21',PI:'22',CE:'23',RN:'24',PB:'25',PE:'26',AL:'27',SE:'28',BA:'29',MG:'31',ES:'32',RJ:'33',SP:'35',PR:'41',SC:'42',RS:'43',MS:'50',MT:'51',GO:'52',DF:'53'};\n    // ---------- cache da malha no navegador ----------\n    // A malha do IBGE são 27 arquivos, 8,1 MB de rede comprimidos e ~11 s antes de a tela mostrar\n    // qualquer coisa — pagos INTEIROS a cada entrada no app, para baixar exatamente os mesmos bytes\n    // de sempre. Fronteira de município muda por lei, em lote, raramente.\n    // Guarda o TEXTO da resposta, e não as features já parseadas: a IndexedDB faz structured clone\n    // na escrita, e clonar 642 mil pontos em arrays aninhados custa mais do que o download que se\n    // quer evitar. Uma string é copiada em bloco. O JSON.parse continua acontecendo na leitura,\n    // igual a hoje — o que o cache elimina é a REDE, que é a parte lenta.\n    // A FONTE não muda: continua o mesmo arquivo do mesmo repositório, buscado do mesmo jeito na\n    // primeira vez. Isto é memória de navegador, não uma origem de dado nova.\n    // NENHUMA falha aqui pode derrubar o mapa: aba anônima, storage bloqueado por política da\n    // máquina e cota estourada são todos casos reais, e em todos o certo é degradar para o fetch\n    // de sempre, calado. Por isso toda promessa deste bloco RESOLVE — nenhuma rejeita.\n    const GEO_DB='sec-home-geo', GEO_STORE='uf';\n    // Sobe quando a FORMA do que é guardado mudar (outra fonte de malha, outro shape do registro).\n    // Malha velha lida por parser novo é pior do que não ter cache nenhum.\n    const GEO_CACHE_V=1;\n    // Cache sem prazo é cache que ninguém sabe invalidar. 30 dias garante que uma alteração de\n    // fronteira chegue em no máximo um mês, sem custar rede em nenhuma entrada do dia a dia.\n    const GEO_TTL_MS=30*24*3600*1000;\n    let GEO_DB_P=null;\n    function geoDb(){\n      if(GEO_DB_P) return GEO_DB_P;\n      GEO_DB_P=new Promise(res=>{\n        let req;\n        try{ req=indexedDB.open(GEO_DB,1); }catch(e){ return res(null); }\n        let desistiu=false;\n        req.onupgradeneeded=()=>{ try{ req.result.createObjectStore(GEO_STORE); }catch(e){ } };\n        req.onsuccess=()=>{\n          const db=req.result;\n          // Sem isto, esta conexão — que vive o tempo inteiro da página — bloqueia\n          // INDEFINIDAMENTE o \"limpar dados do site\" do navegador e qualquer abertura em versão\n          // nova por outra aba, que cai no onblocked e fica sem cache, calada. Fechar na hora é\n          // seguro: geoDb memoiza a PROMESSA, e a partir daí toda transação cai no catch e degrada\n          // para a rede, que é o comportamento de antes desta fase.\n          db.onversionchange=()=>{ try{ db.close(); }catch(e){ } };\n          // Resolver no onblocked não CANCELA o request: se a outra aba fechar depois, o sucesso\n          // chega aqui com o res já consumido e sobra uma conexão aberta que ninguém usa — e é\n          // justamente conexão aberta que faz a próxima aba cair no onblocked.\n          if(desistiu){ try{ db.close(); }catch(e){ } return; }\n          res(db);\n        };\n        req.onerror=()=>res(null);\n        // Uma outra aba com a versão antiga aberta trava o upgrade para SEMPRE — sem erro e sem\n        // sucesso. Sem este ramo a promessa nunca resolve e o boot fica pendurado em\n        // \"carregando mapa…\" sem nada na tela explicando o quê.\n        req.onblocked=()=>{ desistiu=true; res(null); };\n      });\n      return GEO_DB_P;\n    }\n    function geoCacheGet(uf){\n      return geoDb().then(db=>db&&new Promise(res=>{\n        let r;\n        try{\n          const tx=db.transaction(GEO_STORE,'readonly');\n          // Mesma blindagem da escrita, e pelo mesmo motivo: um erro de request borbulha para a\n          // transação e daí para a conexão, então resolver a promessa com null deixa o mapa certo\n          // mas não impede o evento não tratado de aparecer no console a cada entrada — com o mapa\n          // funcionando perfeitamente e quem olhar procurando defeito nele.\n          tx.onabort=()=>{}; tx.onerror=()=>{};\n          r=tx.objectStore(GEO_STORE).get(uf);\n        }\n        catch(e){ return res(null); }\n        r.onsuccess=()=>{\n          const v=r.result;\n          res((v&&v.v===GEO_CACHE_V&&v.txt&&(Date.now()-(+v.at||0))<GEO_TTL_MS)?v.txt:null);\n        };\n        r.onerror=()=>res(null);\n      })).catch(()=>null);\n    }\n    // Cota estourada aborta a transação de forma ASSÍNCRONA, fora do alcance do try: sem os\n    // handlers vazios o erro sobe como evento não tratado e aparece no console a cada entrada —\n    // e quem olhar vai procurar defeito no mapa, que está funcionando.\n    function geoCacheWrite(fn){\n      return geoDb().then(db=>{ if(!db) return;\n        try{\n          const tx=db.transaction(GEO_STORE,'readwrite');\n          tx.onabort=()=>{}; tx.onerror=()=>{};\n          const rq=fn(tx.objectStore(GEO_STORE));\n          if(rq) rq.onerror=()=>{};\n        }catch(e){ }\n      }).catch(()=>{});\n    }\n    function geoCachePut(uf,txt){\n      return geoCacheWrite(st=>st.put({v:GEO_CACHE_V,at:Date.now(),txt:txt},uf));\n    }\n    function geoCacheDel(uf){ return geoCacheWrite(st=>st.delete(uf)); }\n    // PARSEAR NÃO É VALIDAR, e a diferença aqui custa a tela inteira. HTML de portal cativo de\n    // fato estoura no JSON.parse, mas WAF e proxy corporativo respondem 200 com um corpo JSON\n    // legítimo — {\"error\":\"blocked\"} — que parseia sem reclamar. Daí `features` é undefined, o\n    // flatMap de loadAllGeo INSERE undefined em vez de achatar nada (flatMap não achata o que não\n    // é array), e o buraco resultante atravessa project e muniKeyOfFeature, que são guardados, até\n    // estourar em pathOf, que é o único ponto da cadeia de desenho SEM guarda de feature. O boot\n    // morre inteiro e a tela fica sem mapa.\n    const ehMalha=g=>!!(g&&Array.isArray(g.features)&&g.features.length);\n    // Critério de aceite desta fase: sem um contador, ligar ou desligar o cache não muda NADA\n    // visível e não há como conferir se ele está funcionando de verdade.\n    let GEO_FROM_CACHE=0;\n    async function geoUF(uf){\n      const code=UF_CODE[uf]||uf;\n      const U=String(uf).toUpperCase();\n      const cached=await geoCacheGet(U);\n      let g=null;\n      // Registro ruim no cache não pode custar a UF: cai para a rede como se não houvesse cache\n      // nenhum, e o registro sai no caminho em vez de estragar toda entrada até o TTL vencer.\n      // Os dois ramos apagam — texto corrompido (estoura no parse) e corpo que parseia mas não é\n      // malha. Sem o segundo, o registro sobreviveria, a UF pagaria rede MAIS uma leitura inútil\n      // de IndexedDB em toda entrada, e o contador ainda a anunciaria como vinda do cache.\n      if(cached){\n        try{ const p=JSON.parse(cached); if(ehMalha(p)){ g=p; GEO_FROM_CACHE++; } else geoCacheDel(U); }\n        catch(e){ geoCacheDel(U); }\n      }\n      if(!g){\n        const r=await fetch('https://raw.githubusercontent.com/tbrugz/geodata-br/master/geojson/geojs-'+code+'-mun.json');\n        if(!r.ok) throw new Error('geo '+uf);\n        const txt=await r.text();\n        const p=JSON.parse(txt);\n        // Falhar é o comportamento CERTO aqui: loadAllGeo tolera UF nula, então a tela degrada\n        // para um mapa com um estado a menos em vez de não abrir. E, principalmente, nada entra no\n        // cache — gravar um corpo de bloqueio de proxy transformaria um incidente de rede de UMA\n        // entrada num estado pegajoso de 30 dias, repetido a cada abertura com a rede já perfeita\n        // e sem nenhum caminho de recuperação na tela.\n        if(!ehMalha(p)) throw new Error('geo '+uf+': a resposta não é uma malha de municípios (200 com corpo de bloqueio de proxy?)');\n        g=p;\n        // Sem await: gravar é otimização, e a tela não tem por que esperar por ela.\n        geoCachePut(U,txt);\n      }\n      g.features.forEach(f=>{ f.__uf=U; });\n      return g;\n    }\n    // Sigla canônica a partir do que a base traz na coluna de UF: sigla, código IBGE ou nome por\n    // extenso. O que não resolver vira vazio — uma UF inventada chavearia o município para um shape\n    // inexistente e o tiraria do mapa sem dizer nada.\n    const UF_OF_CODE={}; Object.keys(UF_CODE).forEach(k=>{ UF_OF_CODE[UF_CODE[k]]=k; });\n    const UF_OF_NAME={ACRE:'AC',ALAGOAS:'AL',AMAPA:'AP',AMAZONAS:'AM',BAHIA:'BA',CEARA:'CE','DISTRITO FEDERAL':'DF','ESPIRITO SANTO':'ES',GOIAS:'GO',MARANHAO:'MA','MATO GROSSO':'MT','MATO GROSSO DO SUL':'MS','MINAS GERAIS':'MG',PARA:'PA',PARAIBA:'PB',PARANA:'PR',PERNAMBUCO:'PE',PIAUI:'PI','RIO DE JANEIRO':'RJ','RIO GRANDE DO NORTE':'RN','RIO GRANDE DO SUL':'RS',RONDONIA:'RO',RORAIMA:'RR','SANTA CATARINA':'SC','SAO PAULO':'SP',SERGIPE:'SE',TOCANTINS:'TO'};\n    const ufNorm=v=>{ const s=norm(v); if(!s) return '';\n      if(UF_CODE[s]) return s;\n      if(UF_OF_CODE[s]) return UF_OF_CODE[s];\n      return UF_OF_NAME[s]||''; };\n    // Chave de município: NOME/UF quando a base tem UF, só o nome quando não tem. Os dois lados\n    // (base e malha) precisam usar a MESMA regra, senão nada casa — por isso a decisão mora aqui,\n    // num lugar só, e não duplicada nos dois pontos de construção de chave.\n    const mkey=(nome,uf)=>{ const n=muniN(nome); if(!n) return '';\n      // Sem esta guarda, um feature sem nome produziria a chave '/SP' — que é truthy, passa pelo\n      // filtro `if(!k) return` de buildMuniAdjacency, e funde TODOS os features sem nome da mesma\n      // UF num nó só: exatamente o defeito que o chaveamento por UF veio eliminar, em miniatura.\n      const u=String(uf==null?'':uf).trim().toUpperCase();\n      return (DOM.hasUf&&u)?(n+'/'+u):n; };\n    const mNome=k=>{ const s=String(k==null?'':k), i=s.lastIndexOf('/'); return i<0?s:s.slice(0,i); };\n    const mUf=k=>{ const s=String(k==null?'':k), i=s.lastIndexOf('/'); return i<0?'':s.slice(i+1); };\n    let ALL_FEATURES=[];\n    // UFs cujo fetch falhou NESTA sessão (rede, rate limit, o que for) — geoUF já degrada calado por\n    // desenho (uma UF fora do ar não pode derrubar as outras 26), mas isso deixava uma unidade cuja\n    // malha simplesmente não baixou agora indistinguível de um problema genuíno de dado na base.\n    // Guardado para o resumo do cabeçalho (unitSummary) e para a mensagem de \"sem território\n    // desenhado\" (statusDaUnidade) pararem de acusar a base por um hiccup de rede transitório.\n    let GEO_FAILED_UFS=[];\n    async function loadAllGeo(){\n      const ufs=Object.keys(UF_CODE);\n      GEO_FAILED_UFS=[];\n      const geos=await Promise.all(ufs.map(u=>geoUF(u).catch(()=>{ GEO_FAILED_UFS.push(u); return null; })));\n      // (g&&g.features)||[] e nunca g.features direto: flatMap NÃO achata o que não é array — um\n      // retorno undefined é INSERIDO no resultado. Um único buraco em ALL_FEATURES derruba pathOf,\n      // que é o único ponto da cadeia de desenho sem guarda. geoUF já rejeita corpo que não seja\n      // malha; isto é a segunda camada, porque o custo de errar aqui é a tela não abrir.\n      ALL_FEATURES=geos.filter(Boolean).flatMap(g=>(g&&g.features)||[]);\n    }\n\n    const DOM_MEDIA_BASE='https://natura-geomarketing.data2.media/file/';\n    const DOM_CSV_FALLBACK=DOM_MEDIA_BASE+'yw_TQdp0QXEk6JsrliAqP';\n    // hasUf nasce FALSE explicitamente, e não undefined: mkey o consulta, e se ensureDominantes\n    // falhar antes de defini-lo o modo de chaveamento ficaria indefinido no meio do boot — com as\n    // chaves já congeladas em f.__mk e em MUNI_NBRS, sem nada que as invalide depois.\n    const DOM={url:null,promise:null,bySetor:null,hasUf:false};\n    function parseCsv(text){\n      const out=[]; let row=[],cur='',q=false;\n      const push=()=>{ row.push(cur); cur=''; };\n      const endRow=()=>{ push(); if(row.length>1||row[0]!=='') out.push(row); row=[]; };\n      for(let i=0;i<text.length;i++){ const ch=text[i];\n        if(q){ if(ch==='\"'){ if(text[i+1]==='\"'){ cur+='\"'; i++; } else q=false; } else cur+=ch; }\n        else if(ch==='\"') q=true;\n        else if(ch===',') push();\n        else if(ch==='\\n') endRow();\n        else if(ch!=='\\r') cur+=ch;\n      }\n      if(cur!==''||row.length) endRow();\n      return out;\n    }\n    function d2SessionToken(sysDomain){\n      const read=k=>{ try{ return (k&&window.localStorage.getItem(k))||''; }catch(e){ return ''; } };\n      return read(sysDomain?('__data2-session-'+sysDomain):'')||read('__data2-session-data2')||'';\n    }\n    function domCsvSystemDomain(url){\n      const m=/^https?:\\/\\/([^.\\/]+)\\.data2\\.(?:media|app)\\//i.exec(url)||/^https?:\\/\\/([^.\\/]+)\\.data2apis\\.com\\//i.exec(url);\n      return m?m[1]:'';\n    }\n    function domCsvCandidates(url){\n      const out=[];\n      const isMedia=/^https?:\\/\\/[^.\\/]+\\.data2\\.media\\//i.test(url);\n      const api=isMedia?url.replace(/^(https?:\\/\\/[^.\\/]+)\\.data2\\.media\\//i,'$1.data2apis.com/'):null;\n      const tok=d2SessionToken(domCsvSystemDomain(url));\n      const auth=tok?{authorization:'Bearer '+tok}:null;\n      if(api&&auth) out.push({url:api,headers:auth,label:'api+sessão'});\n      if(!api&&auth) out.push({url:url,headers:auth,label:'origem+sessão'});\n      if(api) out.push({url:api,headers:null,label:'api anônimo'});\n      out.push({url:url,headers:null,label:'origem anônima'});\n      return out;\n    }\n    function domCsvUrl(){\n      const pick=v=>{\n        if(!v) return '';\n        if(typeof v==='string') return v;\n        const q=v.params||{};\n        const ref=q.reference||v.reference;\n        return q.fileUrl||q.url||v.fileUrl||v.url||(ref?DOM_MEDIA_BASE+ref:'');\n      };\n      return String(pick(d2.param('dominantesCsvUrl'))||DOM_CSV_FALLBACK||'').trim();\n    }\n    async function ensureDominantes(){\n      const url=domCsvUrl();\n      if(!url) throw new Error('base de municípios dominantes não configurada');\n      if(DOM.url===url && DOM.bySetor) return DOM.bySetor;\n      if(DOM.url===url && DOM.promise) return DOM.promise;\n      DOM.url=url;\n      DOM.promise=(async()=>{\n        let r=null; const tried=[];\n        for(const c of domCsvCandidates(url)){\n          let res=null;\n          try{ res=await fetch(c.url,{cache:'no-store',credentials:'omit',headers:c.headers||undefined}); }\n          catch(e){ tried.push(c.label+' → rede/CORS'); continue; }\n          if(res.ok){ r=res; break; }\n          tried.push(c.label+' → '+res.status);\n        }\n        if(!r){\n          let sess='sessão desconhecida';\n          try{\n            const a=d2.auth(AUTH_REF);\n            sess=(a&&a.isAuthed)?('logado como '+((a.user&&(a.user.email||a.user.nome))||'?')):('NÃO logado na estratégia '+AUTH_REF);\n          }catch(e){ sess='estratégia '+AUTH_REF+' não resolvida'; }\n          throw new Error('base de municípios dominantes: não foi possível ler o arquivo — '+sess+'. Tentativas: '+tried.join(' · '));\n        }\n        const table=parseCsv(await r.text());\n        if(table.length<2) throw new Error('base de municípios dominantes vazia');\n        const head=table[0].map(h=>norm(h));\n        const iS=head.indexOf('CD SETOR'), iM=head.indexOf('MUNICIPIO');\n        if(iS<0||iM<0) throw new Error('base sem as colunas CD_SETOR / MUNICIPIO');\n        const iG=['CD GV','CDGV','GV','CD GERENCIA','CD GERENCIA VENDAS','CD GERENCIA DE VENDAS','GERENCIA DE VENDAS','GERENCIA'].map(h=>head.indexOf(h)).find(i=>i>=0);\n        if(iG==null) throw new Error('base sem a coluna CD_GV');\n        const iK=['MERCADO','MERCADO REGIONAL','MKT','MARKET'].map(h=>head.indexOf(h)).find(i=>i>=0);\n        if(iK==null) throw new Error('base sem a coluna MERCADO');\n        // Coluna de UF: opcional, e decisiva. Sem ela o município é chaveado só pelo nome — e o\n        // Brasil tem centenas de nomes repetidos entre estados (Bom Jesus, Santa Luzia, Bonito).\n        // Todos colapsavam no primeiro setor encontrado: dezenas de municípios pintados no setor\n        // errado, a caixa envolvente da unidade esticada por meio país (arruinando o corte de\n        // rótulo e o enquadramento da busca) e cor gasta à toa na coloração. Com ela, a chave vira\n        // NOME/UF e o problema desaparece. Sem ela, o comportamento volta a ser exatamente o de\n        // antes — e DOM.hasUf passa a dizer em que modo a tela está, em vez de a diferença ficar\n        // invisível.\n        const iU=['UF','SIGLA UF','SG UF','CD UF','ESTADO','DS ESTADO','SIGLA','ESTADO SIGLA','UF MUNICIPIO']\n          .map(h=>head.indexOf(h)).find(i=>i>=0);\n        const by={},gvBy={},mkBySetor={},muniLabel={}; let n=0,badUf=0,semUf=0;\n        for(let i=1;i<table.length;i++){\n          const line=table[i]; if(!line) continue;\n          const s=String(line[iS]==null?'':line[iS]).trim(), nm=muniN(line[iM]);\n          if(!s||!nm) continue;\n          const uf=(iU==null)?'':ufNorm(line[iU]);\n          // UF presente na linha mas irreconhecível é contada, não silenciada: é erro de base, e\n          // some sem rastro se a linha simplesmente degradar para chave sem UF.\n          if(iU!=null && String(line[iU]==null?'':line[iU]).trim() && !uf) badUf++;\n          // Célula de UF VAZIA numa base que TEM a coluna é pior que UF errada, e escapava da\n          // contagem acima (que exige célula preenchida). A linha vira chave sem barra enquanto\n          // todo feature vem com barra, então ela nunca casa com nada: o município fica cinza, a\n          // contagem de municípios do setor infla, e nada denuncia — nem o aviso de \"ficaram fora\n          // do enquadramento\", porque para aquele código o município sequer existe.\n          else if(iU!=null && !uf) semUf++;\n          const m=uf?(nm+'/'+uf):nm;\n          // Grafia original guardada aqui, no único ponto em que ela ainda existe: a chave `m` já\n          // passou por norm() e perdeu acento e caixa para sempre. Serve só para exibição.\n          if(!muniLabel[m]) muniLabel[m]=String(line[iM]==null?'':line[iM]).trim();\n          const gvv=String(line[iG]==null?'':line[iG]).trim();\n          if(gvv && gvBy[s]===undefined) gvBy[s]=gvv;\n          const mkv=String(line[iK]==null?'':line[iK]).trim();\n          if(mkv && mkBySetor[s]===undefined) mkBySetor[s]=mkv;\n          (by[s]=by[s]||new Set()).add(m); n++;\n        }\n        if(!n) throw new Error('base de municípios dominantes sem linhas válidas');\n        DOM.bySetor=by;\n        DOM.muniLabel=muniLabel;\n        DOM.hasUf=(iU!=null);\n        DOM.ufCol=(iU==null?null:String(table[0][iU]||'').trim());\n        DOM.badUf=badUf;\n        DOM.semUf=semUf;\n        // setorByMuni (singular, primeiro dono) segue governando pintura/hover/tooltip/adjacência —\n        // sem mudança aí, de propósito: fatiar visualmente um município entre vários setores (como o\n        // simulador faz) é um projeto à parte no motor WebGL desta tela. setoresByMuni (plural, TODOS\n        // os donos) existe só para featuresDaUnidade não dizer \"sem território\" pra um setor real que\n        // genuinamente possui uma FATIA de um município compartilhado (Recife, São Paulo etc.) mas\n        // perdeu a corrida do \"primeiro no CSV\" — setor 3322 (pedido do usuário) é exatamente este\n        // caso: Recife/PE tem vários setores, e só o primeiro encontrado aparecia com território.\n        const revBy={},allBy={};\n        Object.entries(by).forEach(([s,ms])=>ms.forEach(m=>{ if(!revBy[m]) revBy[m]=s; (allBy[m]=allBy[m]||new Set()).add(s); }));\n        DOM.setorByMuni=revBy;\n        DOM.setoresByMuni=allBy;\n        DOM.gvBySetor=gvBy;\n        DOM.mercadoBySetor=mkBySetor;\n        const sectorsByGv={},sectorsByMercado={};\n        Object.keys(by).forEach(s=>{\n          const gv=gvBy[s], mk=mkBySetor[s];\n          if(gv) (sectorsByGv[gv]=sectorsByGv[gv]||new Set()).add(s);\n          if(mk) (sectorsByMercado[mk]=sectorsByMercado[mk]||new Set()).add(s);\n        });\n        DOM.sectorsByGv=sectorsByGv; DOM.sectorsByMercado=sectorsByMercado;\n        return by;\n      })();\n      DOM.promise.catch(e=>{ DOM.promise=null; DOM.bySetor=null; setStatus('erro ao carregar dominantes: '+e.message, true); });\n      return DOM.promise;\n    }\n\n    // ---------- de município para unidade ----------\n    // A HIERARQUIA sai do CSV de dominantes, nunca do CRM — é ela que o motor usa para decidir\n    // quais setores podem receber território. Chaves canonizadas por secKey para casarem com o\n    // índice UNITS, que vem do Databricks com a serialização dele.\n    function muniKeyOfFeature(f){\n      // Memoizado na própria feature: norm() faz normalize('NFKD') mais duas regex, e isso rodava\n      // 11 mil vezes por redesenho. Só é seguro memoizar porque os aliases de município agora\n      // carregam ANTES da base de dominantes — antes dessa correção, a mesma feature podia dar\n      // chaves diferentes dependendo de quando fosse perguntada.\n      if(f&&f.__mk!==undefined) return f.__mk;\n      // A UF vem do próprio arquivo: geoUF carimba f.__uf em cada feature no momento da carga, que\n      // é o único ponto em que ela é conhecida com certeza (o fetch é por UF; depois as features\n      // são achatadas num array só e a origem seria irrecuperável). mkey só a usa se a base também\n      // tiver UF — senão os dois lados deixariam de casar.\n      const k=mkey(f&&f.properties&&(f.properties.name||f.properties.NOME||f.properties.nome), f&&f.__uf);\n      if(f) f.__mk=k;\n      return k;\n    }\n    function unitOfMuniKey(m,level){\n      const s=DOM.setorByMuni&&DOM.setorByMuni[m];\n      if(!s) return null;\n      if(level==='setor') return secKey(s)||null;\n      if(level==='gv') return secKey((DOM.gvBySetor||{})[s])||null;\n      return txt((DOM.mercadoBySetor||{})[s])||null;\n    }\n    function unitOfFeature(f,level){ return unitOfMuniKey(muniKeyOfFeature(f),level); }\n    function unitRecord(code,level){ return (UNITS[level]||{})[code]||null; }\n    // Municípios (chaves cruas do CSV) que a base de dominantes atribui a esta unidade — usada só\n    // para diagnóstico de \"unidade sem território desenhado\": diferenciar \"a malha desta UF falhou\n    // nesta sessão\" (GEO_FAILED_UFS) de um mismatch de grafia genuíno, e nesse segundo caso apontar\n    // exatamente qual(is) município(s) a base esperava, em vez de só dizer \"pode ser grafia\".\n    function csvMunisDaUnidade(code,level,rec){\n      if(!rec) return [];\n      const raws = level==='setor' ? [rec.raw]\n        : (rec.setores||[]).map(sc=>{ const r=UNITS.setor[sc]; return r&&r.raw; }).filter(Boolean);\n      const out=new Set();\n      raws.forEach(raw=>{ (DOM.bySetor&&DOM.bySetor[raw]||new Set()).forEach(m=>out.add(m)); });\n      return [...out];\n    }\n    function csvUfsDaUnidade(code,level,rec){\n      return [...new Set(csvMunisDaUnidade(code,level,rec).map(m=>mUf(m)).filter(Boolean))];\n    }\n    // pathOf/pathFor saíram na Fase 7: a geometria vai para a GPU em buildGeo, sem string de path.\n    // Caixa envolvente projetada da feature, para o culling por enquadramento. Mora na feature e cai\n    // junto com __d, porque depende da MESMA projeção — mantida depois de um resize que muda a\n    // proporção, ela mandaria para fora da tela exatamente o que está dentro.\n    function featBBox(f,pr){\n      if(f.__bb!==undefined) return f.__bb;\n      const g=f&&f.geometry;\n      if(!g||!g.coordinates) return (f.__bb=null);\n      let x0=1/0,y0=1/0,x1=-1/0,y1=-1/0;\n      const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      ps.forEach(poly=>poly.forEach(r=>r.forEach(p=>{ const q=pr(p);\n        if(q[0]<x0)x0=q[0]; if(q[0]>x1)x1=q[0]; if(q[1]<y0)y0=q[1]; if(q[1]>y1)y1=q[1]; })));\n      return (f.__bb=isFinite(x0)?{x0:x0,y0:y0,x1:x1,y1:y1}:null);\n    }\n\n    // ---------- município sob o cursor ----------\n    // O tooltip diz o nome da CIDADE, em qualquer zoom — e o desenho tem um path por unidade, não\n    // por município, então o nome não pode vir do elemento sob o mouse. Vem de geometria: uma grade\n    // sobre o viewBox lista, por célula, as features cuja caixa a toca; o mousemove converte o cursor\n    // para unidades de viewBox, pega a célula e testa ponto-em-polígono só nos candidatos. São uma\n    // ou duas dezenas de candidatos por célula e ~115 vértices por município: microssegundos.\n    // Cai junto com PROJECT (syncMapDims): as caixas são da projeção.\n    const HIT_N=48;\n    let HIT_GRID=null;\n    function buildHitGrid(pr){\n      const cells=new Array(HIT_N*HIT_N), cw=MAP_W/HIT_N, ch=MAP_H/HIT_N;\n      ALL_FEATURES.forEach(f=>{ const b=featBBox(f,pr); if(!b) return;\n        const i0=Math.max(0,Math.floor(b.x0/cw)), i1=Math.min(HIT_N-1,Math.floor(b.x1/cw));\n        const j0=Math.max(0,Math.floor(b.y0/ch)), j1=Math.min(HIT_N-1,Math.floor(b.y1/ch));\n        for(let j=j0;j<=j1;j++) for(let i=i0;i<=i1;i++){ const k=j*HIT_N+i; (cells[k]||(cells[k]=[])).push(f); }\n      });\n      HIT_GRID={cells:cells,cw:cw,ch:ch};\n    }\n    // Par-ímpar sobre TODOS os anéis da feature, exteriores e buracos de todos os polígonos: um ponto\n    // dentro de um buraco cruza o exterior e o buraco, duas vezes, e sai como fora — que é o certo.\n    // Mesma fórmula do inside() de labelPointFor.\n    function dentroDe(f,x,y,pr){\n      const g=f&&f.geometry; if(!g||!g.coordinates) return false;\n      const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      let c=false;\n      for(const poly of ps) for(const r of poly){\n        for(let i=0,j=r.length-1;i<r.length;j=i++){\n          const a=pr(r[i]), b=pr(r[j]);\n          if(((a[1]>y)!==(b[1]>y)) && (x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])) c=!c;\n        }\n      }\n      return c;\n    }\n    // `exceto`: feature a ignorar. A sondagem de vizinho em buildSegOther precisa do que está do outro\n    // lado SEM contar a própria feature — onde duas malhas de UF se sobrepõem num sliver, o ponto está\n    // dentro das duas, e devolver a própria (primeira no array) fazia toda divisa virar borda externa.\n    function featureEm(x,y,exceto){\n      if(!PROJECT) return null;\n      if(!HIT_GRID) buildHitGrid(PROJECT);\n      const i=Math.floor(x/HIT_GRID.cw), j=Math.floor(y/HIT_GRID.ch);\n      if(i<0||j<0||i>=HIT_N||j>=HIT_N) return null;\n      const cand=HIT_GRID.cells[j*HIT_N+i]; if(!cand) return null;\n      for(let k=0;k<cand.length;k++){ const f=cand[k], b=f.__bb;\n        if(f===exceto||!b||x<b.x0||x>b.x1||y<b.y0||y>b.y1) continue;\n        if(dentroDe(f,x,y,PROJECT)) return f;\n      }\n      return null;\n    }\n    // Texto do tooltip: a CIDADE, com a UF — homônimos existem, há seis \"Bom Jesus\" — e nada de\n    // nome de setor/GV/mercado, por decisão do Fernando em 15/09: esses nomes já estão nos rótulos\n    // do mapa e no painel lateral. Para município cinza, entra o diagnóstico de base que o cinza\n    // representa: três causas diferentes que mandariam quem corrige procurar em lugares diferentes.\n    function tooltipDe(f){\n      const pp=(f&&f.properties)||{};\n      const nome=pp.name||pp.NOME||pp.nome||'';\n      const uf=(f&&f.__uf)?(', '+f.__uf):'';\n      const mk=muniKeyOfFeature(f);\n      const outerHit=unitOfMuniKey(mk,LEVEL);\n      if(outerHit){\n        // MESMA dualidade outer/inner do clique, do hover e dos rótulos — E a mesma restrição do\n        // realce de hover: com algo ARMADO, só existe tooltip de escopo DENTRO da unidade\n        // selecionada (o resto já está apagado visualmente; nomear outra unidade ali sugeria que\n        // ela também estava \"no jogo\", quando clicar ali reinicia a seleção do zero).\n        if(SELECIONADO){\n          if(SELECIONADO.outerCode!==outerHit) return null;\n          const innerLevel=INNER_LEVEL[LEVEL];\n          const innerHit=innerLevel==='municipio'?mk:unitOfMuniKey(mk,innerLevel);\n          if(!innerHit) return null;\n          if(innerLevel==='municipio') return nome+uf;\n          const rec=unitRecord(innerHit,innerLevel);\n          return (rec&&rec.nome&&rec.nome!==innerHit?rec.nome+' — ':'')+innerHit;\n        }\n        // Sem seleção: nomeia o outer inteiro (Setor/GV/Mercado — o que o toggle mostra). Sem\n        // linha no CRM, ainda sobra o mínimo que a hierarquia sempre garante — o código. Em\n        // Mercado, rec.nome === code (a base não tem nome separado de mercado), então o nome só\n        // entra quando é informação nova — e sem o rótulo \"Mercado\"/\"GV\"/\"Setor\" na frente do\n        // código, a pedido: a unidade já está identificada pelo escopo ativo no painel esquerdo.\n        const rec=unitRecord(outerHit,LEVEL);\n        return (rec&&rec.nome&&rec.nome!==outerHit?rec.nome+' — ':'')+outerHit;\n      }\n      const temSetor=!!(DOM.setorByMuni&&DOM.setorByMuni[mk]);\n      const soNome=DOM.hasUf&&DOM.setorByMuni&&DOM.setorByMuni[mNome(mk)];\n      const motivo=temSetor\n        ? ('o setor deste município está sem '+(LEVEL==='gv'?'GV':'mercado')+' na base')\n        : (soNome?'o nome existe na base, mas sob outra UF (ou sem UF) — é a UF da linha que está errada'\n                 :'município sem setor na base de dominância');\n      return nome+uf+' — '+motivo;\n    }\n\n    // ---------- adjacência entre municípios ----------\n    // Uma passada sobre a malha inteira, no boot: dois municípios que compartilham um vértice fazem\n    // fronteira. Mesmo encaixe exato (grade de ~111 m) que o simulador usa dentro de uma UF.\n    // Limitação herdada e aceita: atravessando divisa de UF, os dois lados vêm de arquivos\n    // digitalizados separadamente e quase nunca caem nos mesmos vértices, então esses pares não\n    // são detectados. O simulador contorna isso com uma segunda checagem geométrica par a par, que\n    // aqui seria O(n²) sobre 5.564 municípios — inviável. O efeito da falha é só cosmético: duas\n    // unidades de UFs diferentes podem receber a mesma cor encostadas na divisa. Nenhuma regra\n    // depende disto.\n    let MUNI_NBRS=null;\n    function buildMuniAdjacency(){\n      const idx=new Map(), nbrs={};\n      const link=(a,b)=>{ if(!a||!b||a===b) return;\n        (nbrs[a]=nbrs[a]||new Set()).add(b); (nbrs[b]=nbrs[b]||new Set()).add(a); };\n      ALL_FEATURES.forEach(f=>{\n        const k=muniKeyOfFeature(f); if(!k) return;\n        const g=f.geometry; if(!g||!g.coordinates) return;\n        const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>{\n          const vk=Math.round(p[0]*1000)+','+Math.round(p[1]*1000);\n          const prev=idx.get(vk);\n          // Guarda só o PRIMEIRO município de cada vértice. Num vértice compartilhado por três, o\n          // par entre o segundo e o terceiro escapa — irrelevante para colorir, e evita guardar\n          // uma lista por vértice numa estrutura que já é a mais pesada da tela.\n          if(prev===undefined) idx.set(vk,k); else link(prev,k);\n        })));\n      });\n      // idx sai de escopo aqui de qualquer jeito (link fecha sobre nbrs, não sobre idx), então este\n      // clear não é o que libera a memória — só antecipa. São ~400 mil entradas e algumas dezenas de\n      // MB transitórios, ao lado do GeoJSON recém-parseado; antecipar não custa nada.\n      idx.clear();\n      MUNI_NBRS=nbrs;\n      return nbrs;\n    }\n\n    // ---------- cor ----------\n    // 15/09/2026: saiu a paleta colorida por unidade — pedido do Fernando foi um estilo\n    // MONOCROMÁTICO, com a hierarquia mostrada por OPACIDADE em vez de matiz. Duas camadas: a\n    // unidade do ESCOPO atual (o nível externo — Setor, GV ou Mercado, o que o toggle mostra) recebe\n    // um tom-base por adjacência, e a unidade de UM nível abaixo dela (a \"interna\" — município\n    // dentro de setor, setor dentro de GV, GV dentro de mercado) recebe uma pequena variação em cima\n    // desse tom-base, também por adjacência — mas só contra vizinhos do MESMO pai: a fronteira entre\n    // pais já é a linha grossa (buildOutline), não precisa competir por tom.\n    // Por ADJACÊNCIA, não por hash do código, nas duas camadas: o hash distribui muitas unidades em\n    // poucos tons e vizinhos caem no mesmo tom com frequência alta — exatamente o que esta tela\n    // existe para mostrar. Duas unidades que fazem fronteira nunca recebem o mesmo tom enquanto\n    // houver tom livre no respectivo nível.\n    const MONO_COLOR='#0a5aa8';\n    const NODOM_COLOR='#e8e8ec';\n    // Cada MERCADO recebe seu PRÓPRIO matiz — nunca repetido, mesmo entre dois que não fazem\n    // fronteira. Uma paleta fixa obrigaria a reaproveitar tom após a N-ésima unidade (é o que o\n    // Welsh-Powell da opacidade faz DE PROPÓSITO ali embaixo, mas foi rejeitado aqui: \"cada\n    // mercado = sua própria cor\" é pedido explícito, então cor não pode reaproveitar). Em vez de\n    // paleta, o matiz é GERADO por índice: ângulo dourado (137,5°) multiplicado pela posição na\n    // ordem alfabética de UNITS.order.mercado. Ângulo IRRACIONAL: duas posições inteiras distintas\n    // nunca caem no mesmo grau, e a sequência cobre o círculo de forma bem espalhada sem precisar\n    // saber o total de mercados de antemão. Setor com dominância mas sem mercado na base (raro)\n    // cai no MONO_COLOR de sempre — contado em MAP_STATS.semMercadoCor, não escondido.\n    const MERC_HUE_STEP=137.50776405003785;\n    const MERC_SAT=0.60, MERC_LIT=0.50;\n    // HSL→RGB (0..1), sem depender de canvas nem de biblioteca.\n    function hslRgb(h,s,l){\n      const c=(1-Math.abs(2*l-1))*s, x=c*(1-Math.abs((h/60)%2-1)), m=l-c/2;\n      let r=0,g=0,b=0;\n      if(h<60){ r=c; g=x; } else if(h<120){ r=x; g=c; } else if(h<180){ g=c; b=x; }\n      else if(h<240){ g=x; b=c; } else if(h<300){ r=x; b=c; } else { r=c; b=x; }\n      return [r+m,g+m,b+m];\n    }\n    // Nível de baixo de cada escopo — a subdivisão que aparece \"por dentro\" da unidade do toggle.\n    // Município não é um nível do mapa (LEVEL nunca vale 'municipio'), mas é o de baixo de Setor;\n    // por isso as funções abaixo tratam 'municipio' como mais um nível, chaveado pela própria\n    // chave de município (m), não por um código agregado.\n    const INNER_LEVEL={setor:'municipio',gv:'setor',mercado:'gv'};\n    const unitAt=(m,level)=>level==='municipio'?m:unitOfMuniKey(m,level);\n    function allCodesAt(level){\n      if(level==='municipio') return Object.keys(DOM.setorByMuni||{});\n      return (UNITS.order&&UNITS.order[level])||[];\n    }\n    // Adjacência entre unidades de `level`; se `restrictLevel` vier preenchido, só conta a aresta\n    // quando os dois lados também compartilham a MESMA unidade em `restrictLevel` — é o que\n    // restringe a camada interna a só competir por tom dentro do mesmo pai.\n    function buildAdjacency(level,restrictLevel){\n      const adj={};\n      const touch=(a,b)=>{ if(!a||!b||a===b) return;\n        (adj[a]=adj[a]||new Set()).add(b); (adj[b]=adj[b]||new Set()).add(a); };\n      Object.keys(MUNI_NBRS||{}).forEach(m=>{\n        const ua=unitAt(m,level); if(!ua) return;\n        if(!adj[ua]) adj[ua]=new Set();\n        (MUNI_NBRS[m]||new Set()).forEach(o=>{\n          if(restrictLevel){\n            const pa=unitAt(m,restrictLevel), po=unitAt(o,restrictLevel);\n            if(!pa||!po||pa!==po) return;\n          }\n          touch(ua,unitAt(o,level));\n        });\n      });\n      return adj;\n    }\n    // Welsh-Powell genérico: devolve o ÍNDICE na paleta (não a cor em si), para caber tanto no tom\n    // externo quanto na variação interna com o mesmo algoritmo.\n    function welshPowellIdx(adj,codes,paletteLen){\n      codes.forEach(u=>{ if(!adj[u]) adj[u]=new Set(); });\n      const ordered=Object.keys(adj).sort((a,b)=>adj[b].size-adj[a].size);\n      const out={}; let conflitos=0;\n      ordered.forEach(u=>{\n        const used=new Set();\n        adj[u].forEach(o=>{ if(out[o]!=null) used.add(out[o]); });\n        let livre=-1;\n        for(let i=0;i<paletteLen;i++){ if(!used.has(i)){ livre=i; break; } }\n        if(livre>=0){ out[u]=livre; return; }\n        // Paleta esgotada: escolhe o índice que colide com o MENOR número de vizinhos, e conta o\n        // caso — este arquivo reporta todas as outras anomalias e esta não pode ser a única invisível.\n        conflitos++;\n        const viz=[...adj[u]];\n        let best=0,bestN=Infinity;\n        for(let i=0;i<paletteLen;i++){ const n=viz.filter(o=>out[o]===i).length; if(n<bestN){ bestN=n; best=i; } }\n        out[u]=best;\n      });\n      return {idx:out,conflitos:conflitos};\n    }\n    // Seis tons externos (do mais escuro ao mais claro) e quatro variações internas em torno do tom\n    // do pai. Combinados dão até 24 alfas distintos — poucos o bastante para continuar cabendo no\n    // mesmo truque de lotes-por-cor do motor (ver buildBatches): cada combinação é UM lote, não uma\n    // feature.\n    const OUTER_ALPHA=[0.82,0.68,0.55,0.42,0.30,0.20];\n    const INNER_DELTA=[0.13,0.045,-0.045,-0.13];\n    const OUTER_IDX_CACHE={}, OUTER_CONFLITOS={};\n    function outerIdxForLevel(level){\n      if(OUTER_IDX_CACHE[level]) return OUTER_IDX_CACHE[level];\n      const {idx,conflitos}=welshPowellIdx(buildAdjacency(level,null),allCodesAt(level),OUTER_ALPHA.length);\n      OUTER_CONFLITOS[level]=conflitos;\n      OUTER_IDX_CACHE[level]=idx;\n      return idx;\n    }\n    const INNER_IDX_CACHE={}, INNER_CONFLITOS={};\n    // Chaveado pelo ESCOPO (não pelo nível interno em si): o restrictLevel muda com o escopo.\n    function innerIdxForScope(outerLevel){\n      if(INNER_IDX_CACHE[outerLevel]) return INNER_IDX_CACHE[outerLevel];\n      const innerLevel=INNER_LEVEL[outerLevel];\n      const {idx,conflitos}=welshPowellIdx(buildAdjacency(innerLevel,outerLevel),allCodesAt(innerLevel),INNER_DELTA.length);\n      INNER_CONFLITOS[outerLevel]=conflitos;\n      INNER_IDX_CACHE[outerLevel]=idx;\n      return idx;\n    }\n    // Matiz por mercado — independe do ESCOPO ativo (LEVEL): olhando Setor, GV ou Mercado, o tom\n    // de cada município vem sempre do mercado do setor a que pertence; só a opacidade (acima)\n    // segue o toggle. Índice pela ORDEM ALFABÉTICA de UNITS.order.mercado (fixo por sessão) — não\n    // pela adjacência: aqui o objetivo é unicidade, não economia de tons.\n    let MERC_IDX_CACHE=null;\n    function mercadoIdxPorCodigo(){\n      if(MERC_IDX_CACHE) return MERC_IDX_CACHE;\n      const map={};\n      (UNITS.order.mercado||[]).forEach((c,i)=>{ map[c]=i; });\n      MERC_IDX_CACHE=map;\n      return map;\n    }\n    const MERC_RGB_CACHE=[];\n    function mercadoRgb(i){\n      if(!MERC_RGB_CACHE[i]) MERC_RGB_CACHE[i]=hslRgb((i*MERC_HUE_STEP)%360,MERC_SAT,MERC_LIT);\n      return MERC_RGB_CACHE[i];\n    }\n\n    // 20, e não 8: com o teto antigo o menor enquadramento possível tinha ~830 km de largura, e um\n    // setor típico (span alvo de 2.700 CBs, algo como 50 km de lado) ocupava 6% da tela. Buscar um\n    // setor CENTRALIZAVA sem aproximar — a busca entregava metade do que promete. Com a geometria\n    // na GPU em fidelidade total (Fase 7), o teto é só de utilidade: acima disto um setor ocupa\n    // várias telas. As linhas têm largura em pixels de dispositivo e a fonte é contra-escalada.\n    const MAP_W=700; let MAP_H=500; const MAP_ZOOM_MAX=20;\n    let mapView={x:0,y:0,w:MAP_W,h:MAP_H};\n    let PROJECT=null;\n    function project(feats){\n      let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n      const each=cb=>feats.forEach(f=>{const g=f&&f.geometry; if(!g||!g.coordinates) return; const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(p=>p.forEach(r=>r.forEach(cb)));});\n      each(p=>{mnx=Math.min(mnx,p[0]);mxx=Math.max(mxx,p[0]);mny=Math.min(mny,p[1]);mxy=Math.max(mxy,p[1]);});\n      // Sem ponto nenhum, os extremos ficam em ±Infinity e a função resultante projeta TUDO para NaN —\n      // e é truthy, então ficaria em PROJECT até um resize a derrubar: mapa em branco com resumo feliz.\n      // null obriga quem chama a decidir; todos os chamadores já testam.\n      if(!isFinite(mnx)||!isFinite(mny)||!isFinite(mxx)||!isFinite(mxy)) return null;\n      const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=MAP_W,H=MAP_H,pad=22;\n      const gw=(mxx-mnx)*kx,gh=(mxy-mny),sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n      const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n      return p=>[ox+(p[0]-mnx)*kx*sc, oy+(mxy-p[1])*sc];\n    }\n    // A simplificação por zoom (Douglas-Peucker, depois quantização em grade) saiu na Fase 7: com a\n    // geometria na GPU em fidelidade total, não há mais ponto a economizar nem fresta a evitar.\n    function labelPointFor(fs,pr){\n      // O reamostrador era POR ANEL: um anel de 115 pontos dava floor(115/120)=0, ou seja step=1\n      // e nenhuma redução. Uma unidade grande entra com tudo — um mercado tem ~107 mil pontos\n      // espalhados em milhares de anéis pequenos — e a busca em grade abaixo vira dezenas de\n      // milhões de testes de segmento, congelando a tela no primeiro clique em \"Mercado\".\n      // O ponto de inacessibilidade é uma feição GROSSEIRA: quem o determina é o contorno\n      // principal, não as ilhotas. Ficam os anéis maiores, cada um reamostrado para poucos pontos,\n      // com custo limitado independentemente do nível.\n      const MAX_ANEIS=40, MAX_PTS_ANEL=60;\n      const brutos=[];\n      (fs||[]).forEach(f=>{ const g=f&&f.geometry; if(!g||!g.coordinates) return;\n        const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(poly=>poly.forEach(r=>{ if(r&&r.length>=4) brutos.push(r); }));});\n      if(!brutos.length) return null;\n      brutos.sort((a,b)=>b.length-a.length);\n      const rings=[];\n      brutos.slice(0,MAX_ANEIS).forEach(r=>{\n        const step=Math.max(1,Math.ceil(r.length/MAX_PTS_ANEL)),pts=[];\n        for(let i=0;i<r.length;i+=step) pts.push(pr(r[i]));\n        if(pts.length>3) rings.push(pts);\n      });\n      if(!rings.length) return null;\n      let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n      rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n      // MAP_H é dinâmico (acompanha a proporção do card), então o teto fixo de 498 recortava a\n      // caixa de busca para FORA da própria unidade sempre que a janela fosse mais alta que larga:\n      // inside() falhava em todas as células e a unidade perdia o rótulo em silêncio.\n      mnx=Math.max(mnx,2); mny=Math.max(mny,2); mxx=Math.min(mxx,MAP_W-2); mxy=Math.min(mxy,MAP_H-2);\n      if(!(mxx>mnx&&mxy>mny)) return null;\n      const inside=(x,y)=>{ let c=false;\n        for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n          if(((a[1]>y)!==(b[1]>y)) && (x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])) c=!c; }\n        return c; };\n      const edgeDist=(x,y)=>{ let m=Infinity;\n        for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n          const dx=b[0]-a[0],dy=b[1]-a[1],L=dx*dx+dy*dy;\n          let t=L?(((x-a[0])*dx+(y-a[1])*dy)/L):0; t=t<0?0:(t>1?1:t);\n          const ex=a[0]+t*dx-x,ey=a[1]+t*dy-y,d=ex*ex+ey*ey; if(d<m)m=d; }\n        return Math.sqrt(m); };\n      const W=mxx-mnx,H=mxy-mny,N=22;\n      let ccx=0,ccy=0,cn=0;\n      rings.forEach(r=>r.forEach(p=>{ ccx+=p[0]; ccy+=p[1]; cn++; }));\n      ccx/=cn||1; ccy/=cn||1;\n      const CENTER_PULL=0.22;\n      const scoreAt=(x,y,d)=>d-CENTER_PULL*Math.hypot(x-ccx,y-ccy);\n      let best=null,bd=-1,bs=-Infinity;\n      for(let i=0;i<=N;i++) for(let j=0;j<=N;j++){\n        const x=mnx+W*i/N,y=mny+H*j/N;\n        if(!inside(x,y)) continue;\n        const d=edgeDist(x,y),s=scoreAt(x,y,d); if(s>bs){ bs=s; bd=d; best=[x,y]; }\n      }\n      if(!best) return null;\n      return {p:best,r:bd};\n    }\n\n    function syncMapDims(){\n      const wrap=$('.map-wrap'); if(!wrap) return;\n      const cw=wrap.clientWidth, ch=wrap.clientHeight; if(!cw||!ch) return;\n      const newH=Math.round(MAP_W*(ch/cw));\n      if(Math.abs(newH-MAP_H)<2) return;\n      const fracW=mapView.w/MAP_W, cx=(mapView.x+mapView.w/2)/MAP_W, cy=(mapView.y+mapView.h/2)/MAP_H;\n      MAP_H=newH;\n      // project() lê MAP_H para calcular escala e deslocamento vertical, mas PROJECT só é computado\n      // quando está nulo. Sem esta invalidação, um resize que muda a PROPORÇÃO do container deixa a\n      // geometria projetada para a altura ANTIGA dentro de um viewBox de altura nova — mapa\n      // deslocado e fora de escala. Os dois caches caem junto porque são chaveados só por\n      // nível+unidade, sem nenhuma referência à projeção: mantidos, poriam cada rótulo na\n      // coordenada velha.\n      PROJECT=null;\n      Object.keys(LABEL_CACHE).forEach(k=>delete LABEL_CACHE[k]);\n      // A caixa envolvente da feature é memória da projeção TAMBÉM (hit-test do tooltip).\n      ALL_FEATURES.forEach(f=>{ if(f) f.__bb=undefined; });\n      HIT_GRID=null;\n      // A geometria na GPU é da projeção também: cai junto, e o próximo drawMap a remonta.\n      glInvalidate();\n      const w=Math.min(MAP_W,Math.max(MAP_W/MAP_ZOOM_MAX,MAP_W*fracW));\n      const h=Math.min(MAP_H,Math.max(MAP_H/MAP_ZOOM_MAX,MAP_H*fracW));\n      let x=cx*MAP_W-w/2, y=cy*MAP_H-h/2;\n      x=Math.min(MAP_W-w,Math.max(0,x)); y=Math.min(MAP_H-h,Math.max(0,y));\n      mapView={x,y,w,h};\n    }\n    // Halo e espaçamento acompanham o font-size: são unidades de viewBox, e sem contra-escalar viram\n    // 80 px de halo e letras esmagadas no zoom máximo (ver o comentário em .lbl text). Num helper\n    // porque DOIS lugares precisam dele: applyMapView (zoom/arrasto, grupo existente) e drawLabels\n    // (grupo recém-criado). Só no primeiro, o grupo novo nascia sem tamanho e herdava os 13px do\n    // .simroot — que em SVG são 13 UNIDADES: letras de centenas de pixels depois de toda busca.\n    // Tamanho FIXO em pixels de tela. A versão anterior escalava com a largura do painel (8 unidades\n    // de viewBox no zoom 1 → 17,7 px num painel de 1.550 px, 9,8 px num de 860): nome de cidade a\n    // 17 px em negrito cobria o mapa. 12,5 px é o de mapa de rua.\n    const LABEL_PX=12.5;\n    // unitsPerPx = unidades de viewBox por pixel de tela AGORA (mapView.w / largura do svg em px).\n    function estilizarRotulos(gl,unitsPerPx){\n      gl.setAttribute('font-size',(LABEL_PX*unitsPerPx).toFixed(3));\n      gl.setAttribute('stroke-width',(2*unitsPerPx).toFixed(4));\n      gl.setAttribute('letter-spacing',(-0.1*unitsPerPx).toFixed(4));\n    }\n    function unitsPerPxDe(svg){\n      const r=svg.getBoundingClientRect(); return r.width?mapView.w/r.width:0;\n    }\n    function applyMapView(){\n      const svg=$('#map'); if(!svg) return;\n      svg.setAttribute('viewBox', mapView.x.toFixed(2)+' '+mapView.y.toFixed(2)+' '+mapView.w.toFixed(2)+' '+mapView.h.toFixed(2));\n      // font-size, halo e espaçamento são herdados em SVG: basta escrevê-los no GRUPO de rótulos.\n      const gl=svg.querySelector('g.lbl'), upp=unitsPerPxDe(svg);\n      if(gl&&upp) estilizarRotulos(gl,upp);\n      // É AQUI que pan e zoom viram quadro: applyMapView roda a cada rAF do arrasto e a cada passo de\n      // roda, e o desenho na GPU custa uma matriz nova e algumas dezenas de draw calls.\n      glDraw();\n    }\n    // garantirCobertura saiu na Fase 7: sem culling não há área não desenhada — a GPU recorta sozinha.\n    function zoomMapAt(factor, clientX, clientY){\n      const svg=$('#gl'); if(!svg) return;\n      const rect=svg.getBoundingClientRect(); if(!rect.width||!rect.height) return;\n      const fx=(clientX-rect.left)/rect.width, fy=(clientY-rect.top)/rect.height;\n      const wx=mapView.x+fx*mapView.w, wy=mapView.y+fy*mapView.h;\n      let w=mapView.w/factor, h=mapView.h/factor;\n      w=Math.min(MAP_W, Math.max(MAP_W/MAP_ZOOM_MAX, w));\n      h=Math.min(MAP_H, Math.max(MAP_H/MAP_ZOOM_MAX, h));\n      let x=wx-fx*w, y=wy-fy*h;\n      x=Math.min(MAP_W-w, Math.max(0,x));\n      y=Math.min(MAP_H-h, Math.max(0,y));\n      mapView={x,y,w,h};\n      applyMapView();\n      scheduleRedraw();\n    }\n    let mapDrag=null, mapDidDrag=false;\n    function mapPointerDown(e){\n      const svg=$('#gl'); if(!svg) return;\n      mapDrag={x0:e.clientX,y0:e.clientY,vx0:mapView.x,vy0:mapView.y,rect:svg.getBoundingClientRect()};\n      mapDidDrag=false;\n      svg.classList.add('dragging');\n    }\n    let PAN_RAF=0, PAN_DX=0, PAN_DY=0;\n    function mapPointerMove(e){\n      if(!mapDrag) return;\n      const dxPix=e.clientX-mapDrag.x0, dyPix=e.clientY-mapDrag.y0;\n      // mapDidDrag é lido pelo supressor de clique e precisa valer no MESMO tick do evento — só a\n      // atualização da view é adiada.\n      if(!mapDidDrag && Math.hypot(dxPix,dyPix)>4) mapDidDrag=true;\n      if(!mapDrag.rect.width||!mapDrag.rect.height) return;\n      PAN_DX=dxPix; PAN_DY=dyPix;\n      // Um mouse de alta taxa dispara ~100 eventos por segundo e o navegador pinta ~60. Sem este\n      // acoplamento ao quadro, cada evento força um viewBox novo e uma rasterização de ~11 mil\n      // paths que ninguém chega a ver.\n      if(PAN_RAF) return;\n      PAN_RAF=requestAnimationFrame(()=>{\n        PAN_RAF=0;\n        if(!mapDrag) return;\n        const sx=mapView.w/mapDrag.rect.width, sy=mapView.h/mapDrag.rect.height;\n        let x=mapDrag.vx0-PAN_DX*sx, y=mapDrag.vy0-PAN_DY*sy;\n        x=Math.min(MAP_W-mapView.w, Math.max(0,x));\n        y=Math.min(MAP_H-mapView.h, Math.max(0,y));\n        mapView={...mapView,x,y};\n        applyMapView();\n      });\n    }\n    function mapPointerUp(){\n      if(!mapDrag) return;\n      mapDrag=null;\n      const svg=$('#gl'); if(svg) svg.classList.remove('dragging');\n      // Arrastar muda o enquadramento, e o corte de rótulo por enquadramento é avaliado no desenho:\n      // sem isto, a área que entrou na tela ficaria sem rótulo até a próxima interação que\n      // redesenhasse.\n      scheduleRedraw();\n    }\n    function fitMapHeight(){\n      const wrap=$('.map-wrap'); if(!wrap) return;\n      const top=wrap.getBoundingClientRect().top;\n      if(top<=0) return;\n      const margin=24;\n      wrap.style.height=Math.max(320, window.innerHeight-top-margin)+'px';\n    }\n\n    // As camadas OSM (rodovias, rios, aeroportos) saíram daqui em 15/09/2026, junto com o ícone\n    // hambúrgueR que as abria — o lugar dele passou a ser o campo de busca. A Home é porta de\n    // entrada; rodovia e rio servem para DECIDIR um fechamento, e isso acontece na tela de\n    // trabalho, que continua com essas camadas intactas. De quebra, a Home deixou de depender do\n    // Overpass logo na entrada do app.\n    // Tudo o que foi removido — as três queries Overpass, as oito decisões que custaram iteração\n    // para descobrir (uma query só para as quatro classes viárias, só rio permanente, só aeroporto\n    // com código IATA, ordem de desenho por importância, halo só em rio, teto de área, retry com\n    // backoff, e o porquê do proxy) — está registrado em\n    // \"Home - camadas OSM removidas (contexto para recriar).md\", na pasta do projeto. Para\n    // recriar, copiar do simulador, que tem a versão mais completa e mais comentada.\n\n    const DEFAULT_SETTINGS={span:{\n      setor:{targetSpan:2700,maxSpan:3500,minSpan:2200},\n      gv:{targetSpan:16000,maxSpan:22000,minSpan:10000},\n      mercado:{targetSpan:null,maxSpan:null,minSpan:null},\n    }, forceContiguity:true};\n    const LEVEL_LABEL={setor:'Setor',gv:'GV',mercado:'Mercado'};\n\n    // openInSimulator saiu em 15/09/2026. Ela criava um registro em geo-timeline-sessions com a\n    // receita `movements:[{sector,level}]` e navegava para /?session=<id>. NÃO funcionava, e o\n    // motivo é conceitual: loadSession RESTAURA um cenário já calculado e congelado\n    // (hydrateFromFrozen copia resultado pronto e nunca recalcula), então um registro só com a\n    // receita hidratava uma etapa VAZIA — e, sem snapshotRaw, ainda abria travada. No produto,\n    // sessão é o que se SALVA no fim, não o que inicia o trabalho.\n    // O botão do rodapé do painel navega com a INTENÇÃO na URL (?fechar=setor:3504) e deixa o\n    // simulador chamar o addMovement que ele já tem. A Home não grava nada em lugar nenhum — o que\n    // também elimina a sessão órfã que a versão anterior criava a cada clique no mapa.\n    let LEVEL='mercado';\n    // {outerCode, level, code}: outerCode é a unidade do ESCOPO (LEVEL) já \"armada\" por um primeiro\n    // clique — só um segundo clique DENTRO dela seleciona o nível de baixo (INNER_LEVEL[LEVEL]).\n    // level/code é o que está de fato selecionado: o próprio outer (outerCode===code) ou o inner\n    // dentro dele. Vem tanto do clique no mapa quanto da busca — a busca sempre seleciona o outer,\n    // nunca entra direto num inner.\n    let SELECIONADO=null;\n\n    // ---------- busca ----------\n    // Exigir que o usuário saiba ONDE fica o setor 3184 no mapa do Brasil é pedir uma habilidade\n    // que nem o dono do processo tem: setor é código administrativo, não lugar. Até aqui a única\n    // forma de achar era varrer o mapa com o olho — o que só funciona para quem já sabe a resposta.\n    // Inclui o apóstrofo: hoje nenhum campo interpolado cai dentro de atributo com aspas simples,\n    // mas \"escapa quatro caracteres\" é exatamente o tipo de meia-proteção que convida a próxima\n    // linha a escrever title='…' com dado da base dentro.\n    const esc=s=>String(s==null?'':s).replace(/[&<>\"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]));\n    const SR_LABEL={setor:'Setores',gv:'GVs',mercado:'Mercados',municipio:'Municípios'};\n    // Ordem dos grupos no dropdown: o nível ATIVO vem primeiro — é o que a pessoa está olhando\n    // quando digita —, os outros dois níveis-unidade na ordem fixa de sempre, e município por\n    // último: não é um nível do mapa, é a categoria mais numerosa (~5,5 mil) e a busca por cidade\n    // sempre resolve para um SETOR (ver escolher()).\n    const SR_BASE=['setor','gv','mercado'];\n    function ordemGrupos(){\n      const i=SR_BASE.indexOf(LEVEL);\n      const base=i<0?SR_BASE:SR_BASE.slice(i).concat(SR_BASE.slice(0,i));\n      return base.concat(['municipio']);\n    }\n    // Teto POR CATEGORIA, não um teto global. A versão anterior cortava em 40 sobre a lista\n    // inteira já misturada: \"37\" casa com dezenas dos 617 códigos de setor (4 dígitos), e esses\n    // sozinhos enchiam as 40 posições antes de qualquer GV, mercado ou município ser sequer\n    // considerado — a tela se anuncia como \"buscar setor, GV, mercado ou município\" e entregava só\n    // setor. Com teto por grupo as quatro categorias sempre disputam vaga entre si, não umas com\n    // as outras.\n    const SR_MAX_POR_GRUPO=8;\n    let SR_ITENS=[], SR_IDX=-1;\n    function buscar(q){\n      const qn=norm(q);\n      if(!qn) return {};\n      const porKind={setor:[],gv:[],mercado:[],municipio:[]};\n      for(let i=0;i<SEARCH.length;i++){\n        const it=SEARCH[i];\n        const pos=it.norm.indexOf(qn);\n        if(pos<0) continue;\n        // Casar no INÍCIO vale mais que casar no meio: quem digita \"estrel\" quer ESTRELÍCIA antes\n        // de um nome que contenha essas letras no miolo.\n        porKind[it.kind].push({it:it,peso:(pos===0?0:10)+pos*0.001});\n      }\n      // NÃO existe teto de varredura aqui, e a ausência é deliberada: SEARCH está em ordem fixa\n      // (setores → GVs → mercados → ~5,5 mil municípios), então cortar a varredura por contagem\n      // estouraria o limite ainda dentro dos 617 setores em qualquer consulta curta — e NENHUM\n      // município chegaria a ser avaliado. São ~7 mil indexOf por tecla, irrelevante perto dos\n      // 11 mil nós que o mapa redesenha. O corte é DEPOIS, categoria por categoria.\n      const out={};\n      Object.keys(porKind).forEach(k=>{\n        const arr=porKind[k];\n        arr.sort((a,b)=>a.peso-b.peso||a.it.label.length-b.it.label.length);\n        out[k]=arr.slice(0,SR_MAX_POR_GRUPO).map(o=>o.it);\n      });\n      return out;\n    }\n    // Limpa os itens junto, e não só o índice: com a lista fechada mas SR_ITENS cheio, uma seta\n    // para cima passava pelo guard e calculava (-1 + n-1) % n = n-2, reabrindo a lista no\n    // penúltimo item em vez do último.\n    function fecharBusca(){ const r=$('#searchRes'); if(r) r.classList.add('hidden'); SR_ITENS=[]; SR_IDX=-1; }\n    function renderBusca(){\n      const cx=$('#searchRes'); if(!cx) return;\n      const q=(($('#searchInput')||{}).value||'').trim();\n      if(!q){ cx.innerHTML=''; cx.classList.add('hidden'); return; }\n      if(!SEARCH.length){\n        cx.innerHTML='<div class=\"sr-vazio\">o índice de busca não carregou — veja a mensagem no cabeçalho</div>';\n        cx.classList.remove('hidden'); return;\n      }\n      if(!SR_ITENS.length){\n        cx.innerHTML='<div class=\"sr-vazio\">nada encontrado para “'+esc(q)+'”</div>';\n        cx.classList.remove('hidden'); return;\n      }\n      // SR_ITENS já chega nesta ordem — grupo por grupo, na sequência de ordemGrupos() — porque\n      // abrirBusca() monta assim; aqui só detecta a virada de categoria para inserir o cabeçalho,\n      // sem reagrupar nem reordenar de novo.\n      let html='', kindAtual=null;\n      SR_ITENS.forEach((it,i)=>{\n        if(it.kind!==kindAtual){ kindAtual=it.kind; html+='<div class=\"sr-grp\">'+SR_LABEL[kindAtual]+'</div>'; }\n        html+='<div class=\"sr-item'+(i===SR_IDX?' on':'')+'\" data-i=\"'+i+'\" role=\"option\">'\n          +'<span class=\"sr-nm\">'+esc(it.label)+'</span>'\n          +'<span class=\"sr-sub\">'+esc(it.sub)+'</span></div>';\n      });\n      cx.innerHTML=html;\n      cx.classList.remove('hidden');\n      cx.querySelectorAll('.sr-item').forEach(el=>{ el.onclick=()=>escolher(SR_ITENS[+el.dataset.i]); });\n    }\n    function abrirBusca(){\n      const inp=$('#searchInput'), cl=$('#searchClear');\n      const q=inp?inp.value:'';\n      if(cl) cl.classList.toggle('hidden',!q);\n      const porKind=buscar(q);\n      SR_ITENS=[]; ordemGrupos().forEach(k=>{ (porKind[k]||[]).forEach(it=>SR_ITENS.push(it)); });\n      SR_IDX=SR_ITENS.length?0:-1;\n      renderBusca();\n    }\n    // Features de uma unidade. Varre ALL_FEATURES em vez de MUNI_NBRS porque este último só tem\n    // município com pelo menos um vizinho detectado — ilha ficaria de fora justamente do\n    // enquadramento.\n    // Verdadeiro se ALGUM dono real do município (DOM.setoresByMuni, não só o primeiro que\n    // unitOfFeature usa para pintura) resolve para `code` neste nível — sem isto, um setor cujo\n    // único município é compartilhado com outro setor \"mais cedo\" no CSV (Recife etc.) nunca\n    // encontrava suas próprias features, mesmo possuindo uma fatia real do município.\n    function unidadeContemMunicipio(f,code,level){\n      const mk=muniKeyOfFeature(f);\n      const owners=DOM.setoresByMuni&&DOM.setoresByMuni[mk];\n      if(!owners||!owners.size) return unitOfFeature(f,level)===code;\n      for(const raw of owners){\n        const sc=secKey(raw); if(!sc) continue;\n        if(level==='setor'){ if(sc===code) return true; }\n        else if(level==='gv'){ if(secKey((DOM.gvBySetor||{})[raw])===code) return true; }\n        else { if(txt((DOM.mercadoBySetor||{})[raw])===code) return true; }\n      }\n      return false;\n    }\n    function featuresDaUnidade(code,level){ return ALL_FEATURES.filter(f=>unidadeContemMunicipio(f,code,level)); }\n    // Blocos contíguos da unidade, do maior para o menor. Território de setor pode ser disperso: um\n    // município solto a centenas de quilômetros do miolo faria a caixa envolvente cobrir meio estado\n    // e o zoom resultante seria inútil. Enquadra-se o maior bloco, e o resto é AVISADO em vez de\n    // simplesmente sair de vista.\n    function bboxDe(fs,pr){\n      let x0=1/0,y0=1/0,x1=-1/0,y1=-1/0;\n      (fs||[]).forEach(f=>{ const g=f&&f.geometry; if(!g||!g.coordinates) return;\n        const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(poly=>poly.forEach(r=>r.forEach(p=>{ const q=pr(p);\n          if(q[0]<x0)x0=q[0]; if(q[0]>x1)x1=q[0]; if(q[1]<y0)y0=q[1]; if(q[1]>y1)y1=q[1]; })));});\n      return isFinite(x0)?{x0:x0,y0:y0,x1:x1,y1:y1}:null;\n    }\n    function blocosDaUnidade(fs){\n      const chaves=fs.map(f=>muniKeyOfFeature(f));\n      const dentro=new Set(chaves), porChave={};\n      // Lista por chave, e não um por chave: no modo degradado (base sem UF) os seis \"BOM JESUS\"\n      // de estados diferentes colapsam numa chave só, e guardar um deles descartava os outros\n      // cinco — o bloco saía com uma feature, o aviso de \"ficaram fora\" dizia zero, e o\n      // enquadramento ia parar no município que por acaso ficou por último no array, podendo ser\n      // o do Piauí quando o usuário buscou o setor gaúcho.\n      fs.forEach((f,i)=>{ (porChave[chaves[i]]=porChave[chaves[i]]||[]).push(f); });\n      const visto=new Set(); let blocos=[];\n      chaves.forEach(k=>{\n        if(visto.has(k)) return;\n        visto.add(k);\n        const pilha=[k], bloco=[];\n        while(pilha.length){\n          const x=pilha.pop();\n          if(porChave[x]) bloco.push.apply(bloco,porChave[x]);\n          ((MUNI_NBRS&&MUNI_NBRS[x])||new Set()).forEach(o=>{\n            if(dentro.has(o)&&!visto.has(o)){ visto.add(o); pilha.push(o); }\n          });\n        }\n        blocos.push(bloco);\n      });\n      // MUNI_NBRS não detecta vizinhança atravessando divisa de UF — os dois lados vêm de arquivos\n      // digitalizados em separado e não compartilham vértice. Isso era COSMÉTICO enquanto servia só\n      // para colorir. Aqui deixa de ser: toda GV ou mercado que encosta em mais de um estado se\n      // partiria num bloco por estado, o enquadramento mostraria uma fatia só e o status acusaria\n      // municípios \"fora\" de uma unidade perfeitamente contígua. Funde blocos cujas caixas\n      // envolventes se tocam: são poucos blocos por unidade, então o laço quadrático é trivial, e a\n      // fusão erra sempre para o lado seguro (um exclave de verdade tem caixa separada).\n      const pr=PROJECT;\n      if(pr && blocos.length>1){\n        const bbs=blocos.map(b=>bboxDe(b,pr));\n        const TOL=4;   // unidades do viewBox, ~25 km: a folga típica entre duas malhas estaduais\n        let mudou=true;\n        while(mudou){\n          mudou=false;\n          for(let i=0;i<blocos.length&&!mudou;i++){\n            for(let j=i+1;j<blocos.length&&!mudou;j++){\n              const a=bbs[i],b=bbs[j];\n              if(!a||!b) continue;\n              if(a.x1+TOL<b.x0||b.x1+TOL<a.x0||a.y1+TOL<b.y0||b.y1+TOL<a.y0) continue;\n              blocos[i]=blocos[i].concat(blocos[j]);\n              bbs[i]={x0:Math.min(a.x0,b.x0),y0:Math.min(a.y0,b.y0),x1:Math.max(a.x1,b.x1),y1:Math.max(a.y1,b.y1)};\n              blocos.splice(j,1); bbs.splice(j,1); mudou=true;\n            }\n          }\n        }\n      }\n      return blocos.sort((a,b)=>b.length-a.length);\n    }\n    function enquadrar(fs){\n      if(!fs||!fs.length) return;\n      // Garante a projeção em vez de desistir calado. syncMapDims anula PROJECT sempre que a\n      // proporção do container muda, e o handler de resize adia o redesenho em 250 ms — um Enter na\n      // busca dentro dessa janela caa num return silencioso: a seleção acontecia, o status dizia\n      // \"enquadrado o maior bloco\" e a câmera não se mexia. É a mesma linha que drawMap executa.\n      if(!PROJECT) PROJECT=project(ALL_FEATURES);\n      const pr=PROJECT; if(!pr) return;\n      const bb=bboxDe(fs,pr); if(!bb) return;\n      const x0=bb.x0,y0=bb.y0,x1=bb.x1,y1=bb.y1;\n      const folga=Math.max(x1-x0,y1-y0)*0.18+4;\n      let w=Math.max((x1-x0)+folga*2, MAP_W/MAP_ZOOM_MAX);\n      let h=Math.max((y1-y0)+folga*2, MAP_H/MAP_ZOOM_MAX);\n      // Mantém a proporção do viewBox — esticar um eixo só distorceria o mapa inteiro.\n      const asp=MAP_W/MAP_H;\n      if(w/h>asp) h=w/asp; else w=h*asp;\n      w=Math.min(w,MAP_W); h=Math.min(h,MAP_H);\n      let x=(x0+x1)/2-w/2, y=(y0+y1)/2-h/2;\n      x=Math.min(MAP_W-w,Math.max(0,x)); y=Math.min(MAP_H-h,Math.max(0,y));\n      mapView={x:x,y:y,w:w,h:h};\n      applyMapView();\n    }\n    // Texto de status de uma unidade, usado pelo clique no mapa e pela busca. A contagem de\n    // municípios vem das FEATURES desenhadas, nunca de rec.munis: esse campo só existe em setor —\n    // GV e mercado guardam munisSoma, que é a soma dos municípios dos setores e não o número de\n    // municípios distintos da unidade (uma capital é repartida entre dezenas de setores). Lendo\n    // rec.munis nesses níveis o `||0` engolia a ausência e a barra anunciava \"0 município(s)\" —\n    // logo ao lado de um aviso dizendo que 340 deles ficaram fora do enquadramento.\n    function statusDaUnidade(code,nivel,nMunis,fora){\n      const rec=unitRecord(code,nivel);\n      return LEVEL_LABEL[nivel]+' '+code+(rec&&rec.nome?' · '+rec.nome:'')\n        +(rec?' · '+fmtN(rec.cbs)+' CBs':'')\n        +(nMunis?' · '+fmtN(nMunis)+' município(s) no mapa':'')\n        +(fora?' — enquadrado o maior bloco; '+fmtN(fora)+' município(s) desta unidade ficaram fora':'');\n    }\n    // Seleção de uma unidade que JÁ está no nível ativo (clique no mapa): destaca e informa, sem\n    // reenquadrar — quem clicou já está olhando para o lugar certo, mover a câmera debaixo do\n    // cursor só desorienta.\n    // ---------- painel lateral ----------\n    const SB_KIND={setor:'Setor',gv:'Gerência de vendas',mercado:'Mercado'};\n    function ufsDe(fs){\n      const s=new Set(); (fs||[]).forEach(f=>{ if(f&&f.__uf) s.add(f.__uf); });\n      return [...s].sort();\n    }\n    // Barra da banda operacional. As faixas são as mesmas que o motor aplica ao decidir uma\n    // redistribuição (setor 2.200–2.700–3.500 CBs, GV 10.000–16.000–22.000), então o que o painel\n    // mostra aqui é exatamente o critério que a tela de trabalho vai cobrar depois — não um\n    // indicador paralelo inventado para a entrada.\n    function bandaHTML(cbs,banda){\n      if(!banda||!banda.minSpan||!banda.maxSpan) return '';\n      const lo=banda.minSpan*0.6, hi=banda.maxSpan*1.25, larg=hi-lo;\n      const pct=v=>Math.max(0,Math.min(100,(v-lo)/larg*100));\n      const abaixo=cbs<banda.minSpan, acima=cbs>banda.maxSpan;\n      const cls=abaixo?'baixo':(acima?'alto':'ok');\n      // `veredito`, e não `txt`: há um helper txt() no escopo do módulo, e declarar um const com o\n      // mesmo nome aqui o deixa em zona morta temporal da entrada da função até esta linha — a\n      // próxima linha que chamasse txt() aqui dentro estouraria sem motivo aparente.\n      const veredito=abaixo?('abaixo do mínimo em '+fmtN(banda.minSpan-cbs)+' CBs')\n        :(acima?('acima do máximo em '+fmtN(cbs-banda.maxSpan)+' CBs'):'dentro da banda');\n      const nota='mín '+fmtN(banda.minSpan)\n        +(banda.targetSpan?' · alvo '+fmtN(banda.targetSpan):'')\n        +' · máx '+fmtN(banda.maxSpan)+' — a marca escura é onde esta unidade está hoje';\n      return '<div class=\"sb-sec\">Banda operacional</div><div class=\"sb-banda\">'\n        +'<div class=\"sb-veredito '+cls+'\">'+esc(veredito)+'</div>'\n        +'<div class=\"sb-barra\">'\n        +'<div class=\"sb-faixa\" style=\"left:'+pct(banda.minSpan).toFixed(1)+'%;right:'+(100-pct(banda.maxSpan)).toFixed(1)+'%\"></div>'\n        +(banda.targetSpan?'<div class=\"sb-alvo\" style=\"left:'+pct(banda.targetSpan).toFixed(1)+'%\"></div>':'')\n        +'<div class=\"sb-marca\" style=\"left:'+pct(cbs).toFixed(1)+'%\"></div>'\n        +'</div><div class=\"sb-nota\">'+esc(nota)+'</div></div>';\n    }\n    // Escapa o VALOR também, não só o rótulo. Hoje todos os oito pontos de chamada passam fmtN() de\n    // campo numérico, então não há buraco — mas \"escapa metade\" é a meia-proteção que convida o\n    // próximo kpi(rec.nome, 'GV') a virar injeção.\n    function kpi(n,l){ return '<div class=\"sb-kpi\"><div class=\"n\">'+esc(n)+'</div><div class=\"l\">'+esc(l)+'</div></div>'; }\n    function renderSidebar(fsOpt){\n      const sb=$('#sidebar'), hd=$('#sbHead'), bd=$('#sbBody'), ft=$('#sbFoot');\n      if(!sb||!hd||!bd||!ft) return;\n      // O painel existe enquanto a seleção pertence ao escopo atual OU ao nível de baixo dele —\n      // trocar de escopo zera SELECIONADO (ver o handler do toggle), esta checagem é só para não\n      // deixar um painel órfão no meio de uma transição.\n      if(!SELECIONADO||(SELECIONADO.level!==LEVEL&&SELECIONADO.level!==INNER_LEVEL[LEVEL])){ sb.classList.add('hidden'); return; }\n      const nivel=SELECIONADO.level, code=SELECIONADO.code;\n      // Município não é uma UNIDADE com KPI (não está em UNITS) — é só o de baixo do escopo Setor.\n      // Painel mínimo: nome, UF e o setor que o atende. Sem grade de KPI e sem botão de fechamento,\n      // que não fazem sentido nesta granularidade.\n      if(nivel==='municipio'){\n        const uf=mUf(code), nome=(DOM.muniLabel&&DOM.muniLabel[code])||mNome(code);\n        // Um município pode ter território repartido entre vários setores (Recife, São Paulo\n        // etc. — ver DOM.setoresByMuni em ensureDominantes): lista TODOS os donos, não só o\n        // primeiro do CSV (DOM.setorByMuni), que é só quem pinta o mapa.\n        const setoresRaw=[...((DOM.setoresByMuni&&DOM.setoresByMuni[code])||[])].map(secKey).filter(Boolean);\n        const scPrincipal=secKey((DOM.setorByMuni||{})[code]);\n        const setores=[...new Set(setoresRaw.length?setoresRaw:(scPrincipal?[scPrincipal]:[]))]\n          .sort((a,b)=>(+a-+b)||(a<b?-1:a>b?1:0));\n        hd.innerHTML='<div class=\"sb-kind\">Município</div>'\n          +'<div class=\"sb-nome\">'+esc(nome)+'</div>'\n          +'<div class=\"sb-cod\">'+esc(uf?('UF '+uf):'')+'</div>';\n        // Cada linha é um sb-crumb clicável — mesma peça do caminho Setor/GV/Mercado mais abaixo\n        // (crumb), reescrita aqui porque este ramo retorna antes daquele const existir. Clicar\n        // chama irParaUnidade: muda o escopo para Setor e seleciona de verdade (mapa, zoom e\n        // status incluídos), não só troca o texto do painel — \"selecionar em todas as frentes\",\n        // pedido do usuário.\n        const linhaSetor=sc=>{ const r=UNITS.setor[sc];\n          const texto='<b>Setor '+esc(sc)+'</b>'+(r&&r.nome?' · '+esc(r.nome):'')+(r&&r.nomeGv?' · '+esc(r.nomeGv):'');\n          return '<span class=\"sb-crumb\" data-nivel=\"setor\" data-code=\"'+esc(sc)+'\" role=\"button\" tabindex=\"0\">'+texto+'</span>'; };\n        bd.innerHTML = setores.length>1\n          ? '<div class=\"sb-caminho\">pertence a '+setores.length+' setores:<br>'+setores.map(linhaSetor).join('<br>')+'</div>'\n          : '<div class=\"sb-caminho\">pertence ao '+linhaSetor(setores[0]||'?')+'</div>';\n        bd.querySelectorAll('.sb-crumb').forEach(el=>{\n          el.onclick=()=>irParaUnidade(el.dataset.nivel,el.dataset.code,el.textContent);\n        });\n        ft.classList.add('hidden');\n        sb.classList.remove('hidden');\n        return;\n      }\n      const rec=unitRecord(code,nivel);\n      // fsOpt vem de quem já varreu ALL_FEATURES — sem isso, cada seleção varria as 5.564 features\n      // duas vezes, uma aqui e outra no status.\n      const fs=fsOpt||featuresDaUnidade(code,nivel);\n      const ufs=ufsDe(fs);\n      hd.innerHTML='<div class=\"sb-kind\">'+SB_KIND[nivel]+'</div>'\n        +'<div class=\"sb-nome\">'+esc((rec&&rec.nome)||(SB_KIND[nivel]+' '+code))+'</div>'\n        +'<div class=\"sb-cod\">código '+esc(code)+(ufs.length?' · '+esc(ufs.join(', ')):'')+'</div>';\n      // Fechamento agora vale para os três níveis (pedido do usuário) — antes só setor oferecia\n      // o botão; GV e mercado ficam sujeitos às MESMAS duas condições abaixo (território\n      // desenhado + linha no CRM), não a uma checagem extra de nível.\n      let h='', podeFechar=true;\n      if(!fs.length){\n        // Sem território desenhado não há o que fechar: o motor redistribui municípios, e não existe\n        // nenhum aqui. Oferecer o botão faria o usuário recarregar a página inteira só para receber\n        // o erro do outro lado.\n        // Setor 3322 (pedido do usuário) mostrava esta mensagem mesmo tendo o município certinho na\n        // base e desenhado sem problema no simulador (mesma malha, mesma base) — o problema real era\n        // a malha de UMA UF não ter baixado nesta sessão específica (loadAllGeo, 27 fetches em\n        // paralelo, falha individual sempre silenciosa por desenho), não a base estar errada.\n        const munisEsperados=csvMunisDaUnidade(code,nivel,rec);\n        const ufsEsperadas=[...new Set(munisEsperados.map(m=>mUf(m)).filter(Boolean))];\n        const ufsFaltando=ufsEsperadas.filter(u=>GEO_FAILED_UFS.indexOf(u)>=0);\n        if(ufsFaltando.length){\n          h+='<div class=\"sb-nota\">A malha do IBGE de '+esc(ufsFaltando.join(', '))+' não carregou nesta '\n            +'sessão (falha de rede) — por isso nada aparece aqui, não é problema na base. Recarregue a página.</div>';\n        } else {\n          // Aponta o(s) NOME(S) exato(s) que a base espera, em vez de só dizer \"pode ser grafia\" —\n          // é exatamente o que falta para diferenciar um mismatch de grafia real (corrigível na tabela\n          // de correções de nome de município, gb9Mu-5_9AcCDK-myWwSy) de qualquer outra causa, sem\n          // precisar abrir o CSV ou o Databricks pra descobrir qual nome bateu ou não.\n          const nomes=munisEsperados.map(m=>{ const n=(DOM.muniLabel&&DOM.muniLabel[m])||mNome(m); const u=mUf(m); return n+(u?' ('+u+')':''); });\n          h+='<div class=\"sb-nota\">Esta unidade não tem nenhum município desenhado no mapa. A base atribui a ela '\n            +(nomes.length?esc(nomes.join(', ')):'nenhum município')\n            +' — nenhum desses nomes bateu com a malha do IBGE. Costuma ser grafia divergente (confira/adicione uma '\n            +'correção na tabela de nomes de município) ou UF errada na base.</div>';\n        }\n        podeFechar=false;\n      } else if(!rec){\n        // Duas causas muito diferentes cairiam na mesma frase. \"A consulta falhou\" manda recarregar;\n        // \"a base está na frente do CRM\" manda atualizar a base. Dizer a segunda quando foi a\n        // primeira custa a investigação inteira.\n        h+='<div class=\"sb-nota\">'+(UNITS.error\n          ? 'Os números não carregaram nesta sessão: '+esc(UNITS.error)\n          : 'Esta unidade está na base de dominância mas não tem linha no CRM — sem números para '\n            +'mostrar. Normalmente significa base desatualizada.')+'</div>';\n        podeFechar=false;\n      } else {\n        // Caminho completo, sempre da base de dominância — é a hierarquia que o motor usa. Mercado\n        // e GV são cliqueáveis (crumb): trocam de escopo para aquele nível e selecionam a unidade —\n        // a própria unidade atual (em negrito) não precisa, já é o que está selecionado.\n        const crumb=(nv,cd,label)=>cd?('<span class=\"sb-crumb\" data-nivel=\"'+nv+'\" data-code=\"'+esc(cd)+'\" role=\"button\" tabindex=\"0\">'+esc(label)+'</span>'):esc(label);\n        if(nivel==='setor'){\n          h+='<div class=\"sb-caminho\">'+crumb('mercado',rec.mercado,rec.mercado||'sem mercado')+' › <b>'\n            +crumb('gv',rec.gv,rec.nomeGv||('GV '+rec.gv))+'</b> › '+esc(rec.nome||code)+'</div>';\n        } else if(nivel==='gv'){\n          h+='<div class=\"sb-caminho\">'+crumb('mercado',rec.mercado,rec.mercado||'sem mercado')+' › <b>'+esc(rec.nome||code)+'</b></div>';\n        }\n        h+='<div class=\"sb-grid\">';\n        h+=kpi(fmtN(rec.cbs),'CBs');\n        h+=kpi(fmtN(rec.ativas),'ativas no ciclo');\n        if(nivel==='setor'){\n          h+=kpi(fmtN(fs.length),'municípios no mapa');\n          h+=kpi(fmtN(rec.grupos),'grupos (líderes)');\n        } else if(nivel==='gv'){\n          h+=kpi(fmtN((rec.setores||[]).length),'setores');\n          h+=kpi(fmtN(fs.length),'municípios no mapa');\n        } else {\n          h+=kpi(fmtN((rec.gvs||[]).length),'GVs');\n          h+=kpi(fmtN((rec.setores||[]).length),'setores');\n        }\n        h+='</div>';\n        h+=bandaHTML(rec.cbs,(DEFAULT_SETTINGS.span||{})[nivel]);\n        // Setor sem GV na base: dito explicitamente, porque a ausência de GV também tira a migalha\n        // de pão correspondente do breadcrumb sem nenhum outro aviso.\n        if(nivel==='setor'&&!rec.gv)\n          h+='<div class=\"sb-nota\" style=\"margin-top:12px\">⚠ Este setor está sem GV na base.</div>';\n      }\n      if(UNITS.snapshot) h+='<div class=\"sb-nota\" style=\"margin-top:14px\">números do snapshot de '+esc(fmtSnapshot(UNITS.snapshot))+'</div>';\n      bd.innerHTML=h;\n      // innerHTML apaga qualquer listener anterior — precisa reatar a cada render, igual à busca.\n      bd.querySelectorAll('.sb-crumb').forEach(el=>{\n        el.onclick=()=>irParaUnidade(el.dataset.nivel,el.dataset.code,el.textContent);\n      });\n      // Texto do botão segue o nível selecionado — \"desta GV\" concorda com GERÊNCIA (feminino),\n      // \"deste setor\"/\"deste mercado\" com o substantivo masculino de cada um.\n      if(podeFechar){\n        const sbFecharBtn=$('#sbFechar');\n        if(sbFecharBtn) sbFecharBtn.textContent='Simular fechamento '+({setor:'deste setor',gv:'desta GV',mercado:'deste mercado'}[nivel]||'');\n      }\n      // O botão só aparece quando há o que fechar de verdade (território desenhado + dado no CRM).\n      ft.classList.toggle('hidden', !podeFechar);\n      sb.classList.remove('hidden');\n    }\n    // Nome + UF do município, e o setor que o atende — usado só quando a seleção desce um nível\n    // abaixo de Setor (a própria cidade). Município não está em UNITS (não tem CB nem KPI), então\n    // não reaproveita statusDaUnidade.\n    function statusDoMunicipio(mk){\n      const nome=(DOM.muniLabel&&DOM.muniLabel[mk])||mNome(mk), uf=mUf(mk);\n      const sc=secKey((DOM.setorByMuni||{})[mk]);\n      return 'Município '+nome+(uf?' ('+uf+')':'')+' — setor '+(sc||'?');\n    }\n    // Seleciona uma unidade — o outer do escopo (outerCode===code) ou o inner dentro dele, uma vez\n    // que o outer já estava \"armado\" (ver o clique no mapa). Sem reenquadrar: quem clicou já está\n    // olhando para o lugar certo, mover a câmera debaixo do cursor só desorienta.\n    function selecionarUnidade(nivel,code,outerCode){\n      SELECIONADO={outerCode:outerCode,level:nivel,code:code};\n      HOVER=null;\n      drawMap();\n      if(nivel==='municipio'){\n        renderSidebar();\n        setStatus(statusDoMunicipio(code));\n        return;\n      }\n      const fs=featuresDaUnidade(code,nivel);\n      renderSidebar(fs);\n      setStatus(statusDaUnidade(code,nivel,fs.length,0));\n    }\n    // Muda de escopo (se preciso) e seleciona `code` como o outer daquele nível. Reaproveitado por\n    // DUAS origens: a busca (escolher) e as migalhas de pão clicáveis do painel lateral (crumb, em\n    // renderSidebar) — mesma navegação, só muda de onde o clique veio.\n    function irParaUnidade(nivel,code,label){\n      if(LEVEL!==nivel){\n        LEVEL=nivel;\n        $$('#levelToggle button').forEach(b=>b.classList.toggle('on', b.dataset.level===nivel));\n      }\n      SELECIONADO={outerCode:code,level:nivel,code:code};\n      HOVER=null;\n      const fs=featuresDaUnidade(code,nivel);\n      if(!fs.length){ drawMap(); renderSidebar(); setStatus('a unidade '+(label?'“'+label+'” ':'')+'não tem território desenhado no mapa', true); return; }\n      const blocos=blocosDaUnidade(fs);\n      // Enquadrar ANTES de desenhar: o corte de rótulo por enquadramento é avaliado no desenho, e\n      // na ordem inversa a tela sairia rotulada para o enquadramento antigo.\n      enquadrar(blocos[0]);\n      drawMap();\n      renderSidebar(fs);\n      const fora=blocos.slice(1).reduce((a,b)=>a+b.length,0);\n      setStatus(statusDaUnidade(code,nivel,fs.length,fora));\n    }\n    function escolher(it){\n      if(!it) return;\n      fecharBusca();\n      const inp=$('#searchInput'); if(inp) inp.value=it.label;\n      const cl=$('#searchClear'); if(cl) cl.classList.remove('hidden');\n      // Município seleciona o SETOR que o atende — é essa a pergunta de quem digita o nome de uma\n      // cidade. E a visão troca para o nível do que foi escolhido, senão a unidade selecionada\n      // sequer existe como unidade na tela.\n      const nivel=(it.kind==='municipio')?'setor':it.kind;\n      irParaUnidade(nivel,it.code,it.label);\n    }\n    // A partir de que zoom a fronteira INTERNA (o nível de baixo do escopo) entra. Dentro de GV ou\n    // Mercado o de baixo é pequeno (até ~600 setores, ~60 GVs) e cabe em qualquer enquadramento;\n    // dentro de Setor o de baixo é MUNICÍPIO — ~5,5 mil no país — e no zoom nacional viraria uma teia\n    // de linhas que esconde justamente o que deveria revelar.\n    const INNER_OUTLINE_ZOOM={setor:6,gv:1,mercado:1};\n    // Culling, DRAWN_AREA e o \"modo de detalhe\" por município saíram na Fase 7: o mapa inteiro vive na\n    // GPU em fidelidade total e o tooltip é geométrico (featureEm), então não há o que recortar nem\n    // modo para trocar.\n    const MAP_STATS={semDominancia:0,semHierarquia:0,semMercadoCor:0};\n    // O nível de contorno e quais rótulos cabem são decididos em drawMap, a partir do zoom. Zoom e\n    // arrasto redesenham a GPU a cada quadro (barato), mas NÃO refazem os rótulos — medir caixa de\n    // texto e anti-colisão a cada giro de roda não vale. Um único drawMap quando o gesto pára\n    // resolve, e só dispara se a chave mudou de fato.\n    let ZOOM_T=null, LAST_DRAW_KEY='';\n    function drawKeyNow(){\n      const z=MAP_W/mapView.w;\n      const thinOn=z>=(INNER_OUTLINE_ZOOM[LEVEL]||1)?1:0;\n      // Faixa logarítmica de zoom: só conta como mudança quando dobra ou cai pela metade. Sem isso,\n      // cada passo de roda pediria seu próprio redesenho.\n      return LEVEL+'|'+thinOn+'|'+Math.round(Math.log2(Math.max(z,0.01)))\n        +'|'+Math.round(mapView.x)+','+Math.round(mapView.y);\n    }\n    function scheduleRedraw(){\n      clearTimeout(ZOOM_T);\n      ZOOM_T=setTimeout(()=>{ if(drawKeyNow()!==LAST_DRAW_KEY) drawMap(); },220);\n    }\n    // Ponto de rótulo por município (chave 'm|índice'), derrubado com a projeção em syncMapDims.\n    const LABEL_CACHE={};\n    // ---------- motor WebGL ----------\n    // Fase 7 (15/09/2026). Em SVG, TODA mudança de viewBox mandava o navegador re-rasterizar o mapa\n    // inteiro em software — o perfil mediu 15,5 s de Painting em 38,7 s, e nenhuma redução de\n    // elementos ou de pontos muda QUANTAS VEZES isso acontece. Aqui a geometria sobe para a GPU uma\n    // vez, com fidelidade total (642 mil vértices é pouco para ela), e cada quadro de pan ou zoom\n    // é uma matriz nova e algumas dezenas de draw calls.\n    // PREENCHIMENTO POR PARIDADE NO STENCIL, sem triangular: cada anel vira um leque de triângulos a\n    // partir do primeiro vértice, desenhado com stencil em INVERT; onde a paridade ficou ímpar está\n    // dentro do polígono — côncavo e buraco inclusos, sem uma linha de triangulação. Depois um quad\n    // cobre a tela com a cor onde o stencil está marcado, e ZERA o que consumiu (nenhum clear entre\n    // lotes). Como unidades vizinhas não se sobrepõem, o lote é POR COR da paleta e não por unidade:\n    // até 16 lotes (8 cores × aceso/apagado) por quadro. Duas unidades da mesma cor lado a lado saem\n    // no mesmo passe, e a costura de antialiasing que exigia stroke da mesma cor em SVG não existe:\n    // com MSAA o stencil é por amostra, e cada amostra pertence a exatamente um polígono.\n    // CONTORNO POR ARESTA DE FRONTEIRA, exato. Cada segmento da malha sabe quem está do outro lado\n    // (buildSegOther): se as duas unidades diferem, é fronteira e vira linha branca POR CIMA dos\n    // preenchimentos. Some o truque de ordem contorno-antes-de-preenchimento, some o contorno de 3 px\n    // por baixo de tudo que vazava pelas frestas, e a seleção passa a contornar só a borda externa\n    // da unidade, não cada município dela.\n    // O que NÃO mudou: unidades de viewBox continuam sendo a moeda de mapView, busca, enquadramento,\n    // rótulos e hit-test. O shader só converte viewBox → clip. Os rótulos ficam num <svg> fino por\n    // cima (~50 textos), e o tooltip continua geométrico (featureEm), independente do desenho.\n    let GLX=null, GLU=null, QUAD=null;\n    // TOPO é topologia — índices de leque e de segmento, quem está do outro lado de cada aresta — e\n    // NÃO depende da projeção: sobrevive ao resize. GEO é o que depende (vértices projetados na GPU).\n    // Sem essa separação, cada resize que mudasse a proporção refazia a sondagem de vizinhos inteira\n    // (segundos com a aba congelada); com ela, refaz só 642 mil projeções (dezenas de ms).\n    let TOPO=null;\n    let GEO=null;          // vértices projetados na GPU + referências para TOPO\n    let BATCH=null;        // lotes de preenchimento do nível/seleção atuais (+ costuras de UF)\n    let SEL_GL=null;       // contorno da seleção\n    let HOVER=null;        // {level,code} da unidade que um clique selecionaria AGORA, ou null\n    let HOVER_GL=null;     // preenchimento GPU do realce de hover — muda a cada mousemove\n    let GL_ERRO=null;      // última falha do motor, para o resumo do cabeçalho não escondê-la\n    // drawMap só desenha depois que o boot terminou de carregar malha, base e CRM. Antes disso ele é\n    // alcançável — toggle de nível, botão limpar, e o ResizeObserver dispara na inscrição — e um\n    // desenho precoce envenenaria caches que nunca são invalidados: TOPO.units todo nulo, f.__mk\n    // chaveado sem UF, LEVEL_COLORS sem adjacência. O nível ficaria cinza para sempre com o resumo\n    // anunciando setores normalmente.\n    let BOOT_PRONTO=false;\n    const OUTLINE_CACHE={};\n    const hexRgb=h=>{ const n=parseInt(String(h).slice(1),16); return [((n>>16)&255)/255,((n>>8)&255)/255,(n&255)/255]; };\n    const BG_RGB=hexRgb('#f5f5f7');\n    // Tom do realce de hover: preto translúcido escurece o que já está pintado ali (fill de\n    // qualquer cor/opacidade), sem precisar saber a cor por baixo. Sutil de propósito — é uma\n    // pré-visualização de clique, não a seleção em si (essa já tem seu próprio contorno escuro).\n    const HOVER_ALPHA=0.12;\n    function glInvalidate(){\n      const g=GLX;\n      if(g){\n        try{\n          if(GEO&&GEO.vbuf) g.deleteBuffer(GEO.vbuf);\n          if(BATCH&&BATCH.ibo) g.deleteBuffer(BATCH.ibo);\n          if(BATCH&&BATCH.seams) BATCH.seams.forEach(s=>{ if(s.ibo) g.deleteBuffer(s.ibo); });\n          if(SEL_GL&&SEL_GL.ibo) g.deleteBuffer(SEL_GL.ibo);\n          if(HOVER_GL&&HOVER_GL.ibo) g.deleteBuffer(HOVER_GL.ibo);\n          if(HOVER_GL&&HOVER_GL.seamIbo) g.deleteBuffer(HOVER_GL.seamIbo);\n          Object.keys(OUTLINE_CACHE).forEach(k=>{ const o=OUTLINE_CACHE[k]; ['bright','dim','gray'].forEach(n=>{ if(o[n]&&o[n].ibo) g.deleteBuffer(o[n].ibo); }); });\n        }catch(e){ }\n      }\n      GEO=null; BATCH=null; SEL_GL=null; HOVER_GL=null;\n      Object.keys(OUTLINE_CACHE).forEach(k=>delete OUTLINE_CACHE[k]);\n    }\n    function glInit(){\n      if(GLX) return GLX;\n      const cv=$('#gl'); if(!cv) return null;\n      const opts={antialias:true,stencil:true,depth:false,alpha:false,premultipliedAlpha:false,preserveDrawingBuffer:false};\n      // Índice de 32 bits é obrigatório (1,9 milhão de índices de leque): nativo no WebGL2, extensão no 1.\n      let g=cv.getContext('webgl2',opts);\n      if(!g){ g=cv.getContext('webgl',opts); if(g&&!g.getExtension('OES_element_index_uint')) g=null; }\n      if(!g){ GL_ERRO='este navegador não tem WebGL (ou não tem índice de 32 bits)'; setStatus(GL_ERRO, true); return null; }\n      // u_ident=1 passa a_pos direto como clip space: é o quad de cobertura. u_px é um deslocamento\n      // em clip por pixel de tela, para engrossar linha redesenhando-a deslocada (gl.lineWidth>1\n      // não existe na prática).\n      const vs='attribute vec2 a_pos;uniform vec2 u_off;uniform vec2 u_scale;uniform vec2 u_px;uniform float u_ident;'\n        +'void main(){vec2 p=(a_pos-u_off)*u_scale+vec2(-1.0,1.0);gl_Position=vec4(mix(p,a_pos,u_ident)+u_px,0.0,1.0);}';\n      const fs='precision mediump float;uniform vec4 u_color;void main(){gl_FragColor=u_color;}';\n      const mk=(t,s)=>{ const sh=g.createShader(t); g.shaderSource(sh,s); g.compileShader(sh);\n        if(!g.getShaderParameter(sh,g.COMPILE_STATUS)) throw new Error('shader: '+g.getShaderInfoLog(sh)); return sh; };\n      const prog=g.createProgram();\n      g.attachShader(prog,mk(g.VERTEX_SHADER,vs)); g.attachShader(prog,mk(g.FRAGMENT_SHADER,fs)); g.linkProgram(prog);\n      if(!g.getProgramParameter(prog,g.LINK_STATUS)) throw new Error('program: '+g.getProgramInfoLog(prog));\n      g.useProgram(prog);\n      GLU={pos:g.getAttribLocation(prog,'a_pos'),off:g.getUniformLocation(prog,'u_off'),scale:g.getUniformLocation(prog,'u_scale'),\n           px:g.getUniformLocation(prog,'u_px'),ident:g.getUniformLocation(prog,'u_ident'),color:g.getUniformLocation(prog,'u_color')};\n      g.enableVertexAttribArray(GLU.pos);\n      QUAD=g.createBuffer(); g.bindBuffer(g.ARRAY_BUFFER,QUAD);\n      g.bufferData(g.ARRAY_BUFFER,new Float32Array([-1,-1, 1,-1, -1,1, 1,1]),g.STATIC_DRAW);\n      g.enable(g.BLEND); g.blendFunc(g.SRC_ALPHA,g.ONE_MINUS_SRC_ALPHA);\n      // Perda de contexto é real (GPU trocada, aba em segundo plano por muito tempo). preventDefault\n      // pede a restauração; quando ela chega, programa, buffers e lotes são remontados — TOPO (CPU)\n      // sobrevive. Registrado UMA vez por canvas.\n      if(!cv.__glHooked){\n        cv.__glHooked=true;\n        cv.addEventListener('webglcontextlost',e=>{ e.preventDefault(); GLX=null; GEO=null; BATCH=null; SEL_GL=null; HOVER_GL=null; Object.keys(OUTLINE_CACHE).forEach(k=>delete OUTLINE_CACHE[k]); });\n        cv.addEventListener('webglcontextrestored',()=>{ drawMap(); });\n      }\n      GLX=g;\n      return g;\n    }\n    function fitCanvas(){\n      const cv=$('#gl'); if(!cv) return false;\n      const dpr=window.devicePixelRatio||1, w=Math.round(cv.clientWidth*dpr), h=Math.round(cv.clientHeight*dpr);\n      if(!w||!h) return false;\n      if(cv.width!==w||cv.height!==h){ cv.width=w; cv.height=h; }\n      return true;\n    }\n    // Vértices projetados de TODOS os anéis num Float32Array; índices de leque (preenchimento) e de\n    // segmento (contorno) em Uint32Array, com o intervalo de cada feature. Uma passada só por projeção.\n    function buildGeo(pr){\n      const feats=ALL_FEATURES;\n      const ringN=r=>{ let n=r.length; if(n>1&&r[0][0]===r[n-1][0]&&r[0][1]===r[n-1][1]) n--; return n<3?0:n; };\n      const first=!TOPO;\n      let nv=0,nfan=0,nseg=0;\n      feats.forEach(f=>{ const g=f&&f.geometry; if(!g||!g.coordinates) return;\n        const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(poly=>poly.forEach(r=>{ const n=ringN(r); if(!n) return; nv+=n; nfan+=3*(n-2); nseg+=2*n; })); });\n      const pos=new Float32Array(nv*2);\n      const fan=first?new Uint32Array(nfan):null, seg=first?new Uint32Array(nseg):null;\n      const segFeat=first?new Int32Array(nseg/2):null, fanRange=first?new Int32Array(feats.length*2):null;\n      let vi=0,fi=0,si=0;\n      // A ordem de percurso é determinística, então reprojetar produz os vértices na MESMA ordem que a\n      // topologia guardada indexa — é o que permite refazer só `pos` no resize.\n      feats.forEach((f,idx)=>{\n        if(f) f.__gi=idx;\n        if(first) fanRange[idx*2]=fi;\n        const g=f&&f.geometry;\n        if(g&&g.coordinates){\n          const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n          ps.forEach(poly=>poly.forEach(r=>{\n            const n=ringN(r); if(!n) return;\n            const base=vi;\n            for(let i=0;i<n;i++){ const q=pr(r[i]); pos[vi*2]=q[0]; pos[vi*2+1]=q[1]; vi++; }\n            if(first){\n              for(let i=1;i<n-1;i++){ fan[fi++]=base; fan[fi++]=base+i; fan[fi++]=base+i+1; }\n              for(let i=0;i<n;i++){ segFeat[si>>1]=idx; seg[si++]=base+i; seg[si++]=base+((i+1)%n); }\n            }\n          }));\n        }\n        if(first) fanRange[idx*2+1]=fi-fanRange[idx*2];\n      });\n      if(first){\n        const so=buildSegOther(pos,seg,segFeat,feats,pr);\n        TOPO={fan:fan,seg:seg,segFeat:segFeat,segOther:so.other,segProbed:so.probed,fanRange:fanRange,nseg:nseg/2,units:{},unitFeats:{}};\n      }\n      const g=GLX, vbuf=g.createBuffer();\n      g.bindBuffer(g.ARRAY_BUFFER,vbuf); g.bufferData(g.ARRAY_BUFFER,pos,g.STATIC_DRAW);\n      // `pos` não fica: depois do upload só a GPU precisa dele (5 MB a menos na heap).\n      return {vbuf:vbuf,fan:TOPO.fan,seg:TOPO.seg,segFeat:TOPO.segFeat,segOther:TOPO.segOther,segProbed:TOPO.segProbed,fanRange:TOPO.fanRange,nseg:TOPO.nseg,units:TOPO.units,unitFeats:TOPO.unitFeats};\n    }\n    // Quem está do OUTRO lado de cada segmento. Primeiro por vértice compartilhado: dentro de uma UF os\n    // dois lados de uma fronteira têm os mesmos vértices (é o que buildMuniAdjacency explora), então\n    // a aresta aparece duas vezes com a mesma chave — par SIMÉTRICO, e só ele pode ser deduplicado\n    // pelo índice (a<b). O que sobra sem par — costa, fronteira do país e divisa de UF, onde os dois\n    // arquivos foram digitalizados em separado e os vértices não batem — é sondado por geometria:\n    // pontos a ~500 m, 1,5 km e 3,6 km de cada lado do meio do segmento, e featureEm diz de quem é.\n    // Sem isto, toda divisa de UF dentro de uma unidade viraria linha branca. Par por sondagem é\n    // ASSIMÉTRICO (cada lado sonda sozinho, e as polilinhas dos dois lados são diferentes), por isso\n    // fica marcado em `probed` e nunca é deduplicado: desenhar os dois lados é inócuo; descartar o\n    // lado certo não. Roda uma vez por SESSÃO (é topologia, vive em TOPO) e custa 1–3 s.\n    function buildSegOther(pos,seg,segFeat,feats,pr){\n      const nseg=segFeat.length, other=new Int32Array(nseg).fill(-1), probed=new Uint8Array(nseg);\n      const K=100, vk=i=>(Math.round(pos[i*2]*K)*400000+Math.round(pos[i*2+1]*K));\n      const map=new Map();\n      for(let s=0;s<nseg;s++){\n        const a=vk(seg[s*2]), b=vk(seg[s*2+1]), key=a<b?(a+':'+b):(b+':'+a);\n        const prev=map.get(key);\n        if(prev===undefined) map.set(key,s);\n        else if(prev>=0){ other[s]=segFeat[prev]; other[prev]=segFeat[s]; map.set(key,-1); }\n      }\n      map.clear();\n      if(!HIT_GRID) buildHitGrid(pr);\n      const EPS=0.08;\n      for(let s=0;s<nseg;s++){\n        if(other[s]!==-1) continue;\n        const i0=seg[s*2], i1=seg[s*2+1];\n        const ax=pos[i0*2],ay=pos[i0*2+1],bx=pos[i1*2],by=pos[i1*2+1];\n        const mx=(ax+bx)/2,my=(ay+by)/2; let nx=-(by-ay),ny=bx-ax; const L=Math.hypot(nx,ny); if(!L) continue;\n        nx=nx/L*EPS; ny=ny/L*EPS;\n        const self=feats[segFeat[s]];\n        // Os dois lados, EXCLUINDO a própria feature — senão, num sliver de sobreposição entre malhas\n        // de UF, o lado de fora devolvia a própria e a aresta virava borda externa. E uma segunda\n        // tentativa mais longe (3×) para vãos maiores entre as duas digitalizações.\n        let f=null;\n        // Lista explícita: a versão anterior fazia m*=3 com teto 6 e produzia 1, 3, 9 — a terceira\\n        // sonda nunca rodava.\n        const MULT=[1,3,6];\n        for(let q=0;q<MULT.length&&!f;q++){ const m=MULT[q]; f=featureEm(mx+m*nx,my+m*ny,self)||featureEm(mx-m*nx,my-m*ny,self); }\n        if(f&&f.__gi!==undefined){ other[s]=f.__gi; probed[s]=1; }\n      }\n      return {other:other,probed:probed};\n    }\n    // Unidade de cada feature no nível, por índice — e as features de cada unidade. Cacheado em TOPO\n    // (GEO só referencia) porque depende só de DOM/UNITS, que não mudam depois do boot — e drawMap\n    // só chega aqui depois do boot (BOOT_PRONTO), senão o cache nasceria todo nulo e ficaria.\n    function unitsAt(level){\n      if(GEO.units[level]) return GEO.units[level];\n      const arr=new Array(ALL_FEATURES.length), by={};\n      ALL_FEATURES.forEach((f,i)=>{ const u=unitOfFeature(f,level)||null; arr[i]=u; if(u) (by[u]=by[u]||[]).push(f); });\n      GEO.units[level]=arr; GEO.unitFeats[level]=by;\n      return arr;\n    }\n    // Setor selecionado — direto (escopo Setor) ou vindo de um \"clique de dentro\" (escopo GV/Mercado\n    // com um setor selecionado como inner). Independe de LEVEL: é sempre o setor de fato selecionado,\n    // não o outer do escopo.\n    // Unidade externa \"em foco\" — o outer já armado, independente de já ter descido para um inner.\n    // ÚNICA regra de foco agora: só a própria unidade externa selecionada fica acesa, o resto do\n    // mapa apaga — inclusive dentro do MESMO escopo (uma GV vizinha da GV selecionada não ganha\n    // tratamento especial). A versão anterior tinha uma regra extra — setor selecionado iluminava a\n    // GV INTEIRA, pensada para mostrar destinos elegíveis de redistribuição — mas isso confundia\n    // com \"selecionei X e o mapa realça Y\": reportado como bug, removida.\n    function outerSelecionado(){\n      return SELECIONADO?SELECIONADO.outerCode:null;\n    }\n    // Lotes de preenchimento: por classe de tom (externo×interno) × aceso/apagado. Refeitos só\n    // quando escopo ou seleção mudam.\n    function buildBatches(){\n      const g=GLX, outerSel=outerSelecionado();\n      const key=LEVEL+'|'+(outerSel||'');\n      if(BATCH&&BATCH.key===key) return BATCH;\n      const outerIdx=outerIdxForLevel(LEVEL), innerIdx=innerIdxForScope(LEVEL);\n      const innerLevel=INNER_LEVEL[LEVEL], innerArr=innerLevel==='municipio'?null:unitsAt(innerLevel);\n      const outerUnits=unitsAt(LEVEL), monoRgb=hexRgb(MONO_COLOR), mercIdx=mercadoIdxPorCodigo();\n      const groups=new Map(), cls=new Array(ALL_FEATURES.length);\n      let nodomTodos=0, semMercCor=0; MAP_STATS.semHierarquia=0;\n      ALL_FEATURES.forEach((f,i)=>{\n        const uOuter=outerUnits[i]; let k,rgbaBase,dim;\n        if(!uOuter){\n          // Contado SEMPRE: o número no cabeçalho é diagnóstico da BASE. Duas causas distintas caem\n          // no mesmo cinza — município sem setor na base, e setor que existe mas está sem GV/mercado.\n          nodomTodos++;\n          if(DOM.setorByMuni&&DOM.setorByMuni[muniKeyOfFeature(f)]) MAP_STATS.semHierarquia++;\n          // Com qualquer foco ativo, o resto do país apaga — sem-dominância entra nessa regra\n          // também, não fica \"sempre aceso\" enquanto tudo ao redor apaga.\n          dim=!!outerSel;\n          k='gray'+(dim?'d':'b'); rgbaBase=[...hexRgb(NODOM_COLOR),dim?0.26:1];\n        } else {\n          const oi=outerIdx[uOuter]||0;\n          // Variação de tom pelo nível de baixo (INNER_DELTA) só entra DENTRO da unidade ARMADA —\n          // mesma regra do contorno fino (buildOutline): sem seleção, ou fora da unidade\n          // selecionada, o preenchimento fica CHAPADO, sem denunciar a subdivisão por textura de\n          // opacidade antes de ela ser pedida (bug reportado: \"GVs ainda aparecem, só a linha saiu\").\n          // Pertence ao outer selecionado tanto pela posse PRINCIPAL (uOuter) quanto pela\n          // SECUNDÁRIA (DOM.setoresByMuni, via unidadeContemMunicipio): sem o segundo teste, um\n          // setor cujo único território é um município compartilhado com outro setor \"mais cedo\"\n          // no CSV (Recife, setor 3322 etc.) nunca bate uOuter===outerSel — nenhuma feature acende\n          // e o mapa inteiro apaga como se nada estivesse selecionado (bug reportado).\n          const armadoAqui=!!outerSel&&(uOuter===outerSel||unidadeContemMunicipio(f,outerSel,LEVEL));\n          let ii=0;\n          if(armadoAqui){\n            const uInner=innerLevel==='municipio'?muniKeyOfFeature(f):innerArr[i];\n            ii=(uInner!=null&&innerIdx[uInner]!=null)?innerIdx[uInner]:0;\n          }\n          const alpha=Math.max(0.08,Math.min(0.92,OUTER_ALPHA[oi]+INNER_DELTA[ii]));\n          // Só a própria unidade externa selecionada fica acesa — todo o resto apaga, mesmo outras\n          // unidades do mesmo tipo (outra GV, outro setor).\n          dim=!!outerSel&&!armadoAqui;\n          // O MATIZ vem do MERCADO da feature, sempre — independe de LEVEL ser Setor, GV ou Mercado\n          // (quando LEVEL já é 'mercado', uOuter É o mercado, e esta busca devolve o mesmo código).\n          // Setor com dominância mas sem mercado na base cai no azul de MONO_COLOR, contado à parte\n          // em vez de escondido atrás de qualquer matiz da paleta.\n          const merc=unitOfFeature(f,'mercado');\n          const mi=(merc!=null&&mercIdx[merc]!=null)?mercIdx[merc]:-1;\n          const rgb=mi>=0?mercadoRgb(mi):monoRgb;\n          if(mi<0) semMercCor++;\n          // Apagado é um alfa PLANO, e não uma fração do alfa próprio: \"de-enfatizado\" precisa ler\n          // igual em toda a área apagada, independente de a unidade por baixo ser clara ou escura.\n          // O índice de cor entra na chave do lote: dois setores no mesmo tom de opacidade (oi/ii)\n          // mas de mercados diferentes não podem cair no mesmo lote — um pintaria por cima do outro\n          // com a cor errada.\n          k=mi+'_'+oi+'_'+ii+(dim?'d':'b');\n          rgbaBase=[rgb[0],rgb[1],rgb[2],dim?0.14:alpha];\n        }\n        cls[i]=k;\n        let gr=groups.get(k);\n        if(!gr) groups.set(k,gr={rgba:rgbaBase,ranges:[],total:0});\n        const a=GEO.fanRange[i*2], n=GEO.fanRange[i*2+1];\n        if(n){ gr.ranges.push(a,n); gr.total+=n; }\n      });\n      MAP_STATS.semDominancia=nodomTodos;\n      MAP_STATS.semMercadoCor=semMercCor;\n      let total=0; groups.forEach(gr=>{ total+=gr.total; });\n      const idx=new Uint32Array(total), list=[]; let off=0;\n      groups.forEach(gr=>{\n        const start=off;\n        for(let j=0;j<gr.ranges.length;j+=2){ idx.set(GEO.fan.subarray(gr.ranges[j],gr.ranges[j]+gr.ranges[j+1]),off); off+=gr.ranges[j+1]; }\n        list.push({rgba:gr.rgba,start:start,count:off-start});\n      });\n      if(BATCH&&BATCH.ibo) g.deleteBuffer(BATCH.ibo);\n      if(BATCH&&BATCH.seams) BATCH.seams.forEach(s=>{ if(s.ibo) g.deleteBuffer(s.ibo); });\n      const ibo=g.createBuffer(); g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,ibo); g.bufferData(g.ELEMENT_ARRAY_BUFFER,idx,g.STATIC_DRAW);\n      // COSTURA DE UF: aresta cujos dois lados caem na MESMA classe de tom (cls) mas vêm de arquivos\n      // de UF diferentes. Ali as duas digitalizações não coincidem — ora deixam um vão (o fundo\n      // aparece como fio claro), ora se sobrepõem num sliver que a paridade do stencil CANCELA\n      // (buraco da cor de fundo). Uma linha na cor do preenchimento por cima dessas arestas faz o\n      // papel que o stroke-da-mesma-cor fazia em SVG. São poucas (só divisas de UF).\n      const seamIdx=new Map();\n      for(let s=0;s<GEO.nseg;s++){\n        const a=GEO.segFeat[s], b=GEO.segOther[s];\n        if(b<0||(a>b&&!GEO.segProbed[s])) continue;\n        if(!cls[a]||cls[a]!==cls[b]) continue;\n        const fa=ALL_FEATURES[a], fb=ALL_FEATURES[b];\n        if(!fa||!fb||fa.__uf===fb.__uf) continue;\n        const k=cls[a]; let arr=seamIdx.get(k); if(!arr){ arr=[]; seamIdx.set(k,arr); }\n        arr.push(GEO.seg[s*2],GEO.seg[s*2+1]);\n      }\n      const seams=[];\n      seamIdx.forEach((arr,k)=>{\n        const sb=g.createBuffer(); g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,sb);\n        g.bufferData(g.ELEMENT_ARRAY_BUFFER,new Uint32Array(arr),g.STATIC_DRAW);\n        // Costura apagada sai OPACA na cor já misturada com o fundo: uma linha com alpha baixo por\n        // cima de um preenchimento igualmente baixo escurecia a divisa (dois blends), justamente\n        // onde deveria sumir.\n        const r=groups.get(k).rgba, al=r[3];\n        const rgba=al>=1?r:[r[0]*al+BG_RGB[0]*(1-al), r[1]*al+BG_RGB[1]*(1-al), r[2]*al+BG_RGB[2]*(1-al), 1];\n        seams.push({rgba:rgba,ibo:sb,count:arr.length});\n      });\n      BATCH={key:key,ibo:ibo,list:list,outerSel:outerSel,seams:seams};\n      return BATCH;\n    }\n    // Arestas de fronteira, em DUAS camadas agora: GROSSA entre unidades do ESCOPO atual (LEVEL —\n    // Setor, GV ou Mercado, o \"outer\") e FINA entre unidades do nível de baixo (INNER_LEVEL[LEVEL])\n    // dentro do MESMO outer — é o traço que revela \"por dentro\" a subdivisão que o pedido do\n    // Fernando descreveu (GV por fora, setor por dentro; setor por fora, município por dentro).\n    // A fronteira ENTRE outers diferentes nunca desce para fina: mesmo que o inner também mude ali,\n    // a grossa já conta a história inteira daquele ponto. Cinza-cinza continua à parte (malha dos\n    // sem dominância, traço leve). Aresta compartilhada aparece duas vezes na malha; só entra pela\n    // feature de índice menor.\n    function buildOutline(outerSel){\n      const key=LEVEL+'|'+(outerSel||''); if(OUTLINE_CACHE[key]) return OUTLINE_CACHE[key];\n      const g=GLX, outerUnits=unitsAt(LEVEL), innerLevel=INNER_LEVEL[LEVEL];\n      const innerArr=innerLevel==='municipio'?null:unitsAt(innerLevel);\n      const innerOf=i=>innerArr?innerArr[i]:muniKeyOfFeature(ALL_FEATURES[i]);\n      // Mesma regra do buildBatches — inclusive a mesma lacuna: sem o teste por posse SECUNDÁRIA\n      // (DOM.setoresByMuni), a borda grossa do escopo nunca acende ao redor de um setor cujo único\n      // território é um município compartilhado (Recife, 3322 etc.).\n      const dimOf=i=>!!outerSel&&!(outerUnits[i]===outerSel||unidadeContemMunicipio(ALL_FEATURES[i],outerSel,LEVEL));\n      const n=GEO.nseg, boldBright=[], boldDim=[], thin=[], gray=[];\n      for(let s=0;s<n;s++){\n        const a=GEO.segFeat[s], b=GEO.segOther[s];\n        if(b>=0&&a>b&&!GEO.segProbed[s]) continue;\n        const oa=outerUnits[a], ob=b<0?null:outerUnits[b];\n        if(!oa&&!ob){ gray.push(GEO.seg[s*2],GEO.seg[s*2+1]); continue; }\n        if(oa!==ob){\n          const dest=(dimOf(a)&&(b<0||dimOf(b)))?boldDim:boldBright;\n          dest.push(GEO.seg[s*2],GEO.seg[s*2+1]);\n          continue;\n        }\n        // Pedido do usuário: o sub-escopo (GV dentro de Mercado, Setor dentro de GV, município\n        // dentro de Setor) só aparece depois de uma unidade ser ARMADA, e só por DENTRO dela —\n        // sem seleção, ou fora da unidade selecionada, a subdivisão fica escondida (antes disto\n        // ela entrava sempre que o zoom passava de INNER_OUTLINE_ZOOM, mesmo sem nada selecionado).\n        if(!outerSel||oa!==outerSel) continue;\n        const ia=innerOf(a), ib=b<0?null:innerOf(b);\n        if(ia===ib) continue;\n        thin.push(GEO.seg[s*2],GEO.seg[s*2+1]);\n      }\n      const up=arr=>{ if(!arr.length) return {ibo:null,count:0}; const ibo=g.createBuffer(); g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,ibo);\n        g.bufferData(g.ELEMENT_ARRAY_BUFFER,new Uint32Array(arr),g.STATIC_DRAW); return {ibo:ibo,count:arr.length}; };\n      OUTLINE_CACHE[key]={boldBright:up(boldBright),boldDim:up(boldDim),thin:up(thin),gray:up(gray)};\n      return OUTLINE_CACHE[key];\n    }\n    // Contorno da seleção: só a borda EXTERNA da unidade — arestas em que exatamente um dos lados é\n    // ela. `nivel` é o que está de fato selecionado (SELECIONADO.level), que agora tanto pode ser o\n    // outer do escopo quanto o inner dentro dele — a mesma checagem serve para os dois, e para\n    // município (chaveado pela própria chave, não por um array unitsAt).\n    function buildSel(){\n      const g=GLX, sel=SELECIONADO, nivel=sel?sel.level:null, code=sel?String(sel.code):null;\n      const key=(nivel||'')+'|'+(code||'');\n      if(SEL_GL&&SEL_GL.key===key) return;\n      if(SEL_GL&&SEL_GL.ibo) g.deleteBuffer(SEL_GL.ibo);\n      SEL_GL={key:key,ibo:null,count:0};\n      if(!code) return;\n      const arr2=nivel==='municipio'?null:unitsAt(nivel);\n      const codeOf=i=>arr2?arr2[i]:muniKeyOfFeature(ALL_FEATURES[i]);\n      // Mesma lacuna do buildBatches: codeOf só enxerga a posse PRINCIPAL. Sem o teste por\n      // DOM.setoresByMuni, o contorno de seleção de um setor cujo único município é compartilhado\n      // (Recife, 3322 etc.) nunca fecha um segmento sequer — SEL_GL fica vazio e a unidade some do\n      // mapa sem contorno nenhum, mesmo com a sidebar já mostrando os dados certos.\n      const pertence=i=>codeOf(i)===code||(!!arr2&&unidadeContemMunicipio(ALL_FEATURES[i],code,nivel));\n      const idx=[];\n      for(let s=0;s<GEO.nseg;s++){\n        const a=GEO.segFeat[s], b=GEO.segOther[s];\n        if(b>=0&&a>b&&!GEO.segProbed[s]) continue;\n        const ia=pertence(a), ib=(b>=0&&pertence(b));\n        if(ia===ib) continue;\n        idx.push(GEO.seg[s*2],GEO.seg[s*2+1]);\n      }\n      if(!idx.length) return;\n      const arr=new Uint32Array(idx), ibo=g.createBuffer();\n      g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,ibo); g.bufferData(g.ELEMENT_ARRAY_BUFFER,arr,g.STATIC_DRAW);\n      SEL_GL={key:key,ibo:ibo,count:arr.length};\n    }\n    // Realce de hover — Fase 10 (pedido do usuário): as duas tentativas anteriores tentavam TAPAR\n    // a costura de UF com uma camada translúcida ou uma linha extra, e a costura continuava\n    // aparecendo (relatado duas vezes). O problema é a transparência em si: pra uma camada\n    // translúcida ficar perfeita ela precisa cobrir o recorte EXATO do hover, pixel a pixel — e\n    // é exatamente isso que a costura entre arquivos de UF impede. A saída é não depender de\n    // cobertura pixel-perfeita: em vez de uma camada translúcida por cima, calcula a cor que a\n    // unidade JÁ mostra (mesma fórmula de buildBatches) e escurece por MULTIPLICAÇÃO — um filtro\n    // de brilho, não alfa — pintando essa cor final OPACA. Opaco repintado não acumula (mesma\n    // técnica das costuras do preenchimento principal), então cobrir a costura com folga não tem\n    // risco de escurecer ela mais que o resto.\n    const HOVER_DARKEN=0.82;\n    function corAtualDaUnidade(nivel,code){\n      const arr=nivel==='municipio'?null:unitsAt(nivel);\n      const codeOf=i=>arr?arr[i]:muniKeyOfFeature(ALL_FEATURES[i]);\n      let fi=-1;\n      for(let i=0;i<ALL_FEATURES.length;i++){ if(codeOf(i)===code){ fi=i; break; } }\n      if(fi<0) return null;\n      const f=ALL_FEATURES[fi];\n      const outerUnits=unitsAt(LEVEL), uOuter=outerUnits[fi];\n      if(!uOuter) return null;\n      const oi=(outerIdxForLevel(LEVEL)[uOuter])||0;\n      const innerLevel=INNER_LEVEL[LEVEL], innerArr=innerLevel==='municipio'?null:unitsAt(innerLevel);\n      const armadoAqui=!!(outerSelecionado()&&uOuter===outerSelecionado());\n      let ii=0;\n      if(armadoAqui){\n        const innerIdx=innerIdxForScope(LEVEL);\n        const uInner=innerLevel==='municipio'?muniKeyOfFeature(f):innerArr[fi];\n        ii=(uInner!=null&&innerIdx[uInner]!=null)?innerIdx[uInner]:0;\n      }\n      const alpha=Math.max(0.08,Math.min(0.92,OUTER_ALPHA[oi]+INNER_DELTA[ii]));\n      const merc=unitOfFeature(f,'mercado'), mercIdx=mercadoIdxPorCodigo();\n      const mi=(merc!=null&&mercIdx[merc]!=null)?mercIdx[merc]:-1;\n      const rgb=mi>=0?mercadoRgb(mi):hexRgb(MONO_COLOR);\n      return [rgb[0],rgb[1],rgb[2],alpha];\n    }\n    function buildHoverGL(){\n      const g=GLX, hv=HOVER, nivel=hv?hv.level:null, code=hv?String(hv.code):null;\n      const key=(nivel||'')+'|'+(code||'');\n      if(HOVER_GL&&HOVER_GL.key===key) return;\n      if(HOVER_GL&&HOVER_GL.ibo) g.deleteBuffer(HOVER_GL.ibo);\n      if(HOVER_GL&&HOVER_GL.seamIbo) g.deleteBuffer(HOVER_GL.seamIbo);\n      HOVER_GL={key:key,ibo:null,count:0,seamIbo:null,seamCount:0,rgba:[0,0,0,1]};\n      if(!code) return;\n      const arr2=nivel==='municipio'?null:unitsAt(nivel);\n      const codeOf=i=>arr2?arr2[i]:muniKeyOfFeature(ALL_FEATURES[i]);\n      let total=0;\n      for(let i=0;i<ALL_FEATURES.length;i++){ if(codeOf(i)===code) total+=GEO.fanRange[i*2+1]; }\n      if(!total) return;\n      const idxArr=new Uint32Array(total); let off=0;\n      for(let i=0;i<ALL_FEATURES.length;i++){\n        if(codeOf(i)!==code) continue;\n        const a=GEO.fanRange[i*2], n=GEO.fanRange[i*2+1];\n        if(n){ idxArr.set(GEO.fan.subarray(a,a+n),off); off+=n; }\n      }\n      const ibo=g.createBuffer();\n      g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,ibo); g.bufferData(g.ELEMENT_ARRAY_BUFFER,idxArr,g.STATIC_DRAW);\n      // Mesma costura de UF de sempre, agora só como GEOMETRIA (a cor sai da mesma amostra abaixo).\n      const seamArr=[];\n      for(let s=0;s<GEO.nseg;s++){\n        const a=GEO.segFeat[s], b=GEO.segOther[s];\n        if(b<0||(a>b&&!GEO.segProbed[s])) continue;\n        if(codeOf(a)!==code||codeOf(b)!==code) continue;\n        const fa=ALL_FEATURES[a], fb=ALL_FEATURES[b];\n        if(!fa||!fb||fa.__uf===fb.__uf) continue;\n        seamArr.push(GEO.seg[s*2],GEO.seg[s*2+1]);\n      }\n      let seamIbo=null, seamCount=0;\n      if(seamArr.length){\n        seamIbo=g.createBuffer(); g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,seamIbo);\n        g.bufferData(g.ELEMENT_ARRAY_BUFFER,new Uint32Array(seamArr),g.STATIC_DRAW);\n        seamCount=seamArr.length;\n      }\n      // Amostra QUALQUER feature do hover: mi/oi/ii são constantes dentro de uma mesma unidade\n      // (mesmo mercado, mesmo tom), então a primeira que achar já dá a cor certa pra todas.\n      const base=corAtualDaUnidade(nivel,code);\n      let rgba=[0,0,0,1];\n      if(base){\n        const bg=BG_RGB, [r,gg,b,a]=base;\n        const or_=a>=1?r:r*a+bg[0]*(1-a), og=a>=1?gg:gg*a+bg[1]*(1-a), ob=a>=1?b:b*a+bg[2]*(1-a);\n        rgba=[or_*HOVER_DARKEN,og*HOVER_DARKEN,ob*HOVER_DARKEN,1];\n      }\n      HOVER_GL={key:key,ibo:ibo,count:idxArr.length,seamIbo:seamIbo,seamCount:seamCount,rgba:rgba};\n    }\n    function glDraw(){\n      const g=GLX; if(!g||!GEO||!BATCH||!GLU) return;\n      if(!fitCanvas()) return;\n      const cv=g.canvas;\n      g.viewport(0,0,cv.width,cv.height);\n      g.clearColor(BG_RGB[0],BG_RGB[1],BG_RGB[2],1); g.clearStencil(0);\n      g.clear(g.COLOR_BUFFER_BIT|g.STENCIL_BUFFER_BIT);\n      g.uniform2f(GLU.off,mapView.x,mapView.y);\n      g.uniform2f(GLU.scale,2/mapView.w,-2/mapView.h);\n      g.uniform2f(GLU.px,0,0);\n      const useGeo=()=>{ g.uniform1f(GLU.ident,0); g.bindBuffer(g.ARRAY_BUFFER,GEO.vbuf); g.vertexAttribPointer(GLU.pos,2,g.FLOAT,false,0,0); };\n      // Preenchimentos: paridade no stencil, cobertura por cor, e a cobertura zera o que consumiu.\n      g.enable(g.STENCIL_TEST);\n      for(let i=0;i<BATCH.list.length;i++){\n        const b=BATCH.list[i];\n        useGeo();\n        g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,BATCH.ibo);\n        g.colorMask(false,false,false,false);\n        g.stencilFunc(g.ALWAYS,0,0xFF); g.stencilOp(g.KEEP,g.KEEP,g.INVERT); g.stencilMask(0xFF);\n        g.drawElements(g.TRIANGLES,b.count,g.UNSIGNED_INT,b.start*4);\n        g.colorMask(true,true,true,true);\n        g.stencilFunc(g.EQUAL,1,0x01); g.stencilOp(g.KEEP,g.KEEP,g.ZERO);\n        g.uniform4f(GLU.color,b.rgba[0],b.rgba[1],b.rgba[2],b.rgba[3]);\n        g.uniform1f(GLU.ident,1); g.bindBuffer(g.ARRAY_BUFFER,QUAD); g.vertexAttribPointer(GLU.pos,2,g.FLOAT,false,0,0);\n        g.drawArrays(g.TRIANGLE_STRIP,0,4);\n      }\n      g.disable(g.STENCIL_TEST);\n      // Linhas por cima. gl.LINES tem 1 px de dispositivo e gl.lineWidth>1 não existe na prática, então\n      // a linha é redesenhada em deslocamentos SIMÉTRICOS (±0,5 px em DPR 1; -1/0/+1 em DPR 2) para\n      // engrossar sem escorregar para um lado. Só cor OPACA engrossa assim: cópias com alpha se\n      // acumulam onde caem no mesmo pixel (numa linha horizontal, todas as cópias com o mesmo dy), e\n      // 0,26 virava 0,45 — e dependia da orientação. Linha apagada sai em uma cópia só.\n      useGeo();\n      const ol=buildOutline(BATCH.outerSel);\n      // Traço GROSSO afinado a pedido: não escala mais com o devicePixelRatio (numa tela retina\n      // isso dava 2-3 px físicos — grosso demais). ±0,6 → ±0,42 (-30%) → ±0,25 (-40% de novo).\n      const offs=[-0.25,0.25];\n      const lines=(o,rgba,grossa)=>{\n        if(!o||!o.count) return;\n        g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,o.ibo);\n        g.uniform4f(GLU.color,rgba[0],rgba[1],rgba[2],rgba[3]);\n        const os=grossa?offs:[0];\n        for(let a=0;a<os.length;a++) for(let b=0;b<os.length;b++){\n          g.uniform2f(GLU.px,os[a]*2/cv.width,-os[b]*2/cv.height);\n          g.drawElements(g.LINES,o.count,g.UNSIGNED_INT,0);\n        }\n        g.uniform2f(GLU.px,0,0);\n      };\n      const emFoco=BATCH.outerSel;\n      // Costuras de UF primeiro, na cor do preenchimento: tapam vão e sliver entre malhas estaduais\n      // dentro da mesma unidade, ANTES do contorno branco passar por cima do que é fronteira de fato.\n      if(BATCH.seams) BATCH.seams.forEach(s=>lines(s,s.rgba,true));\n      // Cinza apaga junto quando há foco: todo sem-dominância é \"dim\" nesse estado (buildBatches).\n      lines(ol.gray,emFoco?[0.84,0.84,0.86,0.26]:[0.84,0.84,0.86,1],false);\n      // Interna (nível de baixo do escopo) é sempre um traço FINO, na mesma opacidade esteja a área\n      // apagada ou não. Já vem vazia de buildOutline quando nada está selecionado (ou fora da\n      // unidade selecionada) — a chamada aqui é incondicional de propósito.\n      lines(ol.thin,[1,1,1,0.55],false);\n      // Externa (o escopo — Setor/GV/Mercado) é sempre GROSSA: é ela que \"segmenta o mapa\" pelo\n      // escopo, como pedido — apagada quando fora da GV elegível, cheia quando dentro.\n      lines(ol.boldDim,[1,1,1,0.32],true);\n      lines(ol.boldBright,[1,1,1,1],true);\n      // Hover só AGORA, depois de toda linha — e é essa ordem, não o remendo em si, que faltava\n      // nas três tentativas anteriores: a costura de UF do PREENCHIMENTO NORMAL (BATCH.seams,\n      // logo acima) é redesenhada na cor CLARA de sempre por cima de tudo que veio antes dela —\n      // inclusive de um hover pintado antes dela, como nas tentativas anteriores. O problema\n      // nunca foi a costura DO HOVER: era essa aqui, sempre presente, sendo repintada depois que\n      // o hover já tinha escurecido aquele pixel. Escurecendo por ÚLTIMO (ainda antes do contorno\n      // de seleção, que deve continuar nítido por cima de tudo), o hover cobre essa linha também.\n      if(HOVER_GL&&HOVER_GL.count){\n        useGeo();\n        g.enable(g.STENCIL_TEST);\n        g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,HOVER_GL.ibo);\n        g.colorMask(false,false,false,false);\n        g.stencilFunc(g.ALWAYS,0,0xFF); g.stencilOp(g.KEEP,g.KEEP,g.INVERT); g.stencilMask(0xFF);\n        g.drawElements(g.TRIANGLES,HOVER_GL.count,g.UNSIGNED_INT,0);\n        g.colorMask(true,true,true,true);\n        g.stencilFunc(g.EQUAL,1,0x01); g.stencilOp(g.KEEP,g.KEEP,g.ZERO);\n        g.uniform4f(GLU.color,HOVER_GL.rgba[0],HOVER_GL.rgba[1],HOVER_GL.rgba[2],1);\n        g.uniform1f(GLU.ident,1); g.bindBuffer(g.ARRAY_BUFFER,QUAD); g.vertexAttribPointer(GLU.pos,2,g.FLOAT,false,0,0);\n        g.drawArrays(g.TRIANGLE_STRIP,0,4);\n        g.disable(g.STENCIL_TEST);\n        if(HOVER_GL.seamCount){\n          useGeo();\n          g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,HOVER_GL.seamIbo);\n          g.uniform4f(GLU.color,HOVER_GL.rgba[0],HOVER_GL.rgba[1],HOVER_GL.rgba[2],1);\n          const seamOffs=[-0.75,0,0.75];\n          for(let sa=0;sa<seamOffs.length;sa++) for(let sb=0;sb<seamOffs.length;sb++){\n            g.uniform2f(GLU.px,seamOffs[sa]*2/cv.width,-seamOffs[sb]*2/cv.height);\n            g.drawElements(g.LINES,HOVER_GL.seamCount,g.UNSIGNED_INT,0);\n          }\n          g.uniform2f(GLU.px,0,0);\n        }\n        // Restaura ident=0/GEO.vbuf incondicionalmente — o SEL_GL abaixo usa lines(), que espera\n        // esse estado, e o caminho sem costura (seamCount===0) sai daqui com ident=1/QUAD ainda\n        // ligado (do cover acima), o que desalinharia a posição do contorno de seleção.\n        useGeo();\n      }\n      if(SEL_GL&&SEL_GL.count) lines(SEL_GL,[0.11,0.11,0.12,1],true);\n    }\n    // Rótulos no <svg> fino por cima — Fase 8 (pedido do usuário): uma etiqueta por UNIDADE DO\n    // ESCOPO, não mais por município. Sem seleção, uma por unidade do nível ATIVO (Setor/GV/\n    // Mercado — o que o toggle mostra); com uma unidade ARMADA (outerCode selecionado), uma por\n    // unidade do nível DE BAIXO dela, só dentro da armada — a MESMA dualidade outer/inner do\n    // clique e do hover, agora para rótulo. Município só volta a aparecer quando o nível de\n    // baixo É município (escopo Setor, com um setor armado).\n    // Aparece SEMPRE, \"regardless of zoom level\" — nenhum corte por tamanho de tela. O único\n    // filtro é anti-colisão contra um rótulo maior já colocado (do maior para o menor em área):\n    // isso é legibilidade, não é esconder por zoom.\n    function drawLabels(svg,pr){\n      svg.innerHTML='';\n      if(!LABELS_ON) return;\n      const gL=document.createElementNS(NS,'g'); gL.setAttribute('class','lbl');\n      const upp=unitsPerPxDe(svg); if(!upp){ svg.appendChild(gL); return; }\n      // Estiliza o grupo NOVO aqui. applyMapView também estiliza — mas o grupo que existia quando ele\n      // rodou acabou de ser destruído pelo innerHTML='' acima.\n      estilizarRotulos(gL,upp);\n      const pxPerUnit=1/upp, fontPx=LABEL_PX;\n      const vx0=mapView.x, vy0=mapView.y, vx1=mapView.x+mapView.w, vy1=mapView.y+mapView.h;\n      const labelLevel=SELECIONADO?INNER_LEVEL[LEVEL]:LEVEL;\n      const restrictOuter=SELECIONADO?SELECIONADO.outerCode:null;\n      const outerArr=restrictOuter!=null?unitsAt(LEVEL):null;\n      const labelArr=labelLevel==='municipio'?null:unitsAt(labelLevel);\n      // Agrupa as FEATURES por unidade de rótulo: uma etiqueta de Setor/GV/Mercado representa,\n      // em geral, vários municípios — o ponto de inacessibilidade precisa da geometria UNIDA de\n      // todos eles, não da de um município isolado.\n      const grupos={};\n      for(let i=0;i<ALL_FEATURES.length;i++){\n        if(outerArr&&outerArr[i]!==restrictOuter) continue;\n        const f=ALL_FEATURES[i]; if(!f) continue;\n        const code=labelArr?labelArr[i]:muniKeyOfFeature(f);\n        if(!code) continue;\n        (grupos[code]=grupos[code]||[]).push(f);\n      }\n      // Candidatos visíveis, do maior para o menor em área de tela — só decide QUEM cede lugar a\n      // quem quando dois rótulos colidem, nunca descarta por ser pequeno.\n      const cands=[];\n      Object.keys(grupos).forEach(code=>{\n        const fs=grupos[code];\n        let x0=1/0,y0=1/0,x1=-1/0,y1=-1/0;\n        fs.forEach(f=>{ const bb=featBBox(f,pr); if(!bb) return;\n          if(bb.x0<x0)x0=bb.x0; if(bb.x1>x1)x1=bb.x1; if(bb.y0<y0)y0=bb.y0; if(bb.y1>y1)y1=bb.y1; });\n        if(!isFinite(x0)) return;\n        if(x1<vx0||x0>vx1||y1<vy0||y0>vy1) return;\n        cands.push({code:code,fs:fs,area:(x1-x0)*pxPerUnit*(y1-y0)*pxPerUnit});\n      });\n      cands.sort((a,b)=>b.area-a.area);\n      const placed=[];\n      cands.forEach(c=>{\n        const ck=labelLevel+'|'+c.code;\n        if(!(ck in LABEL_CACHE)) LABEL_CACHE[ck]=labelPointFor(c.fs,pr);\n        const lp=LABEL_CACHE[ck]; if(!lp||!lp.p) return;\n        let txt;\n        if(labelLevel==='municipio'){\n          const pp=(c.fs[0]&&c.fs[0].properties)||{};\n          txt=pp.name||pp.NOME||pp.nome||'';\n        } else {\n          const rec=unitRecord(c.code,labelLevel);\n          txt=(rec&&rec.nome&&rec.nome!==c.code?rec.nome+' — ':'')+c.code;\n        }\n        if(!txt) return;\n        const cx=(lp.p[0]-vx0)*pxPerUnit, cy=(lp.p[1]-vy0)*pxPerUnit;\n        const w=String(txt).length*fontPx*0.6+6, h=fontPx*1.3;\n        const bb={x0:cx-w/2,y0:cy-h/2,x1:cx+w/2,y1:cy+h/2};\n        for(let i=0;i<placed.length;i++){ const p=placed[i]; if(!(bb.x1<p.x0||bb.x0>p.x1||bb.y1<p.y0||bb.y0>p.y1)) return; }\n        placed.push(bb);\n        const t=document.createElementNS(NS,'text');\n        t.setAttribute('x',lp.p[0].toFixed(1)); t.setAttribute('y',lp.p[1].toFixed(1));\n        t.textContent=txt;\n        gL.appendChild(t);\n      });\n      svg.appendChild(gL);\n    }\n    // Ponto de entrada de sempre: todos os chamadores (nível, busca, seleção, boot, resize) continuam\n    // chamando drawMap. Ele só remonta o que ficou velho — geometria se a projeção mudou, lotes se\n    // nível/seleção mudaram — e desenha. O quadro em si sai de applyMapView.\n    function drawMap(){\n      const svg=$('#map'); if(!svg) return;\n      // ANTES de qualquer cálculo: chamado cedo (ResizeObserver na inscrição, toggle durante a carga)\n      // não pode computar PROJECT com a malha vazia nem popular caches sem base. Ver BOOT_PRONTO.\n      if(!BOOT_PRONTO||!ALL_FEATURES.length) return;\n      syncMapDims();\n      if(!PROJECT) PROJECT=project(ALL_FEATURES);\n      const pr=PROJECT; if(!pr) return;\n      LAST_DRAW_KEY=drawKeyNow();\n      // TUDO dentro do try, e não só a inicialização: fora do boot (toggle de nível, busca, seleção)\n      // uma exceção em qualquer etapa subiria sem mensagem e a tela ficaria como estava, calada.\n      // GL_ERRO fica gravado para o resumo do cabeçalho não o sobrescrever, e limpa no sucesso.\n      try{\n        const g=glInit(); if(!g) return;\n        if(!GEO) GEO=buildGeo(pr);\n        buildBatches();\n        buildSel();\n        buildHoverGL();\n        GL_ERRO=null;\n        applyMapView();\n        drawLabels(svg,pr);\n      }catch(e){\n        GL_ERRO='falha no motor de desenho: '+((e&&e.message)||e);\n        setStatus(GL_ERRO, true);\n      }\n    }\n    function showTip(e,text){\n      const tip=$('#tip'); if(!tip) return;\n      tip.textContent=text;\n      tip.style.left=(e.clientX+14)+'px';\n      tip.style.top=(e.clientY+14)+'px';\n      tip.classList.add('on');\n    }\n    function hideTip(){ const tip=$('#tip'); if(tip) tip.classList.remove('on'); }\n\n    $$('#levelToggle button').forEach(btn=>{\n      btn.onclick=()=>{\n        LEVEL=btn.dataset.level;\n        $$('#levelToggle button').forEach(b=>b.classList.toggle('on', b===btn));\n        // Trocar de escopo zera a seleção: outerCode se refere a uma unidade do escopo ANTERIOR, e\n        // mantê-la faria o próximo clique no mapa comparar contra uma unidade de um nível que nem\n        // está mais na tela — \"já armado\" nunca bateria, ou pior, bateria por coincidência de código.\n        SELECIONADO=null; HOVER=null;\n        drawMap();\n        renderSidebar();\n        if(UNITS.loaded) setStatus(unitSummary());\n      };\n    });\n    const inpBusca=$('#searchInput'), boxBusca=$('#searchBox'), clrBusca=$('#searchClear');\n    if(inpBusca){\n      inpBusca.addEventListener('input',abrirBusca);\n      inpBusca.addEventListener('focus',abrirBusca);\n      inpBusca.addEventListener('keydown',e=>{\n        if(e.key==='ArrowDown'||e.key==='ArrowUp'){\n          if(!SR_ITENS.length) return;\n          e.preventDefault();\n          SR_IDX=(SR_IDX+(e.key==='ArrowDown'?1:SR_ITENS.length-1))%SR_ITENS.length;\n          renderBusca();\n          const el=$('#searchRes')&&$('#searchRes').querySelector('.sr-item.on');\n          if(el&&el.scrollIntoView) el.scrollIntoView({block:'nearest'});\n        } else if(e.key==='Enter'){ if(SR_IDX>=0) escolher(SR_ITENS[SR_IDX]); }\n        else if(e.key==='Escape'){ fecharBusca(); inpBusca.blur(); }\n      });\n    }\n    if(clrBusca) clrBusca.onclick=()=>{\n      if(inpBusca){ inpBusca.value=''; inpBusca.focus(); }\n      clrBusca.classList.add('hidden');\n      SELECIONADO=null; HOVER=null; fecharBusca(); renderSidebar();\n      // Limpar desfaz a busca INTEIRA, não só o texto: a busca pode ter trocado o nível e com\n      // certeza trocou o enquadramento. Deixar o mapa parado no zoom da última unidade, em outro\n      // nível, com o campo vazio, é um estado que o usuário não tem como explicar.\n      mapView={x:0,y:0,w:MAP_W,h:MAP_H};\n      applyMapView();\n      drawMap();\n      // Só sobrescreve o status se houver resumo de verdade: com UNITS não carregado, unitSummary\n      // devolve string vazia e apagaria a mensagem de erro do boot, que era a única pista na tela.\n      if(UNITS.loaded) setStatus(unitSummary());\n    };\n    // Clique fora fecha a lista. No próprio root, e não no document: tudo desta tela vive dentro\n    // dele, e um listener no document sobreviveria a uma remontagem do componente.\n    root.addEventListener('pointerdown',e=>{ if(boxBusca&&!boxBusca.contains(e.target)) fecharBusca(); },true);\n\n    const sbCloseEl=$('#sbClose'), sbFecharEl=$('#sbFechar');\n    if(sbCloseEl) sbCloseEl.onclick=()=>{\n      SELECIONADO=null; HOVER=null;\n      drawMap(); renderSidebar();\n      if(UNITS.loaded) setStatus(unitSummary());\n    };\n    // Navegação de documento inteiro: voltar pelo bfcache restaura o DOM exatamente como ficou, com\n    // o botão desabilitado e escrito \"abrindo simulador…\" para sempre.\n    window.addEventListener('pageshow',()=>{\n      const b=$('#sbFechar'); if(b){ b.disabled=false; }\n    });\n    // Escape fecha o painel. Só quando a lista de busca já está fechada — senão um único Escape\n    // fecharia as duas coisas de uma vez, e quem está digitando espera fechar só a lista.\n    root.addEventListener('keydown',e=>{\n      if(e.key!=='Escape'||!SELECIONADO) return;\n      const r=$('#searchRes'); if(r&&!r.classList.contains('hidden')) return;\n      SELECIONADO=null; HOVER=null; drawMap(); renderSidebar();\n      if(UNITS.loaded) setStatus(unitSummary());\n    });\n    if(sbFecharEl) sbFecharEl.onclick=()=>{\n      if(!SELECIONADO) return;\n      // Fase 13 (pedido do usuário — simplifica a Fase 12): a Home NÃO cria mais sessão nenhuma\n      // — só navega direto pra /simulador com a intenção na URL, sem :session_id nenhum. É o\n      // simulador que cria a sessão, no final, depois de calcular a movimentação (o mesmo que\n      // \"Salvar sessão\" já faz manualmente) — a Home nunca grava nada em geo-timeline-sessions.\n      sbFecharEl.disabled=true; sbFecharEl.textContent='abrindo simulador…';\n      window.location.href=window.location.origin+'/simulador?apply='\n        +encodeURIComponent(SELECIONADO.level)+':'+encodeURIComponent(SELECIONADO.code);\n    };\n\n    // Redesenhar a malha inteira a cada evento de resize congela a aba enquanto se arrasta a borda\n    // da janela: são ~11 mil nós refeitos por evento, e o resize dispara dezenas deles por segundo.\n    // O ajuste de altura e o reenquadramento são baratos e continuam imediatos; só o redesenho\n    // (que existe para reavaliar quais rótulos cabem) espera a janela parar.\n    let RESIZE_T=null;\n    const aoRedimensionar=()=>{\n      fitMapHeight(); syncMapDims(); applyMapView();\n      clearTimeout(RESIZE_T); RESIZE_T=setTimeout(drawMap,250);\n    };\n    window.addEventListener('resize',aoRedimensionar);\n    // O SVG re-layoutava sozinho quando o CONTAINER mudava sem evento de janela (menu lateral do\n    // shell colapsando, painel que começa oculto e aparece). O canvas não: o bitmap antigo seria\n    // esticado pelo CSS enquanto o tooltip usaria o retângulo novo — desenho e mouse desalinhados.\n    // Em rAF: fitMapHeight muda a altura do PRÓPRIO elemento observado, e fazer isso dentro do callback\n    // gera \"ResizeObserver loop completed with undelivered notifications\" no console a cada disparo.\n    if(typeof ResizeObserver!=='undefined'){\n      const wrap=$('.map-wrap');\n      if(wrap) new ResizeObserver(()=>requestAnimationFrame(aoRedimensionar)).observe(wrap);\n    }\n    // Guarda em vez de acesso direto, e a razão é ferramental: o dry_render do MCP chama mounted()\n    // com o root VAZIO (a saída de render() não é injetada nele), então a primeira linha sem guarda\n    // estoura ali antes de qualquer outra coisa ser exercitada — e o dry_render só consegue dizer\n    // \"mounted threw\", deixando de valer como teste de fumaça do resto da montagem. Estas quatro\n    // linhas eram a única exceção num arquivo em que todo o resto do acesso ao DOM já é guardado.\n    // A ordem de registro é preservada de propósito: o listener de clique em captura no #map tem\n    // de continuar depois dos de root.\n    // svgEl é o <canvas>: é ele quem recebe mouse e roda. O <svg> #map é só a camada de rótulos.\n    const svgEl=$('#gl');\n    // Alternador novo no painel esquerdo, no lugar do botão de zoom removido (a roda do mouse já\n    // zoomava; o botão só duplicava o gesto).\n    // 18/09 (pedido do usuario): a Home abre com os rotulos DESLIGADOS. A classe `on` do botao no\n    // markup de render() foi removida junto — se so um dos dois mudasse, o botao apareceria aceso\n    // com o mapa sem rotulo (ou o contrario), e o primeiro clique pareceria nao fazer nada.\n    let LABELS_ON=false;\n    const toggleLabelsEl=$('#toggleLabels');\n    if(toggleLabelsEl) toggleLabelsEl.onclick=()=>{\n      LABELS_ON=!LABELS_ON;\n      toggleLabelsEl.classList.toggle('on',LABELS_ON);\n      drawMap();\n    };\n    if(svgEl) svgEl.addEventListener('pointerdown',mapPointerDown);\n    if(svgEl) svgEl.addEventListener('wheel',e=>{ e.preventDefault(); zoomMapAt(e.deltaY<0?1.15:1/1.15, e.clientX, e.clientY); }, {passive:false});\n    root.addEventListener('pointermove',mapPointerMove);\n    root.addEventListener('pointerup',mapPointerUp);\n    // Sair do componente encerra o arrasto E limpa o sinalizador de \"arrastou\": nenhum clique vai\n    // chegar depois disso, então o supressor ficaria armado e engoliria o PRÓXIMO clique legítimo.\n    // Bug anterior à delegação, que passou a depender do mesmo sinalizador.\n    root.addEventListener('pointerleave',()=>{ mapPointerUp(); mapDidDrag=false; });\n    // Três listeners no <canvas>, e nenhum elemento por município: tooltip E clique são resolvidos por\n    // geometria. O canvas não tem filhos — e.target é sempre ele —, então \"quem está sob o mouse\" só\n    // pode vir de featureEm. (A primeira versão do motor ainda lia data-unit do alvo, que não existe\n    // mais: o clique não selecionava nada e o cabeçalho continuava prometendo \"clique direto no mapa\".)\n    // Mesma conversão linear de zoomMapAt: o viewBox acompanha a proporção do container (syncMapDims),\n    // então não há letterbox do preserveAspectRatio a compensar.\n    const featureSob=e=>{\n      const rect=svgEl.getBoundingClientRect();\n      if(!rect.width||!rect.height) return null;\n      const x=mapView.x+(e.clientX-rect.left)/rect.width*mapView.w;\n      const y=mapView.y+(e.clientY-rect.top)/rect.height*mapView.h;\n      return featureEm(x,y);\n    };\n    if(svgEl){\n      svgEl.addEventListener('mousemove',e=>{\n        const f=featureSob(e);\n        // Cursor: default no fundo do canvas (fora de qualquer município), pointer sobre\n        // território — dragging (CSS #gl.dragging, mesma especificidade) sempre vence os dois\n        // enquanto o mapa está sendo arrastado, pela ordem das regras na folha de estilo.\n        svgEl.classList.toggle('hoverable',!!f);\n        // tooltipDe devolve null fora da unidade armada (mesma regra do realce abaixo) — trata\n        // como \"sem tooltip\", não como \"vazio mas mostra a caixa\".\n        const tip=f?tooltipDe(f):null;\n        if(tip) showTip(e,tip); else hideTip();\n        // Realce de hover: a MESMA regra do clique, e a MESMA restrição do tooltip acima — com\n        // algo ARMADO, hover só existe DENTRO da unidade selecionada. Fora dela (outra unidade do\n        // mesmo escopo, já apagada) não realça nem troca o tooltip: clicar ali reinicia a\n        // seleção do zero, não é \"mais uma opção disponível agora\".\n        let novoHover=null;\n        if(f){\n          const outerHit=unitOfFeature(f,LEVEL);\n          if(outerHit){\n            if(SELECIONADO){\n              if(SELECIONADO.outerCode===outerHit){\n                const innerLevel=INNER_LEVEL[LEVEL];\n                const innerHit=innerLevel==='municipio'?muniKeyOfFeature(f):unitOfFeature(f,innerLevel);\n                if(innerHit) novoHover={level:innerLevel,code:innerHit};\n              }\n            } else {\n              novoHover={level:LEVEL,code:outerHit};\n            }\n          }\n        }\n        const mudou=(!!novoHover)!==(!!HOVER)||(novoHover&&HOVER&&(novoHover.level!==HOVER.level||novoHover.code!==HOVER.code));\n        if(mudou){ HOVER=novoHover; buildHoverGL(); glDraw(); }\n      });\n      svgEl.addEventListener('mouseleave',()=>{ svgEl.classList.remove('hoverable'); hideTip(); if(HOVER){ HOVER=null; buildHoverGL(); glDraw(); } });\n      // Selecionar DESTACA e para — nunca navega. Navegar custa criar sessão e carregar o motor\n      // inteiro, e quem clicou num mapa de 5.564 municípios pode perfeitamente ter errado o alvo.\n      // A confirmação explícita para iniciar o fechamento vive na barra lateral. Município cinza\n      // (sem unidade no nível) não seleciona nada, como antes.\n      // Um listener só, que também consome o sinalizador de arrasto. Havia um segundo listener em\n      // captura fazendo stopPropagation — que NÃO impede outro listener no MESMO elemento (só\n      // stopImmediatePropagation faria), então ele zerava mapDidDrag e este selecionava: todo\n      // arrasto que terminasse dentro do canvas abria a sidebar.\n      svgEl.addEventListener('click',e=>{\n        if(mapDidDrag){ mapDidDrag=false; return; }\n        const f=featureSob(e);\n        if(!f){\n          // Clique no fundo do canvas — fora de qualquer município — desmarca a seleção atual,\n          // igual ao X do painel lateral: é o gesto natural de \"não quero mais isto selecionado\".\n          if(SELECIONADO){ SELECIONADO=null; HOVER=null; drawMap(); renderSidebar(); if(UNITS.loaded) setStatus(unitSummary()); }\n          return;\n        }\n        const outerHit=unitOfFeature(f,LEVEL);\n        if(!outerHit) return;\n        const innerLevel=INNER_LEVEL[LEVEL];\n        const innerHit=innerLevel==='municipio'?muniKeyOfFeature(f):unitOfFeature(f,innerLevel);\n        // Descer de escopo (LEVEL) só quando o clique cai numa unidade de baixo que já ERA a\n        // selecionada (mesmo nível, mesmo código) — checado pela IDENTIDADE da seleção atual, e\n        // não recalculando outerHit/armado do zero: perto de uma fronteira o clique pode cair num\n        // município tecnicamente de outra unidade externa, e recalcular tudo de novo faria esse\n        // clique reagarrar um outer errado em vez de aprofundar a MESMA unidade já selecionada.\n        // MUNICIPIO nunca desce escopo (é folha): todo clique nele só seleciona.\n        if(innerLevel!=='municipio'&&innerHit&&SELECIONADO&&SELECIONADO.level===innerLevel&&SELECIONADO.code===innerHit){\n          // ENTRAR NO NIVEL: a unidade já selecionada, clicada de novo, vira a ARMADA do novo\n          // nivel (outerCode===code) — o mesmo estado que o mapa já sabia desenhar como\n          // \"interior\" de uma unidade, o mesmo que o clique normal produz um nível acima.\n          LEVEL=innerLevel;\n          // O toggle da esquerda e so o espelho de LEVEL. Deixa-lo para tras mostraria um escopo\n          // no painel e desenharia outro no mapa - o mesmo par markup+variavel do estado inicial.\n          $$('#levelToggle button').forEach(b=>b.classList.toggle('on', b.dataset.level===LEVEL));\n          // HOVER foi calculado sob o nivel ANTERIOR; mante-lo realcaria uma unidade que nao\n          // existe mais neste escopo. drawMap (chamado por selecionarUnidade) reconstroi o realce.\n          HOVER=null;\n          selecionarUnidade(innerLevel,innerHit,innerHit);\n          return;\n        }\n        // Primeiro clique numa unidade externa nova: só ela é selecionada (o outer). Um SEGUNDO\n        // clique DENTRO da mesma unidade já armada seleciona a unidade de baixo — é o que\n        // outerCode===outerHit detecta, sem precisar de um terceiro estado guardado à parte.\n        const armado=SELECIONADO&&SELECIONADO.outerCode===outerHit;\n        if(!armado){ selecionarUnidade(LEVEL,outerHit,outerHit); return; }\n        if(!innerHit) return;\n        // Um clique simples so SELECIONA a unidade de baixo, sem trocar o escopo — trocar de\n        // escopo (acima) exige clicar de novo na MESMA unidade já selecionada.\n        selecionarUnidade(innerLevel,innerHit,outerHit);\n      });\n    }\n\n    // O botão temporário \"testar deck.gl\" (15/09) saiu com a decisão: o CDN respondeu em 1,5 s (rede OK),\n    // o pacote falhou por desalinhamento de versões no esm.sh, e a Fase 7 foi feita SEM biblioteca.\n\n    (async()=>{\n      // try/catch em volta de TUDO: sem ele, uma exceção em qualquer passo posterior ao await\n      // deixaria a tela em branco com o status congelado em \"carregando…\", que é pior do que\n      // qualquer mensagem de erro.\n      try{\n        setStatus('carregando mapa, dominância e unidades…');\n        // Aliases PRIMEIRO, e sozinho. ensureDominantes chaveia DOM.setorByMuni chamando muniN(),\n        // que lê MUNI_ALIAS — rodando as duas em paralelo, parte das chaves sai normalizada com a\n        // tabela de aliases ainda vazia e parte com ela cheia, enquanto o desenho chama muniN() de\n        // novo já com a tabela completa e não encontra essas chaves. O município cai em \"sem\n        // dominância\" de forma não determinística, mudando de um boot para o outro. Custa um passo\n        // serial (normalmente instantâneo) e elimina uma classe inteira de bug intermitente.\n        await loadMuniAliases();\n        const results=await Promise.allSettled([ensureDominantes(), loadAllGeo(), fetchUnitRows()]);\n        const [domR,geoR,unitR]=results;\n        const msgOf=r=>((r&&r.reason&&r.reason.message)||String(r&&r.reason)||'falha desconhecida');\n        fitMapHeight();\n        if(geoR.status==='rejected'){ setStatus('erro ao carregar mapa: '+msgOf(geoR), true); return; }\n        // loadAllGeo engole a falha de cada UF individualmente e por isso NUNCA rejeita: com as 27\n        // falhando, ele \"resolve\" com zero feature e a tela mostraria um mapa em branco por baixo\n        // de um resumo confiante.\n        if(!ALL_FEATURES.length){ setStatus('nenhum contorno de município carregou — o mapa ficaria em branco', true); return; }\n        // A adjacência entre municípios é construída UMA vez, porque a coloração depende dela. Custa\n        // uma passada sobre os 642 mil pontos; o JSON.parse dos 22,6 MB que acabou de acontecer, na\n        // mesma thread, custou bem mais.\n        const domErr=domR.status==='rejected'?msgOf(domR):null;\n        const unitErr=unitR.status==='rejected'?msgOf(unitR):null;\n        // DEPOIS de saber que a base carregou, nunca antes: buildMuniAdjacency congela a chave de\n        // cada município em f.__mk e em MUNI_NBRS, e a forma dessa chave depende de DOM.hasUf, que\n        // só existe quando ensureDominantes termina. Rodando antes, um retry futuro carregaria a\n        // base em NOME/UF com os features presos em NOME e o mapa inteiro viraria cinza — com o\n        // resumo anunciando setores e CBs normalmente.\n        if(!domErr) buildMuniAdjacency();\n        // O índice só existe com as DUAS fontes: a hierarquia vem do CSV, o nome e o número do CRM.\n        if(!domErr&&!unitErr){ buildUnitIndex(unitR.value); buildSearchIndex(); }\n        // UM desenho só, e sempre DEPOIS do índice. Desenhar antes parecia dar um primeiro quadro\n        // mais rápido, mas saía um mapa rotulado com o CÓDIGO em vez do nome — e, pior, gravava no\n        // cache de cores um resultado calculado com o índice ainda vazio, deixando ilhas e unidades\n        // sem vizinho detectado cinzas PARA SEMPRE, indistinguíveis de \"sem dominância\". Redesenhar\n        // depois consertaria a tela, mas ao custo da malha inteira de novo.\n        BOOT_PRONTO=true;\n        drawMap();\n        // O motor pode ter falhado sem lançar (drawMap guarda em GL_ERRO). Sem este teste o boot\n        // seguia para o resumo feliz e a tela ficava em branco anunciando setores e CBs.\n        if(GL_ERRO){\n          setStatus(GL_ERRO+(domErr?' · dominância: '+domErr:'')+(unitErr?' · CRM: '+unitErr:''), true);\n          return;\n        }\n        if(domErr){\n          UNITS.error=unitErr;\n          setStatus('mapa carregado, mas sem dominância (clique não funciona ainda): '+domErr\n            +(unitErr?' · os nomes e números também falharam: '+unitErr:''), true);\n          return;\n        }\n        if(unitErr){ UNITS.error=unitErr; setStatus('mapa pronto, mas os nomes e números das unidades falharam: '+unitErr, true); return; }\n        setStatus(unitSummary());\n      }catch(e){\n        setStatus('falha no boot da Home: '+((e&&e.message)||e), true);\n      }\n    })();\n  }\n}\n"}},"dominantesCsvUrl":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"dominantesCsvUrl"}}}},"sessionsDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"sessionsDataset"}}}},"muniAliasDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"muniAliasDataset"}}}},"osmProxy":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"osmProxy"}}}}}}}}}},{"_id":"6aab421dae89c3ff1c10bc23","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"component":"aHncbbciw5vzgGTjLOFIz","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"export default {\n  css: `\n  :host{display:block;height:100%;--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--acc2:#0060df;--closing:#ff3b30;--ok:#28cd41;\n    --radius-ctrl:6px;--shadow-ctrl:0 1px 3px rgba(0,0,0,.15)}\n  *{box-sizing:border-box}\n  .simroot{position:relative;height:100%;overflow:hidden;font:13px/1.45 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}\n  select,input,textarea,button{font:inherit}\n  button.act{background:var(--acc);color:#fff;border:1px solid var(--acc2);border-radius:var(--radius-ctrl);padding:6px 13px;font-weight:600;cursor:pointer;box-shadow:var(--shadow-ctrl);transition:background .1s}\n  button.act:hover{background:var(--acc2)}\n  button.act:disabled{opacity:.4;cursor:not-allowed;box-shadow:none}\n  svg{width:100%;height:auto;display:block;user-select:none;-webkit-user-select:none}\n  /* Fase 9 (pedido do usuário): a barra superior saiu inteira — busca, escopo e alternadores agora\n     vivem num painel fixo à ESQUERDA; o painel de detalhes (.sidebar) passou para a DIREITA. */\n  /* Painel transparente, a pedido: sem cartão por baixo, texto/ícones sem fundo próprio levam\n     halo (text-shadow branco + sombra escura sutil) e o diamante leva o mesmo halo via box-shadow\n     — a mesma ideia do halo branco dos rótulos do mapa (.lbl text), pra continuarem legíveis em\n     cima de qualquer cor de território, sem um cartão opaco brigando visualmente com o mapa. */\n  .leftpanel{position:absolute;left:0;top:0;bottom:0;width:300px;box-sizing:border-box;background:transparent;\n    display:flex;flex-direction:column;gap:20px;\n    padding:14px 14px 16px;overflow:auto;z-index:2;pointer-events:none}\n  .leftpanel > *{pointer-events:auto}\n  .scopelist{display:flex;flex-direction:column;gap:16px;margin:auto 0}\n  .scope-item{display:flex;align-items:center;gap:11px;border:0;background:transparent;padding:0;\n    cursor:pointer;font-size:14px;color:var(--mut);font-weight:600;text-align:left;font-family:inherit;\n    text-shadow:0 0 5px #fff,0 0 5px #fff,0 1px 2px rgba(0,0,0,.25)}\n  .scope-dot{width:9px;height:9px;border-radius:2px;background:#bcd6f5;flex:0 0 auto;\n    transform:rotate(45deg);transition:background .12s,width .12s,height .12s;\n    box-shadow:0 0 0 2px rgba(255,255,255,.9),0 1px 2px rgba(0,0,0,.25)}\n  .scope-item.on{color:var(--ink)}\n  .scope-item.on .scope-dot{width:13px;height:13px;background:var(--acc)}\n  .lefttoggles{display:flex;align-items:center;gap:10px;padding-top:10px}\n  .lp-icon-btn{border:0;background:transparent;color:var(--mut);cursor:pointer;padding:6px;\n    border-radius:8px;display:flex;align-items:center;justify-content:center;width:30px;height:30px}\n  .lp-icon-btn:hover{background:rgba(255,255,255,.55)}\n  .lp-icon-btn.on{color:var(--acc)}\n  .lp-icon-btn svg{width:18px;height:18px}\n  .lp-icon-btn.labelbtn{font-size:15px;font-weight:700;font-family:Georgia,\"Times New Roman\",serif;\n    text-shadow:0 0 5px #fff,0 0 5px #fff,0 1px 2px rgba(0,0,0,.25)}\n  .map-wrap{position:relative;width:100%;height:100%;display:flex;justify-content:center;overflow:hidden}\n  /* Duas camadas no mesmo retângulo. O <canvas> é a GPU: preenchimentos e contornos, e é quem recebe\n     o mouse. O <svg> por cima só carrega os rótulos (~50 textos) e não pega evento nenhum — tudo o\n     que é pesado saiu dele. */\n  #gl{position:absolute;left:0;top:0;width:100%;height:100%;display:block;cursor:default;touch-action:none}\n  #gl.hoverable{cursor:pointer}\n  #gl.dragging{cursor:grabbing}\n  #map{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none}\n  .hidden{display:none!important}\n  /* Preenchimentos, contornos de unidade, seleção e o apagado da GV não são mais CSS: vivem no\n     <canvas> (Fase 7). O <svg> só tem rótulos. */\n  /* Sem drop-shadow: é um filtro SVG sobre o grupo inteiro de rótulos, re-rasterizado a cada mudança\n     de viewBox (todo passo de zoom e todo quadro de arrasto). O halo branco do paint-order já dá a\n     legibilidade que a sombra dava. */\n  .lbl{pointer-events:none}\n  /* stroke-width e letter-spacing NÃO ficam aqui, de propósito. Em SVG, \"2px\" numa folha de estilo é\n     2 UNIDADES DO VIEWBOX, não 2 pixels de tela: a 20× de zoom o halo virava ~80 px e o texto uma\n     bolha branca, e o -0,1 de letter-spacing virava -4 px esmagando as letras (\"DENTEDELEAO\").\n     Os dois são escritos por estilizarRotulos (chamado em applyMapView e em drawLabels), escalados\n     pelo MESMO fator que o font-size — e herdam do <g> para os <text>, como o font-size. */\n  .lbl text{text-anchor:middle;paint-order:stroke fill;stroke:#fff;fill:#1c1c1e;stroke-linejoin:round;stroke-linecap:round;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif}\n  /* Fase 9: embutida no painel esquerdo fixo, não mais flutuante sobre o mapa — só o dropdown de\n     resultados (.searchres) continua absoluto, pra não empurrar o resto do painel ao abrir. */\n  .searchbox{position:relative;width:100%;flex:0 0 auto;\n    background:var(--panel);border:1px solid var(--line);border-radius:10px;\n    box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08)}\n  .searchbox-row{display:flex;align-items:center;gap:7px;padding:7px 10px}\n  .searchbox-ico{color:var(--mut);font-size:13px;line-height:1;flex:0 0 auto}\n  .searchbox input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:var(--ink);\n    font-size:13px;padding:2px 0}\n  .searchbox input::placeholder{color:var(--mut)}\n  .searchbox-x{border:0;background:transparent;color:var(--mut);cursor:pointer;font-size:11px;\n    padding:3px 5px;border-radius:5px;flex:0 0 auto}\n  .searchbox-x:hover{background:var(--panel2);color:var(--ink)}\n  .searchres{position:absolute;left:0;right:0;top:100%;margin-top:6px;max-height:320px;overflow:auto;\n    background:var(--panel);border:1px solid var(--line);border-radius:10px;\n    box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);padding:4px;z-index:6}\n  .sr-grp{font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);\n    font-weight:600;padding:7px 8px 3px}\n  .sr-item{display:flex;align-items:baseline;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer}\n  .sr-item:hover,.sr-item.on{background:var(--panel2)}\n  .sr-item .sr-nm{font-size:12.5px;color:var(--ink);font-weight:600;overflow:hidden;\n    text-overflow:ellipsis;white-space:nowrap}\n  .sr-item .sr-sub{font-size:11px;color:var(--mut);margin-left:auto;white-space:nowrap;flex:0 0 auto}\n  .sr-vazio{padding:10px 8px;font-size:12px;color:var(--mut)}\n  /* Painel lateral. Sobreposto ao mapa, e não empurrando-o: empurrar mudaria a largura do\n     container, o que dispara syncMapDims, invalida a projeção e força um redesenho da malha\n     inteira a cada abrir e fechar. */\n  .sidebar{position:absolute;right:10px;top:10px;bottom:10px;width:min(360px,calc(100% - 20px));\n    z-index:3;background:var(--panel);border:1px solid var(--line);border-radius:10px;\n    box-shadow:0 4px 14px rgba(0,0,0,.16),0 1px 2px rgba(0,0,0,.08);\n    display:flex;flex-direction:column;overflow:hidden}\n  .sb-hd{padding:12px 34px 10px 14px;border-bottom:1px solid var(--line)}\n  .sb-kind{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);font-weight:600}\n  .sb-nome{font-size:16px;font-weight:600;letter-spacing:-.2px;margin-top:2px;line-height:1.25}\n  .sb-cod{font-size:11.5px;color:var(--mut);margin-top:3px}\n  .sb-x{position:absolute;right:8px;top:8px;border:0;background:transparent;color:var(--mut);\n    cursor:pointer;font-size:11px;padding:5px 7px;border-radius:6px}\n  .sb-x:hover{background:var(--panel2);color:var(--ink)}\n  .sb-bd{flex:1;overflow:auto;padding:12px 14px}\n  .sb-caminho{font-size:11.5px;color:var(--mut);line-height:1.6;margin-bottom:12px}\n  .sb-caminho b{color:var(--ink);font-weight:600}\n  .sb-crumb{cursor:pointer;text-decoration:underline;text-decoration-color:transparent;transition:text-decoration-color .12s}\n  .sb-crumb:hover{text-decoration-color:currentColor}\n  .sb-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}\n  .sb-kpi{background:var(--panel2);border-radius:8px;padding:8px 10px}\n  .sb-kpi .n{font-size:17px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.2}\n  .sb-kpi .l{font-size:10.5px;color:var(--mut);margin-top:2px}\n  .sb-sec{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);\n    font-weight:600;margin:16px 0 7px}\n  .sb-banda{background:var(--panel2);border-radius:8px;padding:10px 12px}\n  .sb-barra{position:relative;height:8px;background:#e0e0e6;border-radius:4px;margin:10px 0 7px}\n  .sb-faixa{position:absolute;top:0;bottom:0;background:#bdeecb;border-radius:4px}\n  /* translateX pra marca ficar CENTRADA no valor: com left:100% ela saía inteira para fora da\n     barra, e o clamp escondia a magnitude justamente no caso extremo. */\n  .sb-marca{position:absolute;top:-4px;bottom:-4px;width:2px;background:var(--ink);border-radius:1px;transform:translateX(-50%)}\n  .sb-alvo{position:absolute;top:-2px;bottom:-2px;width:1px;background:var(--mut)}\n  .sb-veredito{font-size:12.5px;font-weight:600}\n  /* Tons escurecidos para passar contraste AA sobre o cinza do cartão. O verde do sistema\n     (#28cd41) dá ~1,9:1 aqui — e é justamente o estado mais comum. */\n  .sb-veredito.ok{color:#1a8c2e} .sb-veredito.baixo{color:#8a5200} .sb-veredito.alto{color:#c4231a}\n  .sb-nota{font-size:11px;color:var(--mut);line-height:1.55;margin-top:7px}\n  .sb-ft{padding:10px 12px;border-top:1px solid var(--line);background:var(--panel2)}\n  .sb-ft button{width:100%}\n  .tip{position:fixed;pointer-events:none;background:#1c1c1e;color:#fff;border-radius:8px;padding:7px 9px;font-size:11px;opacity:0;transition:opacity .1s;z-index:9;max-width:230px;box-shadow:0 2px 8px rgba(0,0,0,.28)}\n  .tip.on{opacity:1}\n  .note{color:var(--mut);font-size:10.5px}\n  `,\n  render(d2){\n    return `\n    <div class=\"simroot\">\n      <div class=\"leftpanel\">\n        <div class=\"searchbox\" id=\"searchBox\">\n          <div class=\"searchbox-row\">\n            <span class=\"searchbox-ico\" aria-hidden=\"true\">&#9906;</span>\n            <input id=\"searchInput\" type=\"text\" autocomplete=\"off\" spellcheck=\"false\"\n                   placeholder=\"Buscar setor, GV, mercado ou município\" aria-label=\"Buscar unidade\">\n            <button class=\"searchbox-x hidden\" id=\"searchClear\" title=\"Limpar\" aria-label=\"Limpar busca\">&#10005;</button>\n          </div>\n          <div class=\"searchres hidden\" id=\"searchRes\" role=\"listbox\"></div>\n        </div>\n        <div class=\"scopelist\" id=\"levelToggle\" role=\"radiogroup\" aria-label=\"Escopo do mapa\">\n          <button type=\"button\" data-level=\"mercado\" class=\"scope-item on\"><span class=\"scope-dot\"></span>Mercado</button>\n          <button type=\"button\" data-level=\"gv\" class=\"scope-item\"><span class=\"scope-dot\"></span>GV</button>\n          <button type=\"button\" data-level=\"setor\" class=\"scope-item\"><span class=\"scope-dot\"></span>Setor</button>\n        </div>\n        <div class=\"lefttoggles\">\n          <button type=\"button\" id=\"toggleLabels\" class=\"lp-icon-btn labelbtn\" title=\"Mostrar/ocultar rótulos\" aria-label=\"Mostrar/ocultar rótulos\">A</button>\n        </div>\n      </div>\n      <div class=\"map-wrap\">\n        <canvas id=\"gl\"></canvas>\n        <svg id=\"map\" viewBox=\"0 0 700 500\" preserveAspectRatio=\"xMidYMid meet\"></svg>\n        <div class=\"sidebar hidden\" id=\"sidebar\" role=\"region\" aria-label=\"Detalhes da unidade selecionada\">\n          <div class=\"sb-hd\" id=\"sbHead\"></div>\n          <button class=\"sb-x\" id=\"sbClose\" title=\"Fechar painel\" aria-label=\"Fechar painel\">&#10005;</button>\n          <div class=\"sb-bd\" id=\"sbBody\"></div>\n          <div class=\"sb-ft hidden\" id=\"sbFoot\">\n            <button class=\"act\" id=\"sbFechar\">Simular fechamento</button>\n          </div>\n        </div>\n        <div class=\"tip\" id=\"tip\"></div>\n      </div>\n    </div>\n    `;\n  },\n  mounted(d2, root){\n    const $=s=>root.querySelector(s);\n    const $$=s=>[...root.querySelectorAll(s)];\n    const NS='http://www.w3.org/2000/svg';\n    const AUTH_REF='timelineSimAuthFinal';\n\n    function setStatus(msg,err){ const el=$('#status'); if(!el) return; el.textContent=msg||''; el.classList.toggle('err',!!err); }\n\n    // ---------- nomes e números das unidades (Databricks) ----------\n    // DIVISÃO DE FONTES, decidida em 15/09/2026 e deliberada:\n    //   • o CSV de dominantes governa HIERARQUIA e POSSE (a que GV/mercado um setor pertence, e\n    //     quais municípios são dele). É o que o MOTOR usa — a regra dura `sameParent`, que decide\n    //     quais setores podem receber território, roda sobre o CSV. Se a Home agrupasse pelo CRM e\n    //     as duas fontes discordassem, o usuário selecionaria um setor que a Home diz ser da GV X e\n    //     o motor só ofereceria destinos da GV Y, sem nada na tela explicando por quê.\n    //   • o CRM (Databricks) dá só NOME e NÚMERO. É a única fonte deles, e o CSV não os tem.\n    // Uma consulta por montagem do componente; GV e mercado saem por agregação no cliente. Depois\n    // disto nenhuma seleção na Home toca a rede.\n    const DBX_T='product.perfil_cb.crm_perfil_cb_diaria_historico';\n    const DBX_ELO='product.elo_modelo_combinado.commercial_consultant_index';\n    // Referência LITERAL do listener, e não um parâmetro declarado. Os parâmetros deste componente\n    // NÃO são derivados do uso no código: acrescentar `d2.param('dbxProxy')` ao fonte deixa o schema\n    // com os mesmos 4 params de antes, e um param fora do schema é silenciosamente ignorado em\n    // runtime — compila, liga no bloco, e falha calado. O simulador resolve isto do mesmo jeito. É\n    // o MESMO listener que o simulador usa, não o irmão \"new token\": dois listeners diferentes\n    // significariam duas cadeias que ninguém garantiu equivalentes.\n    const DBX_PROXY='AwW_11Nkg-dKksP7ic8V8';\n    // Mesma expressão do resolveSnapshotPin() do simulador: prefere o PENÚLTIMO DT_HISTORICO e só\n    // cai no mais recente quando não existe um anterior. Hoje a tabela guarda uma única data, então\n    // o COALESCE cai no MAX e as duas telas leem exatamente o mesmo dia — é inócuo AGORA. Está aqui\n    // para o dia em que a tabela passar a acumular histórico: nesse dia, sem isto, a Home passaria a\n    // anunciar um total de CBs que nenhuma tela do simulador calcula, sem nada sinalizando a troca.\n    const SNAP_EXPR=`COALESCE((SELECT MAX(DT_HISTORICO) FROM ${DBX_T} WHERE CD_PAIS=1 AND DT_HISTORICO<(SELECT MAX(DT_HISTORICO) FROM ${DBX_T} WHERE CD_PAIS=1)),(SELECT MAX(DT_HISTORICO) FROM ${DBX_T} WHERE CD_PAIS=1))`;\n    // `cbs` replica o loadSectors() do simulador caractere a caractere. `ativas` usa o MESMO\n    // indicador do ELO que o simulador usa (active_bc_ind) e NÃO a flag ST_FEZ_PED_CICLO_ATUAL do\n    // CRM: as duas contam coisas diferentes e divergem ~10% na prática (setor 3184: 437 pela flag,\n    // 481 pelo ELO). Agregação por COUNT(DISTINCT) e não SUM — medido, o LEFT JOIN hoje não duplica\n    // linha nenhuma (linhas do join == CBs distintas nos setores testados), mas DISTINCT custa o\n    // mesmo e continua correto caso o ELO um dia ganhe mais de uma linha por pessoa e ciclo.\n    // Municípios NÃO são contados aqui: DISTINCT DS_MUNICIPIO é \"onde as CBs estão\", ignora a UF\n    // (funde homônimos) e perde município do setor que esteja sem CB no ciclo. Posse vem do CSV.\n    const UNIT_SQL=`SELECT t.CD_SETOR setor, MAX(t.DS_SETOR) nm_setor,\n        MAX(t.CD_GV) gv_crm, MAX(t.DS_GV) nm_gv, MAX(t.CD_GM) gm_crm, MAX(t.DS_GM) nm_gm,\n        COUNT(DISTINCT t.CD_GRUPO) grupos,\n        COUNT(DISTINCT CASE WHEN e.business_status_code=3 THEN t.CD_CONSULTORA END) cbs,\n        COUNT(DISTINCT CASE WHEN e.active_bc_ind=1 THEN t.CD_CONSULTORA END) ativas,\n        MAX(t.DT_HISTORICO) snapshot\n      FROM ${DBX_T} t\n      LEFT JOIN ${DBX_ELO} e ON e.person_code=t.CD_CONSULTORA AND e.operational_cycle=t.CD_CICLO_ATUAL AND e.country_code=1\n      WHERE t.CD_PAIS=1 AND t.CD_GRUPO>0 AND t.CD_SETOR>0\n        AND t.DT_HISTORICO=${SNAP_EXPR}\n      GROUP BY t.CD_SETOR ORDER BY t.CD_SETOR`;\n    const UNITS={setor:{},gv:{},mercado:{},order:{setor:[],gv:[],mercado:[]},\n                 snapshot:null,loaded:false,error:null,semCrm:0,soCrm:0};\n    const n0=v=>{const n=+v;return isFinite(n)?n:0;};\n    const txt=v=>String(v==null?'':v).trim();\n    // Código de setor/GV vem de DOIS lugares — texto cru do CSV e serialização do Databricks. Um\n    // zero à esquerda ou um \"3184.0\" de um lado só faria o cruzamento falhar em silêncio, perdendo\n    // o nome sem ninguém notar. Canoniza os dois lados pelo número quando for numérico.\n    const secKey=v=>{const s=txt(v); if(!s) return ''; const n=Number(s); return isFinite(n)&&s!==''?String(n):s;};\n    async function dbxQuery(sql){\n      let res;\n      try{ res=await d2.rush(DBX_PROXY).invoke({sql:sql}); }\n      catch(e){\n        // Mesmo diagnóstico de sessão que ensureDominantes já monta para o CSV. O listener é gated\n        // pela MESMA estratégia de autenticação, então \"deslogado\" é a causa mais provável de uma\n        // falha aqui — e sem dizer isso o operador lê \"indisponível\" e abre chamado de infra.\n        let sess='';\n        try{ const a=d2.auth(AUTH_REF); if(!(a&&a.isAuthed)) sess=' — você NÃO está logado na estratégia '+AUTH_REF; }catch(_){ }\n        throw new Error('sonda Databricks indisponível'+sess+': '+((e&&e.message)||e));\n      }\n      const payload=res&&res.data;\n      if(!payload) throw new Error('a sonda não devolveu corpo — listener fora do ar?');\n      // O listener responde 200 com {sql} e SEM `result` quando o upstream estoura o tempo: o\n      // handler monta `result: $result.data`, então um upstream vazio simplesmente não cria a\n      // chave. Ler direto até result.data dava \"Cannot read properties of undefined\", que lê como\n      // bug deste arquivo e manda a investigação para o lugar errado.\n      if(!payload.result) throw new Error('a sonda respondeu 200 sem `result` — normalmente timeout do upstream; o SQL não chegou a rodar');\n      // O upstream também pode responder 200 com um envelope de ERRO ({code,message}) no lugar do\n      // resultado. Sem este teste isso caía em \"respondeu sem dados\" e a mensagem útil (\"invalid\n      // token\") era jogada fora exatamente no caso em que ela resolvia o problema sozinha.\n      if(payload.result.code&&+payload.result.code>=400) throw new Error('sonda Databricks '+payload.result.code+': '+(payload.result.message||'sem detalhe'));\n      const data=payload.result.data;\n      if(!data) throw new Error('a sonda respondeu sem dados');\n      // PENDING/RUNNING não é erro, é \"ainda rodando\" — e esta consulta é mais pesada que a que ela\n      // imita, então está mais perto desse penhasco. Dizer \"erro desconhecido\" aqui faria procurar\n      // defeito onde só faltou espera.\n      if(data.state==='PENDING'||data.state==='RUNNING') throw new Error('a consulta ainda rodava no Databricks quando a sonda parou de esperar ('+data.state+') — recarregue para tentar de novo');\n      if(data.state&&data.state!=='SUCCEEDED'){\n        // `data.error` é o objeto `status` INTEIRO do Databricks: {state, error:{error_code,message}}.\n        // Ler data.error.message devolve undefined SEMPRE, e engoliria a causa de toda falha de SQL\n        // — justo aqui, onde a consulta usa colunas que nenhuma outra query do sistema toca. Procura\n        // nos dois níveis antes de recorrer ao stringify, que é o fallback que salva o simulador.\n        const err=data.error;\n        const msg=(err&&(err.message||(err.error&&err.error.message)))||(err?JSON.stringify(err):'')||'erro desconhecido';\n        throw new Error('Databricks '+data.state+': '+msg);\n      }\n      return data;\n    }\n    function dbxRows(resp){\n      const cols=(resp.columns||[]).map(c=>c.name);\n      return (resp.rows||[]).map(a=>Object.fromEntries(cols.map((k,i)=>[k,a[i]])));\n    }\n    // Memoizado no mesmo padrão do DOM: a promessa é guardada, então duas chamadas concorrentes\n    // compartilham UMA consulta, e uma falha limpa o cache para permitir nova tentativa. Sem isto,\n    // \"uma consulta por sessão\" viraria uma por chamada.\n    let UNIT_ROWS_PROMISE=null;\n    function fetchUnitRows(){\n      if(!UNIT_ROWS_PROMISE) UNIT_ROWS_PROMISE=dbxQuery(UNIT_SQL).then(dbxRows)\n        .catch(e=>{ UNIT_ROWS_PROMISE=null; throw e; });\n      return UNIT_ROWS_PROMISE;\n    }\n    // Exige DOM.bySetor já carregado: a hierarquia sai de lá, não das linhas do CRM.\n    function buildUnitIndex(rs){\n      UNITS.setor={}; UNITS.gv={}; UNITS.mercado={};\n      UNITS.order={setor:[],gv:[],mercado:[]};\n      UNITS.snapshot=null; UNITS.semCrm=0; UNITS.soCrm=0;\n      const crm={},gvNome={};\n      (rs||[]).forEach(r=>{\n        const sc=secKey(r.setor); if(!sc) return;\n        if(!UNITS.snapshot&&r.snapshot) UNITS.snapshot=txt(r.snapshot);\n        crm[sc]={nome:txt(r.nm_setor),grupos:n0(r.grupos),cbs:n0(r.cbs),ativas:n0(r.ativas)};\n        const gc=secKey(r.gv_crm); if(gc&&!gvNome[gc]) gvNome[gc]=txt(r.nm_gv);\n      });\n      const bySetor=DOM.bySetor||{}, gvBy=DOM.gvBySetor||{}, mkBy=DOM.mercadoBySetor||{};\n      const vistos={};\n      Object.keys(bySetor).forEach(raw=>{\n        const sc=secKey(raw); if(!sc) return;\n        vistos[sc]=1;\n        const gc=secKey(gvBy[raw]), mk=txt(mkBy[raw]);\n        const c=crm[sc]||{};\n        if(!crm[sc]) UNITS.semCrm++;\n        const munis=(bySetor[raw]&&bySetor[raw].size)||0;\n        const cbs=n0(c.cbs), ativas=n0(c.ativas), grupos=n0(c.grupos);\n        // `raw` guardado junto: o mapa resolve setor por DOM.setorByMuni, que devolve a chave crua\n        // do CSV. Sem ela, cruzar o desenho com este índice exigiria recanonizar a cada comparação.\n        UNITS.setor[sc]={code:sc,raw:String(raw),nome:c.nome||'',gv:gc,nomeGv:gvNome[gc]||'',\n                         mercado:mk,munis:munis,grupos:grupos,cbs:cbs,ativas:ativas,semCrm:!crm[sc]};\n        if(gc){\n          const g=UNITS.gv[gc]||(UNITS.gv[gc]={code:gc,nome:gvNome[gc]||'',mercado:mk,setores:[],\n                                               munisSoma:0,grupos:0,cbs:0,ativas:0});\n          g.setores.push(sc); g.munisSoma+=munis; g.grupos+=grupos; g.cbs+=cbs; g.ativas+=ativas;\n        }\n        if(mk){\n          const m=UNITS.mercado[mk]||(UNITS.mercado[mk]={code:mk,nome:mk,gvs:[],setores:[],\n                                                         munisSoma:0,grupos:0,cbs:0,ativas:0});\n          m.setores.push(sc); m.munisSoma+=munis; m.grupos+=grupos; m.cbs+=cbs; m.ativas+=ativas;\n          if(gc&&m.gvs.indexOf(gc)<0) m.gvs.push(gc);\n        }\n      });\n      // Setor que existe no CRM e não na base de posse nunca aparece no mapa (o mapa é pintado pelo\n      // CSV), então não entra no índice — mas é contado, porque \"a base está defasada\" é exatamente\n      // o tipo de coisa que precisa ser visível em vez de deduzida.\n      Object.keys(crm).forEach(sc=>{ if(!vistos[sc]) UNITS.soCrm++; });\n      // munisSoma, e não munis: somar os municípios dos setores de uma GV NÃO dá o número de\n      // municípios distintos dela — uma capital é repartida entre dezenas de setores. O nome do\n      // campo diz o que o número realmente é, para nenhuma tela adiante o exibir como \"municípios\".\n      const byCode=(a,b)=>(+a-+b)||(a<b?-1:a>b?1:0);\n      UNITS.order.setor=Object.keys(UNITS.setor).sort(byCode);\n      UNITS.order.gv=Object.keys(UNITS.gv).sort(byCode);\n      UNITS.order.mercado=Object.keys(UNITS.mercado).sort((a,b)=>a<b?-1:a>b?1:0);\n      // Sem isto, um retorno com nomes de coluna inesperados produziria um índice vazio e a tela\n      // exibiria \"0 setores · 0 GVs\" com ar de sucesso.\n      if(!UNITS.order.setor.length) throw new Error('nenhum setor válido saiu do cruzamento entre a base de dominantes e o CRM');\n      UNITS.loaded=true; UNITS.error=null;\n      return UNITS;\n    }\n\n    // ---------- índice de busca ----------\n    // Montado uma vez, depois que UNITS e a base de dominantes existirem. Guarda o texto já\n    // normalizado, para o casamento não precisar normalizar nada a cada tecla digitada.\n    const SEARCH=[];\n    function buildSearchIndex(){\n      SEARCH.length=0;\n      const push=(kind,code,label,sub,hay)=>SEARCH.push({kind:kind,code:code,label:label,sub:sub,norm:norm(hay)});\n      // NOME antes do código no campo de casamento. Com o código na frente, o nome nunca casa na\n      // posição 0, perde o bônus de prefixo, e um município de nome parecido passa na frente do\n      // próprio setor procurado — o oposto do que o comentário de `buscar` promete. Com o nome na\n      // frente, digitar o nome casa em 0 e digitar o código continua casando (só que no meio).\n      UNITS.order.setor.forEach(c=>{const u=UNITS.setor[c];\n        push('setor',c,u.nome||('Setor '+c),'Setor '+c+(u.nomeGv?' · '+u.nomeGv:''),u.nome+' '+c);});\n      UNITS.order.gv.forEach(c=>{const u=UNITS.gv[c];\n        push('gv',c,u.nome||('GV '+c),'GV '+c+(u.mercado?' · '+u.mercado:''),u.nome+' '+c);});\n      UNITS.order.mercado.forEach(c=>{const u=UNITS.mercado[c];\n        push('mercado',c,u.nome,'Mercado',c);});\n      // Município entra pela base de dominantes, não pelo CRM: a pergunta que a busca responde é\n      // \"que setor ATENDE esta cidade\", e posse de território é o conceito certo para isso.\n      // O RÓTULO vem de DOM.muniLabel (a grafia original do arquivo) e não da chave: a chave passou\n      // por norm(), que remove acento e sobe tudo para maiúsculas — \"SAO PAULO\", \"SANTA BARBARA D\n      // OESTE\". Serve para casar, não para ler. O campo `norm` continua igual porque norm() é\n      // idempotente sobre uma string já normalizada.\n      const lbl=DOM.muniLabel||{};\n      if(DOM.setorByMuni) Object.keys(DOM.setorByMuni).forEach(m=>{\n        const sc=secKey(DOM.setorByMuni[m]), u=UNITS.setor[sc];\n        const uf=mUf(m), nome=lbl[m]||mNome(m);\n        // O nome vai no rótulo com a UF entre parênteses porque, com homônimos, \"Bom Jesus\" sozinho\n        // não distingue seis municípios diferentes. O campo de casamento leva chave E nome, então\n        // digitar o nome acha e digitar a sigla também.\n        // O sub usa o MESMO formato do setor (\"Setor <código> · <nome>\"), e não \"Município · <nome>\":\n        // os codinomes de setor (PEDRAS BONITAS, TULIPA BRANCA…) soam como topônimo, e sem a\n        // palavra \"Setor\" na frente pareciam só outro lugar qualquer — confuso justamente na\n        // categoria que existe para responder \"que setor atende esta cidade\".\n        push('municipio',sc,nome+(uf?' ('+uf+')':''),'Setor '+sc+(u&&u.nome?' · '+u.nome:''),m+' '+nome);\n      });\n      return SEARCH.length;\n    }\n\n    const fmtN=n=>(+n||0).toLocaleString('pt-BR');\n    function fmtSnapshot(s){\n      if(!s) return '—';\n      const str=String(s);\n      // Data pura (\"2026-09-15\") é parseada por new Date() como meia-noite UTC, e em America/\n      // Sao_Paulo volta como o dia ANTERIOR às 21h. Numa coluna diária, errar o dia no selo de\n      // confiança do cabeçalho é pior do que não mostrar nada. Hoje a coluna é TIMESTAMP e este\n      // ramo não dispara, mas ele custa uma linha e cobre a mudança de tipo.\n      if(/^\\d{4}-\\d{2}-\\d{2}$/.test(str)) return str.slice(8,10)+'/'+str.slice(5,7)+'/'+str.slice(0,4);\n      const d=new Date(str);\n      return isNaN(d.getTime())?str.slice(0,16).replace('T',' ')\n        :d.toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});\n    }\n    // Resumo de uma linha no cabeçalho: é o critério de aceite visível da camada de dados. Sem ele,\n    // esta fase não muda nada na tela e não há como conferir se carregou de verdade.\n    function unitSummary(){\n      if(!UNITS.loaded) return '';\n      const cbs=UNITS.order.setor.reduce((a,c)=>a+UNITS.setor[c].cbs,0);\n      const bits=[fmtN(UNITS.order.setor.length)+' setores',\n                  fmtN(UNITS.order.gv.length)+' GVs',\n                  fmtN(UNITS.order.mercado.length)+' mercados',\n                  fmtN(cbs)+' CBs',\n                  'snapshot '+fmtSnapshot(UNITS.snapshot),\n                  fmtN(SEARCH.length)+' itens buscáveis'];\n      // Município desenhado que a base não atribui a setor nenhum sai cinza no mapa. Sem esta\n      // contagem ele some em silêncio, e \"sumir em silêncio\" é o modo de falha mais caro que esta\n      // tela tem: a malha do IBGE tem 5.564 municípios e o CRM registra 7.193 grafias distintas.\n      if(MAP_STATS.semDominancia) bits.push(fmtN(MAP_STATS.semDominancia)+' município(s) cinza no mapa'\n        +(MAP_STATS.semHierarquia?' ('+fmtN(MAP_STATS.semHierarquia)+' por setor sem hierarquia na base)':''));\n      if(OUTER_CONFLITOS[LEVEL]) bits.push(fmtN(OUTER_CONFLITOS[LEVEL])+' unidade(s) sem tom livre no nível externo — paleta esgotada');\n      if(INNER_CONFLITOS[LEVEL]) bits.push(fmtN(INNER_CONFLITOS[LEVEL])+' unidade(s) sem tom livre no nível interno — paleta esgotada');\n      if(MAP_STATS.semMercadoCor) bits.push(fmtN(MAP_STATS.semMercadoCor)+' setor(es) sem mercado na base — coloridos no azul padrão');\n      // O modo de chaveamento muda o resultado do mapa inteiro. No modo bom fica calado; no modo\n      // degradado avisa — porque aí homônimos de estados diferentes viram um município só.\n      if(DOM.hasUf===false) bits.push('⚠ base sem coluna de UF — municípios homônimos colapsam num setor só');\n      // Qual coluna foi escolhida importa: a lista de candidatos aceita nomes frouxos ('SIGLA',\n      // 'ESTADO', 'CD UF'), e uma coluna errada cujos valores PAREÇAM UF pinta o mapa quase todo de\n      // cinza sem disparar nenhum dos contadores. Dizer qual foi usada é a única pista barata.\n      else if(DOM.ufCol) bits.push('UF pela coluna “'+DOM.ufCol+'”');\n      if(DOM.badUf) bits.push(fmtN(DOM.badUf)+' linha(s) com UF irreconhecível');\n      if(DOM.semUf) bits.push(fmtN(DOM.semUf)+' linha(s) sem UF — esses municípios não casam com a malha');\n      if(UNITS.semCrm) bits.push(fmtN(UNITS.semCrm)+' setor(es) da base sem dado no CRM');\n      if(UNITS.soCrm) bits.push(fmtN(UNITS.soCrm)+' setor(es) do CRM fora da base');\n      // Critério de aceite do cache da malha: sem isto, ligar ou desligar o cache não muda nada\n      // visível na tela e não há como conferir se ele está funcionando.\n      if(GEO_FROM_CACHE) bits.push('malha: '+fmtN(GEO_FROM_CACHE)+'/27 UFs do cache local');\n      if(GEO_FAILED_UFS.length) bits.unshift('⚠ malha não carregou pra '+GEO_FAILED_UFS.join(', ')+' — recarregue a página');\n      // Erro do motor de desenho entra NO resumo, e na frente: sem isto o toggle de nível e o botão\n      // limpar reescreviam \"622 setores · … CBs\" por cima de \"este navegador não tem WebGL\", e a\n      // tela ficava em branco com ar de sucesso.\n      if(GL_ERRO) bits.unshift('⚠ mapa não desenhado: '+GL_ERRO);\n      return bits.join(' · ');\n    }\n\n    const norm=s=>(s||'').normalize('NFKD').replace(/[̀-ͯ]/g,'').toUpperCase().replace(/[^A-Z0-9]+/g,' ').trim();\n    let MUNI_ALIAS={BRASOPOLIS:'BRAZOPOLIS','DONA EUSEBIA':'DONA EUZEBIA'};\n    const muniN=s=>{const n=norm(s);return MUNI_ALIAS[n]||n;};\n\n    async function loadMuniAliases(){\n      let ds=null;\n      for(let attempt=0; attempt<5 && !ds; attempt++){\n        if(attempt) await new Promise(r=>setTimeout(r,1200));\n        ds=d2.data('muniAliasDataset');\n      }\n      if(!ds) return;\n      try{\n        await ds.refresh();\n        const recs=await ds.loadAll();\n        recs.forEach(r=>{ const de=norm(r.de), pa=norm(r.para); if(de&&pa) MUNI_ALIAS[de]=pa; });\n      }catch(e){ /* non-fatal */ }\n    }\n\n    const UF_CODE={RO:'11',AC:'12',AM:'13',RR:'14',PA:'15',AP:'16',TO:'17',MA:'21',PI:'22',CE:'23',RN:'24',PB:'25',PE:'26',AL:'27',SE:'28',BA:'29',MG:'31',ES:'32',RJ:'33',SP:'35',PR:'41',SC:'42',RS:'43',MS:'50',MT:'51',GO:'52',DF:'53'};\n    // ---------- cache da malha no navegador ----------\n    // A malha do IBGE são 27 arquivos, 8,1 MB de rede comprimidos e ~11 s antes de a tela mostrar\n    // qualquer coisa — pagos INTEIROS a cada entrada no app, para baixar exatamente os mesmos bytes\n    // de sempre. Fronteira de município muda por lei, em lote, raramente.\n    // Guarda o TEXTO da resposta, e não as features já parseadas: a IndexedDB faz structured clone\n    // na escrita, e clonar 642 mil pontos em arrays aninhados custa mais do que o download que se\n    // quer evitar. Uma string é copiada em bloco. O JSON.parse continua acontecendo na leitura,\n    // igual a hoje — o que o cache elimina é a REDE, que é a parte lenta.\n    // A FONTE não muda: continua o mesmo arquivo do mesmo repositório, buscado do mesmo jeito na\n    // primeira vez. Isto é memória de navegador, não uma origem de dado nova.\n    // NENHUMA falha aqui pode derrubar o mapa: aba anônima, storage bloqueado por política da\n    // máquina e cota estourada são todos casos reais, e em todos o certo é degradar para o fetch\n    // de sempre, calado. Por isso toda promessa deste bloco RESOLVE — nenhuma rejeita.\n    const GEO_DB='sec-home-geo', GEO_STORE='uf';\n    // Sobe quando a FORMA do que é guardado mudar (outra fonte de malha, outro shape do registro).\n    // Malha velha lida por parser novo é pior do que não ter cache nenhum.\n    const GEO_CACHE_V=1;\n    // Cache sem prazo é cache que ninguém sabe invalidar. 30 dias garante que uma alteração de\n    // fronteira chegue em no máximo um mês, sem custar rede em nenhuma entrada do dia a dia.\n    const GEO_TTL_MS=30*24*3600*1000;\n    let GEO_DB_P=null;\n    function geoDb(){\n      if(GEO_DB_P) return GEO_DB_P;\n      GEO_DB_P=new Promise(res=>{\n        let req;\n        try{ req=indexedDB.open(GEO_DB,1); }catch(e){ return res(null); }\n        let desistiu=false;\n        req.onupgradeneeded=()=>{ try{ req.result.createObjectStore(GEO_STORE); }catch(e){ } };\n        req.onsuccess=()=>{\n          const db=req.result;\n          // Sem isto, esta conexão — que vive o tempo inteiro da página — bloqueia\n          // INDEFINIDAMENTE o \"limpar dados do site\" do navegador e qualquer abertura em versão\n          // nova por outra aba, que cai no onblocked e fica sem cache, calada. Fechar na hora é\n          // seguro: geoDb memoiza a PROMESSA, e a partir daí toda transação cai no catch e degrada\n          // para a rede, que é o comportamento de antes desta fase.\n          db.onversionchange=()=>{ try{ db.close(); }catch(e){ } };\n          // Resolver no onblocked não CANCELA o request: se a outra aba fechar depois, o sucesso\n          // chega aqui com o res já consumido e sobra uma conexão aberta que ninguém usa — e é\n          // justamente conexão aberta que faz a próxima aba cair no onblocked.\n          if(desistiu){ try{ db.close(); }catch(e){ } return; }\n          res(db);\n        };\n        req.onerror=()=>res(null);\n        // Uma outra aba com a versão antiga aberta trava o upgrade para SEMPRE — sem erro e sem\n        // sucesso. Sem este ramo a promessa nunca resolve e o boot fica pendurado em\n        // \"carregando mapa…\" sem nada na tela explicando o quê.\n        req.onblocked=()=>{ desistiu=true; res(null); };\n      });\n      return GEO_DB_P;\n    }\n    function geoCacheGet(uf){\n      return geoDb().then(db=>db&&new Promise(res=>{\n        let r;\n        try{\n          const tx=db.transaction(GEO_STORE,'readonly');\n          // Mesma blindagem da escrita, e pelo mesmo motivo: um erro de request borbulha para a\n          // transação e daí para a conexão, então resolver a promessa com null deixa o mapa certo\n          // mas não impede o evento não tratado de aparecer no console a cada entrada — com o mapa\n          // funcionando perfeitamente e quem olhar procurando defeito nele.\n          tx.onabort=()=>{}; tx.onerror=()=>{};\n          r=tx.objectStore(GEO_STORE).get(uf);\n        }\n        catch(e){ return res(null); }\n        r.onsuccess=()=>{\n          const v=r.result;\n          res((v&&v.v===GEO_CACHE_V&&v.txt&&(Date.now()-(+v.at||0))<GEO_TTL_MS)?v.txt:null);\n        };\n        r.onerror=()=>res(null);\n      })).catch(()=>null);\n    }\n    // Cota estourada aborta a transação de forma ASSÍNCRONA, fora do alcance do try: sem os\n    // handlers vazios o erro sobe como evento não tratado e aparece no console a cada entrada —\n    // e quem olhar vai procurar defeito no mapa, que está funcionando.\n    function geoCacheWrite(fn){\n      return geoDb().then(db=>{ if(!db) return;\n        try{\n          const tx=db.transaction(GEO_STORE,'readwrite');\n          tx.onabort=()=>{}; tx.onerror=()=>{};\n          const rq=fn(tx.objectStore(GEO_STORE));\n          if(rq) rq.onerror=()=>{};\n        }catch(e){ }\n      }).catch(()=>{});\n    }\n    function geoCachePut(uf,txt){\n      return geoCacheWrite(st=>st.put({v:GEO_CACHE_V,at:Date.now(),txt:txt},uf));\n    }\n    function geoCacheDel(uf){ return geoCacheWrite(st=>st.delete(uf)); }\n    // PARSEAR NÃO É VALIDAR, e a diferença aqui custa a tela inteira. HTML de portal cativo de\n    // fato estoura no JSON.parse, mas WAF e proxy corporativo respondem 200 com um corpo JSON\n    // legítimo — {\"error\":\"blocked\"} — que parseia sem reclamar. Daí `features` é undefined, o\n    // flatMap de loadAllGeo INSERE undefined em vez de achatar nada (flatMap não achata o que não\n    // é array), e o buraco resultante atravessa project e muniKeyOfFeature, que são guardados, até\n    // estourar em pathOf, que é o único ponto da cadeia de desenho SEM guarda de feature. O boot\n    // morre inteiro e a tela fica sem mapa.\n    const ehMalha=g=>!!(g&&Array.isArray(g.features)&&g.features.length);\n    // Critério de aceite desta fase: sem um contador, ligar ou desligar o cache não muda NADA\n    // visível e não há como conferir se ele está funcionando de verdade.\n    let GEO_FROM_CACHE=0;\n    async function geoUF(uf){\n      const code=UF_CODE[uf]||uf;\n      const U=String(uf).toUpperCase();\n      const cached=await geoCacheGet(U);\n      let g=null;\n      // Registro ruim no cache não pode custar a UF: cai para a rede como se não houvesse cache\n      // nenhum, e o registro sai no caminho em vez de estragar toda entrada até o TTL vencer.\n      // Os dois ramos apagam — texto corrompido (estoura no parse) e corpo que parseia mas não é\n      // malha. Sem o segundo, o registro sobreviveria, a UF pagaria rede MAIS uma leitura inútil\n      // de IndexedDB em toda entrada, e o contador ainda a anunciaria como vinda do cache.\n      if(cached){\n        try{ const p=JSON.parse(cached); if(ehMalha(p)){ g=p; GEO_FROM_CACHE++; } else geoCacheDel(U); }\n        catch(e){ geoCacheDel(U); }\n      }\n      if(!g){\n        const r=await fetch('https://raw.githubusercontent.com/tbrugz/geodata-br/master/geojson/geojs-'+code+'-mun.json');\n        if(!r.ok) throw new Error('geo '+uf);\n        const txt=await r.text();\n        const p=JSON.parse(txt);\n        // Falhar é o comportamento CERTO aqui: loadAllGeo tolera UF nula, então a tela degrada\n        // para um mapa com um estado a menos em vez de não abrir. E, principalmente, nada entra no\n        // cache — gravar um corpo de bloqueio de proxy transformaria um incidente de rede de UMA\n        // entrada num estado pegajoso de 30 dias, repetido a cada abertura com a rede já perfeita\n        // e sem nenhum caminho de recuperação na tela.\n        if(!ehMalha(p)) throw new Error('geo '+uf+': a resposta não é uma malha de municípios (200 com corpo de bloqueio de proxy?)');\n        g=p;\n        // Sem await: gravar é otimização, e a tela não tem por que esperar por ela.\n        geoCachePut(U,txt);\n      }\n      g.features.forEach(f=>{ f.__uf=U; });\n      return g;\n    }\n    // Sigla canônica a partir do que a base traz na coluna de UF: sigla, código IBGE ou nome por\n    // extenso. O que não resolver vira vazio — uma UF inventada chavearia o município para um shape\n    // inexistente e o tiraria do mapa sem dizer nada.\n    const UF_OF_CODE={}; Object.keys(UF_CODE).forEach(k=>{ UF_OF_CODE[UF_CODE[k]]=k; });\n    const UF_OF_NAME={ACRE:'AC',ALAGOAS:'AL',AMAPA:'AP',AMAZONAS:'AM',BAHIA:'BA',CEARA:'CE','DISTRITO FEDERAL':'DF','ESPIRITO SANTO':'ES',GOIAS:'GO',MARANHAO:'MA','MATO GROSSO':'MT','MATO GROSSO DO SUL':'MS','MINAS GERAIS':'MG',PARA:'PA',PARAIBA:'PB',PARANA:'PR',PERNAMBUCO:'PE',PIAUI:'PI','RIO DE JANEIRO':'RJ','RIO GRANDE DO NORTE':'RN','RIO GRANDE DO SUL':'RS',RONDONIA:'RO',RORAIMA:'RR','SANTA CATARINA':'SC','SAO PAULO':'SP',SERGIPE:'SE',TOCANTINS:'TO'};\n    const ufNorm=v=>{ const s=norm(v); if(!s) return '';\n      if(UF_CODE[s]) return s;\n      if(UF_OF_CODE[s]) return UF_OF_CODE[s];\n      return UF_OF_NAME[s]||''; };\n    // Chave de município: NOME/UF quando a base tem UF, só o nome quando não tem. Os dois lados\n    // (base e malha) precisam usar a MESMA regra, senão nada casa — por isso a decisão mora aqui,\n    // num lugar só, e não duplicada nos dois pontos de construção de chave.\n    const mkey=(nome,uf)=>{ const n=muniN(nome); if(!n) return '';\n      // Sem esta guarda, um feature sem nome produziria a chave '/SP' — que é truthy, passa pelo\n      // filtro `if(!k) return` de buildMuniAdjacency, e funde TODOS os features sem nome da mesma\n      // UF num nó só: exatamente o defeito que o chaveamento por UF veio eliminar, em miniatura.\n      const u=String(uf==null?'':uf).trim().toUpperCase();\n      return (DOM.hasUf&&u)?(n+'/'+u):n; };\n    const mNome=k=>{ const s=String(k==null?'':k), i=s.lastIndexOf('/'); return i<0?s:s.slice(0,i); };\n    const mUf=k=>{ const s=String(k==null?'':k), i=s.lastIndexOf('/'); return i<0?'':s.slice(i+1); };\n    let ALL_FEATURES=[];\n    // UFs cujo fetch falhou NESTA sessão (rede, rate limit, o que for) — geoUF já degrada calado por\n    // desenho (uma UF fora do ar não pode derrubar as outras 26), mas isso deixava uma unidade cuja\n    // malha simplesmente não baixou agora indistinguível de um problema genuíno de dado na base.\n    // Guardado para o resumo do cabeçalho (unitSummary) e para a mensagem de \"sem território\n    // desenhado\" (statusDaUnidade) pararem de acusar a base por um hiccup de rede transitório.\n    let GEO_FAILED_UFS=[];\n    async function loadAllGeo(){\n      const ufs=Object.keys(UF_CODE);\n      GEO_FAILED_UFS=[];\n      const geos=await Promise.all(ufs.map(u=>geoUF(u).catch(()=>{ GEO_FAILED_UFS.push(u); return null; })));\n      // (g&&g.features)||[] e nunca g.features direto: flatMap NÃO achata o que não é array — um\n      // retorno undefined é INSERIDO no resultado. Um único buraco em ALL_FEATURES derruba pathOf,\n      // que é o único ponto da cadeia de desenho sem guarda. geoUF já rejeita corpo que não seja\n      // malha; isto é a segunda camada, porque o custo de errar aqui é a tela não abrir.\n      ALL_FEATURES=geos.filter(Boolean).flatMap(g=>(g&&g.features)||[]);\n    }\n\n    const DOM_MEDIA_BASE='https://natura-geomarketing.data2.media/file/';\n    const DOM_CSV_FALLBACK=DOM_MEDIA_BASE+'yw_TQdp0QXEk6JsrliAqP';\n    // hasUf nasce FALSE explicitamente, e não undefined: mkey o consulta, e se ensureDominantes\n    // falhar antes de defini-lo o modo de chaveamento ficaria indefinido no meio do boot — com as\n    // chaves já congeladas em f.__mk e em MUNI_NBRS, sem nada que as invalide depois.\n    const DOM={url:null,promise:null,bySetor:null,hasUf:false};\n    function parseCsv(text){\n      const out=[]; let row=[],cur='',q=false;\n      const push=()=>{ row.push(cur); cur=''; };\n      const endRow=()=>{ push(); if(row.length>1||row[0]!=='') out.push(row); row=[]; };\n      for(let i=0;i<text.length;i++){ const ch=text[i];\n        if(q){ if(ch==='\"'){ if(text[i+1]==='\"'){ cur+='\"'; i++; } else q=false; } else cur+=ch; }\n        else if(ch==='\"') q=true;\n        else if(ch===',') push();\n        else if(ch==='\\n') endRow();\n        else if(ch!=='\\r') cur+=ch;\n      }\n      if(cur!==''||row.length) endRow();\n      return out;\n    }\n    function d2SessionToken(sysDomain){\n      const read=k=>{ try{ return (k&&window.localStorage.getItem(k))||''; }catch(e){ return ''; } };\n      return read(sysDomain?('__data2-session-'+sysDomain):'')||read('__data2-session-data2')||'';\n    }\n    function domCsvSystemDomain(url){\n      const m=/^https?:\\/\\/([^.\\/]+)\\.data2\\.(?:media|app)\\//i.exec(url)||/^https?:\\/\\/([^.\\/]+)\\.data2apis\\.com\\//i.exec(url);\n      return m?m[1]:'';\n    }\n    function domCsvCandidates(url){\n      const out=[];\n      const isMedia=/^https?:\\/\\/[^.\\/]+\\.data2\\.media\\//i.test(url);\n      const api=isMedia?url.replace(/^(https?:\\/\\/[^.\\/]+)\\.data2\\.media\\//i,'$1.data2apis.com/'):null;\n      const tok=d2SessionToken(domCsvSystemDomain(url));\n      const auth=tok?{authorization:'Bearer '+tok}:null;\n      if(api&&auth) out.push({url:api,headers:auth,label:'api+sessão'});\n      if(!api&&auth) out.push({url:url,headers:auth,label:'origem+sessão'});\n      if(api) out.push({url:api,headers:null,label:'api anônimo'});\n      out.push({url:url,headers:null,label:'origem anônima'});\n      return out;\n    }\n    function domCsvUrl(){\n      const pick=v=>{\n        if(!v) return '';\n        if(typeof v==='string') return v;\n        const q=v.params||{};\n        const ref=q.reference||v.reference;\n        return q.fileUrl||q.url||v.fileUrl||v.url||(ref?DOM_MEDIA_BASE+ref:'');\n      };\n      return String(pick(d2.param('dominantesCsvUrl'))||DOM_CSV_FALLBACK||'').trim();\n    }\n    async function ensureDominantes(){\n      const url=domCsvUrl();\n      if(!url) throw new Error('base de municípios dominantes não configurada');\n      if(DOM.url===url && DOM.bySetor) return DOM.bySetor;\n      if(DOM.url===url && DOM.promise) return DOM.promise;\n      DOM.url=url;\n      DOM.promise=(async()=>{\n        let r=null; const tried=[];\n        for(const c of domCsvCandidates(url)){\n          let res=null;\n          try{ res=await fetch(c.url,{cache:'no-store',credentials:'omit',headers:c.headers||undefined}); }\n          catch(e){ tried.push(c.label+' → rede/CORS'); continue; }\n          if(res.ok){ r=res; break; }\n          tried.push(c.label+' → '+res.status);\n        }\n        if(!r){\n          let sess='sessão desconhecida';\n          try{\n            const a=d2.auth(AUTH_REF);\n            sess=(a&&a.isAuthed)?('logado como '+((a.user&&(a.user.email||a.user.nome))||'?')):('NÃO logado na estratégia '+AUTH_REF);\n          }catch(e){ sess='estratégia '+AUTH_REF+' não resolvida'; }\n          throw new Error('base de municípios dominantes: não foi possível ler o arquivo — '+sess+'. Tentativas: '+tried.join(' · '));\n        }\n        const table=parseCsv(await r.text());\n        if(table.length<2) throw new Error('base de municípios dominantes vazia');\n        const head=table[0].map(h=>norm(h));\n        const iS=head.indexOf('CD SETOR'), iM=head.indexOf('MUNICIPIO');\n        if(iS<0||iM<0) throw new Error('base sem as colunas CD_SETOR / MUNICIPIO');\n        const iG=['CD GV','CDGV','GV','CD GERENCIA','CD GERENCIA VENDAS','CD GERENCIA DE VENDAS','GERENCIA DE VENDAS','GERENCIA'].map(h=>head.indexOf(h)).find(i=>i>=0);\n        if(iG==null) throw new Error('base sem a coluna CD_GV');\n        const iK=['MERCADO','MERCADO REGIONAL','MKT','MARKET'].map(h=>head.indexOf(h)).find(i=>i>=0);\n        if(iK==null) throw new Error('base sem a coluna MERCADO');\n        // Coluna de UF: opcional, e decisiva. Sem ela o município é chaveado só pelo nome — e o\n        // Brasil tem centenas de nomes repetidos entre estados (Bom Jesus, Santa Luzia, Bonito).\n        // Todos colapsavam no primeiro setor encontrado: dezenas de municípios pintados no setor\n        // errado, a caixa envolvente da unidade esticada por meio país (arruinando o corte de\n        // rótulo e o enquadramento da busca) e cor gasta à toa na coloração. Com ela, a chave vira\n        // NOME/UF e o problema desaparece. Sem ela, o comportamento volta a ser exatamente o de\n        // antes — e DOM.hasUf passa a dizer em que modo a tela está, em vez de a diferença ficar\n        // invisível.\n        const iU=['UF','SIGLA UF','SG UF','CD UF','ESTADO','DS ESTADO','SIGLA','ESTADO SIGLA','UF MUNICIPIO']\n          .map(h=>head.indexOf(h)).find(i=>i>=0);\n        const by={},gvBy={},mkBySetor={},muniLabel={}; let n=0,badUf=0,semUf=0;\n        for(let i=1;i<table.length;i++){\n          const line=table[i]; if(!line) continue;\n          const s=String(line[iS]==null?'':line[iS]).trim(), nm=muniN(line[iM]);\n          if(!s||!nm) continue;\n          const uf=(iU==null)?'':ufNorm(line[iU]);\n          // UF presente na linha mas irreconhecível é contada, não silenciada: é erro de base, e\n          // some sem rastro se a linha simplesmente degradar para chave sem UF.\n          if(iU!=null && String(line[iU]==null?'':line[iU]).trim() && !uf) badUf++;\n          // Célula de UF VAZIA numa base que TEM a coluna é pior que UF errada, e escapava da\n          // contagem acima (que exige célula preenchida). A linha vira chave sem barra enquanto\n          // todo feature vem com barra, então ela nunca casa com nada: o município fica cinza, a\n          // contagem de municípios do setor infla, e nada denuncia — nem o aviso de \"ficaram fora\n          // do enquadramento\", porque para aquele código o município sequer existe.\n          else if(iU!=null && !uf) semUf++;\n          const m=uf?(nm+'/'+uf):nm;\n          // Grafia original guardada aqui, no único ponto em que ela ainda existe: a chave `m` já\n          // passou por norm() e perdeu acento e caixa para sempre. Serve só para exibição.\n          if(!muniLabel[m]) muniLabel[m]=String(line[iM]==null?'':line[iM]).trim();\n          const gvv=String(line[iG]==null?'':line[iG]).trim();\n          if(gvv && gvBy[s]===undefined) gvBy[s]=gvv;\n          const mkv=String(line[iK]==null?'':line[iK]).trim();\n          if(mkv && mkBySetor[s]===undefined) mkBySetor[s]=mkv;\n          (by[s]=by[s]||new Set()).add(m); n++;\n        }\n        if(!n) throw new Error('base de municípios dominantes sem linhas válidas');\n        DOM.bySetor=by;\n        DOM.muniLabel=muniLabel;\n        DOM.hasUf=(iU!=null);\n        DOM.ufCol=(iU==null?null:String(table[0][iU]||'').trim());\n        DOM.badUf=badUf;\n        DOM.semUf=semUf;\n        // setorByMuni (singular, primeiro dono) segue governando pintura/hover/tooltip/adjacência —\n        // sem mudança aí, de propósito: fatiar visualmente um município entre vários setores (como o\n        // simulador faz) é um projeto à parte no motor WebGL desta tela. setoresByMuni (plural, TODOS\n        // os donos) existe só para featuresDaUnidade não dizer \"sem território\" pra um setor real que\n        // genuinamente possui uma FATIA de um município compartilhado (Recife, São Paulo etc.) mas\n        // perdeu a corrida do \"primeiro no CSV\" — setor 3322 (pedido do usuário) é exatamente este\n        // caso: Recife/PE tem vários setores, e só o primeiro encontrado aparecia com território.\n        const revBy={},allBy={};\n        Object.entries(by).forEach(([s,ms])=>ms.forEach(m=>{ if(!revBy[m]) revBy[m]=s; (allBy[m]=allBy[m]||new Set()).add(s); }));\n        DOM.setorByMuni=revBy;\n        DOM.setoresByMuni=allBy;\n        DOM.gvBySetor=gvBy;\n        DOM.mercadoBySetor=mkBySetor;\n        const sectorsByGv={},sectorsByMercado={};\n        Object.keys(by).forEach(s=>{\n          const gv=gvBy[s], mk=mkBySetor[s];\n          if(gv) (sectorsByGv[gv]=sectorsByGv[gv]||new Set()).add(s);\n          if(mk) (sectorsByMercado[mk]=sectorsByMercado[mk]||new Set()).add(s);\n        });\n        DOM.sectorsByGv=sectorsByGv; DOM.sectorsByMercado=sectorsByMercado;\n        return by;\n      })();\n      DOM.promise.catch(e=>{ DOM.promise=null; DOM.bySetor=null; setStatus('erro ao carregar dominantes: '+e.message, true); });\n      return DOM.promise;\n    }\n\n    // ---------- de município para unidade ----------\n    // A HIERARQUIA sai do CSV de dominantes, nunca do CRM — é ela que o motor usa para decidir\n    // quais setores podem receber território. Chaves canonizadas por secKey para casarem com o\n    // índice UNITS, que vem do Databricks com a serialização dele.\n    function muniKeyOfFeature(f){\n      // Memoizado na própria feature: norm() faz normalize('NFKD') mais duas regex, e isso rodava\n      // 11 mil vezes por redesenho. Só é seguro memoizar porque os aliases de município agora\n      // carregam ANTES da base de dominantes — antes dessa correção, a mesma feature podia dar\n      // chaves diferentes dependendo de quando fosse perguntada.\n      if(f&&f.__mk!==undefined) return f.__mk;\n      // A UF vem do próprio arquivo: geoUF carimba f.__uf em cada feature no momento da carga, que\n      // é o único ponto em que ela é conhecida com certeza (o fetch é por UF; depois as features\n      // são achatadas num array só e a origem seria irrecuperável). mkey só a usa se a base também\n      // tiver UF — senão os dois lados deixariam de casar.\n      const k=mkey(f&&f.properties&&(f.properties.name||f.properties.NOME||f.properties.nome), f&&f.__uf);\n      if(f) f.__mk=k;\n      return k;\n    }\n    function unitOfMuniKey(m,level){\n      const s=DOM.setorByMuni&&DOM.setorByMuni[m];\n      if(!s) return null;\n      if(level==='setor') return secKey(s)||null;\n      if(level==='gv') return secKey((DOM.gvBySetor||{})[s])||null;\n      return txt((DOM.mercadoBySetor||{})[s])||null;\n    }\n    function unitOfFeature(f,level){ return unitOfMuniKey(muniKeyOfFeature(f),level); }\n    function unitRecord(code,level){ return (UNITS[level]||{})[code]||null; }\n    // Municípios (chaves cruas do CSV) que a base de dominantes atribui a esta unidade — usada só\n    // para diagnóstico de \"unidade sem território desenhado\": diferenciar \"a malha desta UF falhou\n    // nesta sessão\" (GEO_FAILED_UFS) de um mismatch de grafia genuíno, e nesse segundo caso apontar\n    // exatamente qual(is) município(s) a base esperava, em vez de só dizer \"pode ser grafia\".\n    function csvMunisDaUnidade(code,level,rec){\n      if(!rec) return [];\n      const raws = level==='setor' ? [rec.raw]\n        : (rec.setores||[]).map(sc=>{ const r=UNITS.setor[sc]; return r&&r.raw; }).filter(Boolean);\n      const out=new Set();\n      raws.forEach(raw=>{ (DOM.bySetor&&DOM.bySetor[raw]||new Set()).forEach(m=>out.add(m)); });\n      return [...out];\n    }\n    function csvUfsDaUnidade(code,level,rec){\n      return [...new Set(csvMunisDaUnidade(code,level,rec).map(m=>mUf(m)).filter(Boolean))];\n    }\n    // pathOf/pathFor saíram na Fase 7: a geometria vai para a GPU em buildGeo, sem string de path.\n    // Caixa envolvente projetada da feature, para o culling por enquadramento. Mora na feature e cai\n    // junto com __d, porque depende da MESMA projeção — mantida depois de um resize que muda a\n    // proporção, ela mandaria para fora da tela exatamente o que está dentro.\n    function featBBox(f,pr){\n      if(f.__bb!==undefined) return f.__bb;\n      const g=f&&f.geometry;\n      if(!g||!g.coordinates) return (f.__bb=null);\n      let x0=1/0,y0=1/0,x1=-1/0,y1=-1/0;\n      const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      ps.forEach(poly=>poly.forEach(r=>r.forEach(p=>{ const q=pr(p);\n        if(q[0]<x0)x0=q[0]; if(q[0]>x1)x1=q[0]; if(q[1]<y0)y0=q[1]; if(q[1]>y1)y1=q[1]; })));\n      return (f.__bb=isFinite(x0)?{x0:x0,y0:y0,x1:x1,y1:y1}:null);\n    }\n\n    // ---------- município sob o cursor ----------\n    // O tooltip diz o nome da CIDADE, em qualquer zoom — e o desenho tem um path por unidade, não\n    // por município, então o nome não pode vir do elemento sob o mouse. Vem de geometria: uma grade\n    // sobre o viewBox lista, por célula, as features cuja caixa a toca; o mousemove converte o cursor\n    // para unidades de viewBox, pega a célula e testa ponto-em-polígono só nos candidatos. São uma\n    // ou duas dezenas de candidatos por célula e ~115 vértices por município: microssegundos.\n    // Cai junto com PROJECT (syncMapDims): as caixas são da projeção.\n    const HIT_N=48;\n    let HIT_GRID=null;\n    function buildHitGrid(pr){\n      const cells=new Array(HIT_N*HIT_N), cw=MAP_W/HIT_N, ch=MAP_H/HIT_N;\n      ALL_FEATURES.forEach(f=>{ const b=featBBox(f,pr); if(!b) return;\n        const i0=Math.max(0,Math.floor(b.x0/cw)), i1=Math.min(HIT_N-1,Math.floor(b.x1/cw));\n        const j0=Math.max(0,Math.floor(b.y0/ch)), j1=Math.min(HIT_N-1,Math.floor(b.y1/ch));\n        for(let j=j0;j<=j1;j++) for(let i=i0;i<=i1;i++){ const k=j*HIT_N+i; (cells[k]||(cells[k]=[])).push(f); }\n      });\n      HIT_GRID={cells:cells,cw:cw,ch:ch};\n    }\n    // Par-ímpar sobre TODOS os anéis da feature, exteriores e buracos de todos os polígonos: um ponto\n    // dentro de um buraco cruza o exterior e o buraco, duas vezes, e sai como fora — que é o certo.\n    // Mesma fórmula do inside() de labelPointFor.\n    function dentroDe(f,x,y,pr){\n      const g=f&&f.geometry; if(!g||!g.coordinates) return false;\n      const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n      let c=false;\n      for(const poly of ps) for(const r of poly){\n        for(let i=0,j=r.length-1;i<r.length;j=i++){\n          const a=pr(r[i]), b=pr(r[j]);\n          if(((a[1]>y)!==(b[1]>y)) && (x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])) c=!c;\n        }\n      }\n      return c;\n    }\n    // `exceto`: feature a ignorar. A sondagem de vizinho em buildSegOther precisa do que está do outro\n    // lado SEM contar a própria feature — onde duas malhas de UF se sobrepõem num sliver, o ponto está\n    // dentro das duas, e devolver a própria (primeira no array) fazia toda divisa virar borda externa.\n    function featureEm(x,y,exceto){\n      if(!PROJECT) return null;\n      if(!HIT_GRID) buildHitGrid(PROJECT);\n      const i=Math.floor(x/HIT_GRID.cw), j=Math.floor(y/HIT_GRID.ch);\n      if(i<0||j<0||i>=HIT_N||j>=HIT_N) return null;\n      const cand=HIT_GRID.cells[j*HIT_N+i]; if(!cand) return null;\n      for(let k=0;k<cand.length;k++){ const f=cand[k], b=f.__bb;\n        if(f===exceto||!b||x<b.x0||x>b.x1||y<b.y0||y>b.y1) continue;\n        if(dentroDe(f,x,y,PROJECT)) return f;\n      }\n      return null;\n    }\n    // Texto do tooltip: a CIDADE, com a UF — homônimos existem, há seis \"Bom Jesus\" — e nada de\n    // nome de setor/GV/mercado, por decisão do Fernando em 15/09: esses nomes já estão nos rótulos\n    // do mapa e no painel lateral. Para município cinza, entra o diagnóstico de base que o cinza\n    // representa: três causas diferentes que mandariam quem corrige procurar em lugares diferentes.\n    function tooltipDe(f){\n      const pp=(f&&f.properties)||{};\n      const nome=pp.name||pp.NOME||pp.nome||'';\n      const uf=(f&&f.__uf)?(', '+f.__uf):'';\n      const mk=muniKeyOfFeature(f);\n      const outerHit=unitOfMuniKey(mk,LEVEL);\n      if(outerHit){\n        // MESMA dualidade outer/inner do clique, do hover e dos rótulos — E a mesma restrição do\n        // realce de hover: com algo ARMADO, só existe tooltip de escopo DENTRO da unidade\n        // selecionada (o resto já está apagado visualmente; nomear outra unidade ali sugeria que\n        // ela também estava \"no jogo\", quando clicar ali reinicia a seleção do zero).\n        if(SELECIONADO){\n          if(SELECIONADO.outerCode!==outerHit) return null;\n          const innerLevel=INNER_LEVEL[LEVEL];\n          const innerHit=innerLevel==='municipio'?mk:unitOfMuniKey(mk,innerLevel);\n          if(!innerHit) return null;\n          if(innerLevel==='municipio') return nome+uf;\n          const rec=unitRecord(innerHit,innerLevel);\n          return (rec&&rec.nome&&rec.nome!==innerHit?rec.nome+' — ':'')+innerHit;\n        }\n        // Sem seleção: nomeia o outer inteiro (Setor/GV/Mercado — o que o toggle mostra). Sem\n        // linha no CRM, ainda sobra o mínimo que a hierarquia sempre garante — o código. Em\n        // Mercado, rec.nome === code (a base não tem nome separado de mercado), então o nome só\n        // entra quando é informação nova — e sem o rótulo \"Mercado\"/\"GV\"/\"Setor\" na frente do\n        // código, a pedido: a unidade já está identificada pelo escopo ativo no painel esquerdo.\n        const rec=unitRecord(outerHit,LEVEL);\n        return (rec&&rec.nome&&rec.nome!==outerHit?rec.nome+' — ':'')+outerHit;\n      }\n      const temSetor=!!(DOM.setorByMuni&&DOM.setorByMuni[mk]);\n      const soNome=DOM.hasUf&&DOM.setorByMuni&&DOM.setorByMuni[mNome(mk)];\n      const motivo=temSetor\n        ? ('o setor deste município está sem '+(LEVEL==='gv'?'GV':'mercado')+' na base')\n        : (soNome?'o nome existe na base, mas sob outra UF (ou sem UF) — é a UF da linha que está errada'\n                 :'município sem setor na base de dominância');\n      return nome+uf+' — '+motivo;\n    }\n\n    // ---------- adjacência entre municípios ----------\n    // Uma passada sobre a malha inteira, no boot: dois municípios que compartilham um vértice fazem\n    // fronteira. Mesmo encaixe exato (grade de ~111 m) que o simulador usa dentro de uma UF.\n    // Limitação herdada e aceita: atravessando divisa de UF, os dois lados vêm de arquivos\n    // digitalizados separadamente e quase nunca caem nos mesmos vértices, então esses pares não\n    // são detectados. O simulador contorna isso com uma segunda checagem geométrica par a par, que\n    // aqui seria O(n²) sobre 5.564 municípios — inviável. O efeito da falha é só cosmético: duas\n    // unidades de UFs diferentes podem receber a mesma cor encostadas na divisa. Nenhuma regra\n    // depende disto.\n    let MUNI_NBRS=null;\n    function buildMuniAdjacency(){\n      const idx=new Map(), nbrs={};\n      const link=(a,b)=>{ if(!a||!b||a===b) return;\n        (nbrs[a]=nbrs[a]||new Set()).add(b); (nbrs[b]=nbrs[b]||new Set()).add(a); };\n      ALL_FEATURES.forEach(f=>{\n        const k=muniKeyOfFeature(f); if(!k) return;\n        const g=f.geometry; if(!g||!g.coordinates) return;\n        const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(poly=>poly.forEach(ring=>ring.forEach(p=>{\n          const vk=Math.round(p[0]*1000)+','+Math.round(p[1]*1000);\n          const prev=idx.get(vk);\n          // Guarda só o PRIMEIRO município de cada vértice. Num vértice compartilhado por três, o\n          // par entre o segundo e o terceiro escapa — irrelevante para colorir, e evita guardar\n          // uma lista por vértice numa estrutura que já é a mais pesada da tela.\n          if(prev===undefined) idx.set(vk,k); else link(prev,k);\n        })));\n      });\n      // idx sai de escopo aqui de qualquer jeito (link fecha sobre nbrs, não sobre idx), então este\n      // clear não é o que libera a memória — só antecipa. São ~400 mil entradas e algumas dezenas de\n      // MB transitórios, ao lado do GeoJSON recém-parseado; antecipar não custa nada.\n      idx.clear();\n      MUNI_NBRS=nbrs;\n      return nbrs;\n    }\n\n    // ---------- cor ----------\n    // 15/09/2026: saiu a paleta colorida por unidade — pedido do Fernando foi um estilo\n    // MONOCROMÁTICO, com a hierarquia mostrada por OPACIDADE em vez de matiz. Duas camadas: a\n    // unidade do ESCOPO atual (o nível externo — Setor, GV ou Mercado, o que o toggle mostra) recebe\n    // um tom-base por adjacência, e a unidade de UM nível abaixo dela (a \"interna\" — município\n    // dentro de setor, setor dentro de GV, GV dentro de mercado) recebe uma pequena variação em cima\n    // desse tom-base, também por adjacência — mas só contra vizinhos do MESMO pai: a fronteira entre\n    // pais já é a linha grossa (buildOutline), não precisa competir por tom.\n    // Por ADJACÊNCIA, não por hash do código, nas duas camadas: o hash distribui muitas unidades em\n    // poucos tons e vizinhos caem no mesmo tom com frequência alta — exatamente o que esta tela\n    // existe para mostrar. Duas unidades que fazem fronteira nunca recebem o mesmo tom enquanto\n    // houver tom livre no respectivo nível.\n    const MONO_COLOR='#0a5aa8';\n    const NODOM_COLOR='#e8e8ec';\n    // Cada MERCADO recebe seu PRÓPRIO matiz — nunca repetido, mesmo entre dois que não fazem\n    // fronteira. Uma paleta fixa obrigaria a reaproveitar tom após a N-ésima unidade (é o que o\n    // Welsh-Powell da opacidade faz DE PROPÓSITO ali embaixo, mas foi rejeitado aqui: \"cada\n    // mercado = sua própria cor\" é pedido explícito, então cor não pode reaproveitar). Em vez de\n    // paleta, o matiz é GERADO por índice: ângulo dourado (137,5°) multiplicado pela posição na\n    // ordem alfabética de UNITS.order.mercado. Ângulo IRRACIONAL: duas posições inteiras distintas\n    // nunca caem no mesmo grau, e a sequência cobre o círculo de forma bem espalhada sem precisar\n    // saber o total de mercados de antemão. Setor com dominância mas sem mercado na base (raro)\n    // cai no MONO_COLOR de sempre — contado em MAP_STATS.semMercadoCor, não escondido.\n    const MERC_HUE_STEP=137.50776405003785;\n    const MERC_SAT=0.60, MERC_LIT=0.50;\n    // HSL→RGB (0..1), sem depender de canvas nem de biblioteca.\n    function hslRgb(h,s,l){\n      const c=(1-Math.abs(2*l-1))*s, x=c*(1-Math.abs((h/60)%2-1)), m=l-c/2;\n      let r=0,g=0,b=0;\n      if(h<60){ r=c; g=x; } else if(h<120){ r=x; g=c; } else if(h<180){ g=c; b=x; }\n      else if(h<240){ g=x; b=c; } else if(h<300){ r=x; b=c; } else { r=c; b=x; }\n      return [r+m,g+m,b+m];\n    }\n    // Nível de baixo de cada escopo — a subdivisão que aparece \"por dentro\" da unidade do toggle.\n    // Município não é um nível do mapa (LEVEL nunca vale 'municipio'), mas é o de baixo de Setor;\n    // por isso as funções abaixo tratam 'municipio' como mais um nível, chaveado pela própria\n    // chave de município (m), não por um código agregado.\n    const INNER_LEVEL={setor:'municipio',gv:'setor',mercado:'gv'};\n    const unitAt=(m,level)=>level==='municipio'?m:unitOfMuniKey(m,level);\n    function allCodesAt(level){\n      if(level==='municipio') return Object.keys(DOM.setorByMuni||{});\n      return (UNITS.order&&UNITS.order[level])||[];\n    }\n    // Adjacência entre unidades de `level`; se `restrictLevel` vier preenchido, só conta a aresta\n    // quando os dois lados também compartilham a MESMA unidade em `restrictLevel` — é o que\n    // restringe a camada interna a só competir por tom dentro do mesmo pai.\n    function buildAdjacency(level,restrictLevel){\n      const adj={};\n      const touch=(a,b)=>{ if(!a||!b||a===b) return;\n        (adj[a]=adj[a]||new Set()).add(b); (adj[b]=adj[b]||new Set()).add(a); };\n      Object.keys(MUNI_NBRS||{}).forEach(m=>{\n        const ua=unitAt(m,level); if(!ua) return;\n        if(!adj[ua]) adj[ua]=new Set();\n        (MUNI_NBRS[m]||new Set()).forEach(o=>{\n          if(restrictLevel){\n            const pa=unitAt(m,restrictLevel), po=unitAt(o,restrictLevel);\n            if(!pa||!po||pa!==po) return;\n          }\n          touch(ua,unitAt(o,level));\n        });\n      });\n      return adj;\n    }\n    // Welsh-Powell genérico: devolve o ÍNDICE na paleta (não a cor em si), para caber tanto no tom\n    // externo quanto na variação interna com o mesmo algoritmo.\n    function welshPowellIdx(adj,codes,paletteLen){\n      codes.forEach(u=>{ if(!adj[u]) adj[u]=new Set(); });\n      const ordered=Object.keys(adj).sort((a,b)=>adj[b].size-adj[a].size);\n      const out={}; let conflitos=0;\n      ordered.forEach(u=>{\n        const used=new Set();\n        adj[u].forEach(o=>{ if(out[o]!=null) used.add(out[o]); });\n        let livre=-1;\n        for(let i=0;i<paletteLen;i++){ if(!used.has(i)){ livre=i; break; } }\n        if(livre>=0){ out[u]=livre; return; }\n        // Paleta esgotada: escolhe o índice que colide com o MENOR número de vizinhos, e conta o\n        // caso — este arquivo reporta todas as outras anomalias e esta não pode ser a única invisível.\n        conflitos++;\n        const viz=[...adj[u]];\n        let best=0,bestN=Infinity;\n        for(let i=0;i<paletteLen;i++){ const n=viz.filter(o=>out[o]===i).length; if(n<bestN){ bestN=n; best=i; } }\n        out[u]=best;\n      });\n      return {idx:out,conflitos:conflitos};\n    }\n    // Seis tons externos (do mais escuro ao mais claro) e quatro variações internas em torno do tom\n    // do pai. Combinados dão até 24 alfas distintos — poucos o bastante para continuar cabendo no\n    // mesmo truque de lotes-por-cor do motor (ver buildBatches): cada combinação é UM lote, não uma\n    // feature.\n    const OUTER_ALPHA=[0.82,0.68,0.55,0.42,0.30,0.20];\n    const INNER_DELTA=[0.13,0.045,-0.045,-0.13];\n    const OUTER_IDX_CACHE={}, OUTER_CONFLITOS={};\n    function outerIdxForLevel(level){\n      if(OUTER_IDX_CACHE[level]) return OUTER_IDX_CACHE[level];\n      const {idx,conflitos}=welshPowellIdx(buildAdjacency(level,null),allCodesAt(level),OUTER_ALPHA.length);\n      OUTER_CONFLITOS[level]=conflitos;\n      OUTER_IDX_CACHE[level]=idx;\n      return idx;\n    }\n    const INNER_IDX_CACHE={}, INNER_CONFLITOS={};\n    // Chaveado pelo ESCOPO (não pelo nível interno em si): o restrictLevel muda com o escopo.\n    function innerIdxForScope(outerLevel){\n      if(INNER_IDX_CACHE[outerLevel]) return INNER_IDX_CACHE[outerLevel];\n      const innerLevel=INNER_LEVEL[outerLevel];\n      const {idx,conflitos}=welshPowellIdx(buildAdjacency(innerLevel,outerLevel),allCodesAt(innerLevel),INNER_DELTA.length);\n      INNER_CONFLITOS[outerLevel]=conflitos;\n      INNER_IDX_CACHE[outerLevel]=idx;\n      return idx;\n    }\n    // Matiz por mercado — independe do ESCOPO ativo (LEVEL): olhando Setor, GV ou Mercado, o tom\n    // de cada município vem sempre do mercado do setor a que pertence; só a opacidade (acima)\n    // segue o toggle. Índice pela ORDEM ALFABÉTICA de UNITS.order.mercado (fixo por sessão) — não\n    // pela adjacência: aqui o objetivo é unicidade, não economia de tons.\n    let MERC_IDX_CACHE=null;\n    function mercadoIdxPorCodigo(){\n      if(MERC_IDX_CACHE) return MERC_IDX_CACHE;\n      const map={};\n      (UNITS.order.mercado||[]).forEach((c,i)=>{ map[c]=i; });\n      MERC_IDX_CACHE=map;\n      return map;\n    }\n    const MERC_RGB_CACHE=[];\n    function mercadoRgb(i){\n      if(!MERC_RGB_CACHE[i]) MERC_RGB_CACHE[i]=hslRgb((i*MERC_HUE_STEP)%360,MERC_SAT,MERC_LIT);\n      return MERC_RGB_CACHE[i];\n    }\n\n    // 20, e não 8: com o teto antigo o menor enquadramento possível tinha ~830 km de largura, e um\n    // setor típico (span alvo de 2.700 CBs, algo como 50 km de lado) ocupava 6% da tela. Buscar um\n    // setor CENTRALIZAVA sem aproximar — a busca entregava metade do que promete. Com a geometria\n    // na GPU em fidelidade total (Fase 7), o teto é só de utilidade: acima disto um setor ocupa\n    // várias telas. As linhas têm largura em pixels de dispositivo e a fonte é contra-escalada.\n    const MAP_W=700; let MAP_H=500; const MAP_ZOOM_MAX=20;\n    let mapView={x:0,y:0,w:MAP_W,h:MAP_H};\n    let PROJECT=null;\n    function project(feats){\n      let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n      const each=cb=>feats.forEach(f=>{const g=f&&f.geometry; if(!g||!g.coordinates) return; const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];ps.forEach(p=>p.forEach(r=>r.forEach(cb)));});\n      each(p=>{mnx=Math.min(mnx,p[0]);mxx=Math.max(mxx,p[0]);mny=Math.min(mny,p[1]);mxy=Math.max(mxy,p[1]);});\n      // Sem ponto nenhum, os extremos ficam em ±Infinity e a função resultante projeta TUDO para NaN —\n      // e é truthy, então ficaria em PROJECT até um resize a derrubar: mapa em branco com resumo feliz.\n      // null obriga quem chama a decidir; todos os chamadores já testam.\n      if(!isFinite(mnx)||!isFinite(mny)||!isFinite(mxx)||!isFinite(mxy)) return null;\n      const mlat=(mny+mxy)/2,kx=Math.cos(mlat*Math.PI/180),W=MAP_W,H=MAP_H,pad=22;\n      const gw=(mxx-mnx)*kx,gh=(mxy-mny),sc=Math.min((W-2*pad)/gw,(H-2*pad)/gh);\n      const ox=(W-gw*sc)/2,oy=(H-gh*sc)/2;\n      return p=>[ox+(p[0]-mnx)*kx*sc, oy+(mxy-p[1])*sc];\n    }\n    // A simplificação por zoom (Douglas-Peucker, depois quantização em grade) saiu na Fase 7: com a\n    // geometria na GPU em fidelidade total, não há mais ponto a economizar nem fresta a evitar.\n    function labelPointFor(fs,pr){\n      // O reamostrador era POR ANEL: um anel de 115 pontos dava floor(115/120)=0, ou seja step=1\n      // e nenhuma redução. Uma unidade grande entra com tudo — um mercado tem ~107 mil pontos\n      // espalhados em milhares de anéis pequenos — e a busca em grade abaixo vira dezenas de\n      // milhões de testes de segmento, congelando a tela no primeiro clique em \"Mercado\".\n      // O ponto de inacessibilidade é uma feição GROSSEIRA: quem o determina é o contorno\n      // principal, não as ilhotas. Ficam os anéis maiores, cada um reamostrado para poucos pontos,\n      // com custo limitado independentemente do nível.\n      const MAX_ANEIS=40, MAX_PTS_ANEL=60;\n      const brutos=[];\n      (fs||[]).forEach(f=>{ const g=f&&f.geometry; if(!g||!g.coordinates) return;\n        const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(poly=>poly.forEach(r=>{ if(r&&r.length>=4) brutos.push(r); }));});\n      if(!brutos.length) return null;\n      brutos.sort((a,b)=>b.length-a.length);\n      const rings=[];\n      brutos.slice(0,MAX_ANEIS).forEach(r=>{\n        const step=Math.max(1,Math.ceil(r.length/MAX_PTS_ANEL)),pts=[];\n        for(let i=0;i<r.length;i+=step) pts.push(pr(r[i]));\n        if(pts.length>3) rings.push(pts);\n      });\n      if(!rings.length) return null;\n      let mnx=1/0,mny=1/0,mxx=-1/0,mxy=-1/0;\n      rings.forEach(r=>r.forEach(p=>{ if(p[0]<mnx)mnx=p[0]; if(p[0]>mxx)mxx=p[0]; if(p[1]<mny)mny=p[1]; if(p[1]>mxy)mxy=p[1]; }));\n      // MAP_H é dinâmico (acompanha a proporção do card), então o teto fixo de 498 recortava a\n      // caixa de busca para FORA da própria unidade sempre que a janela fosse mais alta que larga:\n      // inside() falhava em todas as células e a unidade perdia o rótulo em silêncio.\n      mnx=Math.max(mnx,2); mny=Math.max(mny,2); mxx=Math.min(mxx,MAP_W-2); mxy=Math.min(mxy,MAP_H-2);\n      if(!(mxx>mnx&&mxy>mny)) return null;\n      const inside=(x,y)=>{ let c=false;\n        for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n          if(((a[1]>y)!==(b[1]>y)) && (x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])) c=!c; }\n        return c; };\n      const edgeDist=(x,y)=>{ let m=Infinity;\n        for(const r of rings) for(let i=0,j=r.length-1;i<r.length;j=i++){ const a=r[i],b=r[j];\n          const dx=b[0]-a[0],dy=b[1]-a[1],L=dx*dx+dy*dy;\n          let t=L?(((x-a[0])*dx+(y-a[1])*dy)/L):0; t=t<0?0:(t>1?1:t);\n          const ex=a[0]+t*dx-x,ey=a[1]+t*dy-y,d=ex*ex+ey*ey; if(d<m)m=d; }\n        return Math.sqrt(m); };\n      const W=mxx-mnx,H=mxy-mny,N=22;\n      let ccx=0,ccy=0,cn=0;\n      rings.forEach(r=>r.forEach(p=>{ ccx+=p[0]; ccy+=p[1]; cn++; }));\n      ccx/=cn||1; ccy/=cn||1;\n      const CENTER_PULL=0.22;\n      const scoreAt=(x,y,d)=>d-CENTER_PULL*Math.hypot(x-ccx,y-ccy);\n      let best=null,bd=-1,bs=-Infinity;\n      for(let i=0;i<=N;i++) for(let j=0;j<=N;j++){\n        const x=mnx+W*i/N,y=mny+H*j/N;\n        if(!inside(x,y)) continue;\n        const d=edgeDist(x,y),s=scoreAt(x,y,d); if(s>bs){ bs=s; bd=d; best=[x,y]; }\n      }\n      if(!best) return null;\n      return {p:best,r:bd};\n    }\n\n    function syncMapDims(){\n      const wrap=$('.map-wrap'); if(!wrap) return;\n      const cw=wrap.clientWidth, ch=wrap.clientHeight; if(!cw||!ch) return;\n      const newH=Math.round(MAP_W*(ch/cw));\n      if(Math.abs(newH-MAP_H)<2) return;\n      const fracW=mapView.w/MAP_W, cx=(mapView.x+mapView.w/2)/MAP_W, cy=(mapView.y+mapView.h/2)/MAP_H;\n      MAP_H=newH;\n      // project() lê MAP_H para calcular escala e deslocamento vertical, mas PROJECT só é computado\n      // quando está nulo. Sem esta invalidação, um resize que muda a PROPORÇÃO do container deixa a\n      // geometria projetada para a altura ANTIGA dentro de um viewBox de altura nova — mapa\n      // deslocado e fora de escala. Os dois caches caem junto porque são chaveados só por\n      // nível+unidade, sem nenhuma referência à projeção: mantidos, poriam cada rótulo na\n      // coordenada velha.\n      PROJECT=null;\n      Object.keys(LABEL_CACHE).forEach(k=>delete LABEL_CACHE[k]);\n      // A caixa envolvente da feature é memória da projeção TAMBÉM (hit-test do tooltip).\n      ALL_FEATURES.forEach(f=>{ if(f) f.__bb=undefined; });\n      HIT_GRID=null;\n      // A geometria na GPU é da projeção também: cai junto, e o próximo drawMap a remonta.\n      glInvalidate();\n      const w=Math.min(MAP_W,Math.max(MAP_W/MAP_ZOOM_MAX,MAP_W*fracW));\n      const h=Math.min(MAP_H,Math.max(MAP_H/MAP_ZOOM_MAX,MAP_H*fracW));\n      let x=cx*MAP_W-w/2, y=cy*MAP_H-h/2;\n      x=Math.min(MAP_W-w,Math.max(0,x)); y=Math.min(MAP_H-h,Math.max(0,y));\n      mapView={x,y,w,h};\n    }\n    // Halo e espaçamento acompanham o font-size: são unidades de viewBox, e sem contra-escalar viram\n    // 80 px de halo e letras esmagadas no zoom máximo (ver o comentário em .lbl text). Num helper\n    // porque DOIS lugares precisam dele: applyMapView (zoom/arrasto, grupo existente) e drawLabels\n    // (grupo recém-criado). Só no primeiro, o grupo novo nascia sem tamanho e herdava os 13px do\n    // .simroot — que em SVG são 13 UNIDADES: letras de centenas de pixels depois de toda busca.\n    // Tamanho FIXO em pixels de tela. A versão anterior escalava com a largura do painel (8 unidades\n    // de viewBox no zoom 1 → 17,7 px num painel de 1.550 px, 9,8 px num de 860): nome de cidade a\n    // 17 px em negrito cobria o mapa. 12,5 px é o de mapa de rua.\n    const LABEL_PX=12.5;\n    // unitsPerPx = unidades de viewBox por pixel de tela AGORA (mapView.w / largura do svg em px).\n    function estilizarRotulos(gl,unitsPerPx){\n      gl.setAttribute('font-size',(LABEL_PX*unitsPerPx).toFixed(3));\n      gl.setAttribute('stroke-width',(2*unitsPerPx).toFixed(4));\n      gl.setAttribute('letter-spacing',(-0.1*unitsPerPx).toFixed(4));\n    }\n    function unitsPerPxDe(svg){\n      const r=svg.getBoundingClientRect(); return r.width?mapView.w/r.width:0;\n    }\n    function applyMapView(){\n      const svg=$('#map'); if(!svg) return;\n      svg.setAttribute('viewBox', mapView.x.toFixed(2)+' '+mapView.y.toFixed(2)+' '+mapView.w.toFixed(2)+' '+mapView.h.toFixed(2));\n      // font-size, halo e espaçamento são herdados em SVG: basta escrevê-los no GRUPO de rótulos.\n      const gl=svg.querySelector('g.lbl'), upp=unitsPerPxDe(svg);\n      if(gl&&upp) estilizarRotulos(gl,upp);\n      // É AQUI que pan e zoom viram quadro: applyMapView roda a cada rAF do arrasto e a cada passo de\n      // roda, e o desenho na GPU custa uma matriz nova e algumas dezenas de draw calls.\n      glDraw();\n    }\n    // garantirCobertura saiu na Fase 7: sem culling não há área não desenhada — a GPU recorta sozinha.\n    function zoomMapAt(factor, clientX, clientY){\n      const svg=$('#gl'); if(!svg) return;\n      const rect=svg.getBoundingClientRect(); if(!rect.width||!rect.height) return;\n      const fx=(clientX-rect.left)/rect.width, fy=(clientY-rect.top)/rect.height;\n      const wx=mapView.x+fx*mapView.w, wy=mapView.y+fy*mapView.h;\n      let w=mapView.w/factor, h=mapView.h/factor;\n      w=Math.min(MAP_W, Math.max(MAP_W/MAP_ZOOM_MAX, w));\n      h=Math.min(MAP_H, Math.max(MAP_H/MAP_ZOOM_MAX, h));\n      let x=wx-fx*w, y=wy-fy*h;\n      x=Math.min(MAP_W-w, Math.max(0,x));\n      y=Math.min(MAP_H-h, Math.max(0,y));\n      mapView={x,y,w,h};\n      applyMapView();\n      scheduleRedraw();\n    }\n    let mapDrag=null, mapDidDrag=false;\n    function mapPointerDown(e){\n      const svg=$('#gl'); if(!svg) return;\n      mapDrag={x0:e.clientX,y0:e.clientY,vx0:mapView.x,vy0:mapView.y,rect:svg.getBoundingClientRect()};\n      mapDidDrag=false;\n      svg.classList.add('dragging');\n    }\n    let PAN_RAF=0, PAN_DX=0, PAN_DY=0;\n    function mapPointerMove(e){\n      if(!mapDrag) return;\n      const dxPix=e.clientX-mapDrag.x0, dyPix=e.clientY-mapDrag.y0;\n      // mapDidDrag é lido pelo supressor de clique e precisa valer no MESMO tick do evento — só a\n      // atualização da view é adiada.\n      if(!mapDidDrag && Math.hypot(dxPix,dyPix)>4) mapDidDrag=true;\n      if(!mapDrag.rect.width||!mapDrag.rect.height) return;\n      PAN_DX=dxPix; PAN_DY=dyPix;\n      // Um mouse de alta taxa dispara ~100 eventos por segundo e o navegador pinta ~60. Sem este\n      // acoplamento ao quadro, cada evento força um viewBox novo e uma rasterização de ~11 mil\n      // paths que ninguém chega a ver.\n      if(PAN_RAF) return;\n      PAN_RAF=requestAnimationFrame(()=>{\n        PAN_RAF=0;\n        if(!mapDrag) return;\n        const sx=mapView.w/mapDrag.rect.width, sy=mapView.h/mapDrag.rect.height;\n        let x=mapDrag.vx0-PAN_DX*sx, y=mapDrag.vy0-PAN_DY*sy;\n        x=Math.min(MAP_W-mapView.w, Math.max(0,x));\n        y=Math.min(MAP_H-mapView.h, Math.max(0,y));\n        mapView={...mapView,x,y};\n        applyMapView();\n      });\n    }\n    function mapPointerUp(){\n      if(!mapDrag) return;\n      mapDrag=null;\n      const svg=$('#gl'); if(svg) svg.classList.remove('dragging');\n      // Arrastar muda o enquadramento, e o corte de rótulo por enquadramento é avaliado no desenho:\n      // sem isto, a área que entrou na tela ficaria sem rótulo até a próxima interação que\n      // redesenhasse.\n      scheduleRedraw();\n    }\n    function fitMapHeight(){\n      const wrap=$('.map-wrap'); if(!wrap) return;\n      const top=wrap.getBoundingClientRect().top;\n      if(top<=0) return;\n      const margin=24;\n      wrap.style.height=Math.max(320, window.innerHeight-top-margin)+'px';\n    }\n\n    // As camadas OSM (rodovias, rios, aeroportos) saíram daqui em 15/09/2026, junto com o ícone\n    // hambúrgueR que as abria — o lugar dele passou a ser o campo de busca. A Home é porta de\n    // entrada; rodovia e rio servem para DECIDIR um fechamento, e isso acontece na tela de\n    // trabalho, que continua com essas camadas intactas. De quebra, a Home deixou de depender do\n    // Overpass logo na entrada do app.\n    // Tudo o que foi removido — as três queries Overpass, as oito decisões que custaram iteração\n    // para descobrir (uma query só para as quatro classes viárias, só rio permanente, só aeroporto\n    // com código IATA, ordem de desenho por importância, halo só em rio, teto de área, retry com\n    // backoff, e o porquê do proxy) — está registrado em\n    // \"Home - camadas OSM removidas (contexto para recriar).md\", na pasta do projeto. Para\n    // recriar, copiar do simulador, que tem a versão mais completa e mais comentada.\n\n    const DEFAULT_SETTINGS={span:{\n      setor:{targetSpan:2700,maxSpan:3500,minSpan:2200},\n      gv:{targetSpan:16000,maxSpan:22000,minSpan:10000},\n      mercado:{targetSpan:null,maxSpan:null,minSpan:null},\n    }, forceContiguity:true};\n    const LEVEL_LABEL={setor:'Setor',gv:'GV',mercado:'Mercado'};\n\n    // openInSimulator saiu em 15/09/2026. Ela criava um registro em geo-timeline-sessions com a\n    // receita `movements:[{sector,level}]` e navegava para /?session=<id>. NÃO funcionava, e o\n    // motivo é conceitual: loadSession RESTAURA um cenário já calculado e congelado\n    // (hydrateFromFrozen copia resultado pronto e nunca recalcula), então um registro só com a\n    // receita hidratava uma etapa VAZIA — e, sem snapshotRaw, ainda abria travada. No produto,\n    // sessão é o que se SALVA no fim, não o que inicia o trabalho.\n    // O botão do rodapé do painel navega com a INTENÇÃO na URL (?fechar=setor:3504) e deixa o\n    // simulador chamar o addMovement que ele já tem. A Home não grava nada em lugar nenhum — o que\n    // também elimina a sessão órfã que a versão anterior criava a cada clique no mapa.\n    let LEVEL='mercado';\n    // {outerCode, level, code}: outerCode é a unidade do ESCOPO (LEVEL) já \"armada\" por um primeiro\n    // clique — só um segundo clique DENTRO dela seleciona o nível de baixo (INNER_LEVEL[LEVEL]).\n    // level/code é o que está de fato selecionado: o próprio outer (outerCode===code) ou o inner\n    // dentro dele. Vem tanto do clique no mapa quanto da busca — a busca sempre seleciona o outer,\n    // nunca entra direto num inner.\n    let SELECIONADO=null;\n\n    // ---------- busca ----------\n    // Exigir que o usuário saiba ONDE fica o setor 3184 no mapa do Brasil é pedir uma habilidade\n    // que nem o dono do processo tem: setor é código administrativo, não lugar. Até aqui a única\n    // forma de achar era varrer o mapa com o olho — o que só funciona para quem já sabe a resposta.\n    // Inclui o apóstrofo: hoje nenhum campo interpolado cai dentro de atributo com aspas simples,\n    // mas \"escapa quatro caracteres\" é exatamente o tipo de meia-proteção que convida a próxima\n    // linha a escrever title='…' com dado da base dentro.\n    const esc=s=>String(s==null?'':s).replace(/[&<>\"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]));\n    const SR_LABEL={setor:'Setores',gv:'GVs',mercado:'Mercados',municipio:'Municípios'};\n    // Ordem dos grupos no dropdown: o nível ATIVO vem primeiro — é o que a pessoa está olhando\n    // quando digita —, os outros dois níveis-unidade na ordem fixa de sempre, e município por\n    // último: não é um nível do mapa, é a categoria mais numerosa (~5,5 mil) e a busca por cidade\n    // sempre resolve para um SETOR (ver escolher()).\n    const SR_BASE=['setor','gv','mercado'];\n    function ordemGrupos(){\n      const i=SR_BASE.indexOf(LEVEL);\n      const base=i<0?SR_BASE:SR_BASE.slice(i).concat(SR_BASE.slice(0,i));\n      return base.concat(['municipio']);\n    }\n    // Teto POR CATEGORIA, não um teto global. A versão anterior cortava em 40 sobre a lista\n    // inteira já misturada: \"37\" casa com dezenas dos 617 códigos de setor (4 dígitos), e esses\n    // sozinhos enchiam as 40 posições antes de qualquer GV, mercado ou município ser sequer\n    // considerado — a tela se anuncia como \"buscar setor, GV, mercado ou município\" e entregava só\n    // setor. Com teto por grupo as quatro categorias sempre disputam vaga entre si, não umas com\n    // as outras.\n    const SR_MAX_POR_GRUPO=8;\n    let SR_ITENS=[], SR_IDX=-1;\n    function buscar(q){\n      const qn=norm(q);\n      if(!qn) return {};\n      const porKind={setor:[],gv:[],mercado:[],municipio:[]};\n      for(let i=0;i<SEARCH.length;i++){\n        const it=SEARCH[i];\n        const pos=it.norm.indexOf(qn);\n        if(pos<0) continue;\n        // Casar no INÍCIO vale mais que casar no meio: quem digita \"estrel\" quer ESTRELÍCIA antes\n        // de um nome que contenha essas letras no miolo.\n        porKind[it.kind].push({it:it,peso:(pos===0?0:10)+pos*0.001});\n      }\n      // NÃO existe teto de varredura aqui, e a ausência é deliberada: SEARCH está em ordem fixa\n      // (setores → GVs → mercados → ~5,5 mil municípios), então cortar a varredura por contagem\n      // estouraria o limite ainda dentro dos 617 setores em qualquer consulta curta — e NENHUM\n      // município chegaria a ser avaliado. São ~7 mil indexOf por tecla, irrelevante perto dos\n      // 11 mil nós que o mapa redesenha. O corte é DEPOIS, categoria por categoria.\n      const out={};\n      Object.keys(porKind).forEach(k=>{\n        const arr=porKind[k];\n        arr.sort((a,b)=>a.peso-b.peso||a.it.label.length-b.it.label.length);\n        out[k]=arr.slice(0,SR_MAX_POR_GRUPO).map(o=>o.it);\n      });\n      return out;\n    }\n    // Limpa os itens junto, e não só o índice: com a lista fechada mas SR_ITENS cheio, uma seta\n    // para cima passava pelo guard e calculava (-1 + n-1) % n = n-2, reabrindo a lista no\n    // penúltimo item em vez do último.\n    function fecharBusca(){ const r=$('#searchRes'); if(r) r.classList.add('hidden'); SR_ITENS=[]; SR_IDX=-1; }\n    function renderBusca(){\n      const cx=$('#searchRes'); if(!cx) return;\n      const q=(($('#searchInput')||{}).value||'').trim();\n      if(!q){ cx.innerHTML=''; cx.classList.add('hidden'); return; }\n      if(!SEARCH.length){\n        cx.innerHTML='<div class=\"sr-vazio\">o índice de busca não carregou — veja a mensagem no cabeçalho</div>';\n        cx.classList.remove('hidden'); return;\n      }\n      if(!SR_ITENS.length){\n        cx.innerHTML='<div class=\"sr-vazio\">nada encontrado para “'+esc(q)+'”</div>';\n        cx.classList.remove('hidden'); return;\n      }\n      // SR_ITENS já chega nesta ordem — grupo por grupo, na sequência de ordemGrupos() — porque\n      // abrirBusca() monta assim; aqui só detecta a virada de categoria para inserir o cabeçalho,\n      // sem reagrupar nem reordenar de novo.\n      let html='', kindAtual=null;\n      SR_ITENS.forEach((it,i)=>{\n        if(it.kind!==kindAtual){ kindAtual=it.kind; html+='<div class=\"sr-grp\">'+SR_LABEL[kindAtual]+'</div>'; }\n        html+='<div class=\"sr-item'+(i===SR_IDX?' on':'')+'\" data-i=\"'+i+'\" role=\"option\">'\n          +'<span class=\"sr-nm\">'+esc(it.label)+'</span>'\n          +'<span class=\"sr-sub\">'+esc(it.sub)+'</span></div>';\n      });\n      cx.innerHTML=html;\n      cx.classList.remove('hidden');\n      cx.querySelectorAll('.sr-item').forEach(el=>{ el.onclick=()=>escolher(SR_ITENS[+el.dataset.i]); });\n    }\n    function abrirBusca(){\n      const inp=$('#searchInput'), cl=$('#searchClear');\n      const q=inp?inp.value:'';\n      if(cl) cl.classList.toggle('hidden',!q);\n      const porKind=buscar(q);\n      SR_ITENS=[]; ordemGrupos().forEach(k=>{ (porKind[k]||[]).forEach(it=>SR_ITENS.push(it)); });\n      SR_IDX=SR_ITENS.length?0:-1;\n      renderBusca();\n    }\n    // Features de uma unidade. Varre ALL_FEATURES em vez de MUNI_NBRS porque este último só tem\n    // município com pelo menos um vizinho detectado — ilha ficaria de fora justamente do\n    // enquadramento.\n    // Verdadeiro se ALGUM dono real do município (DOM.setoresByMuni, não só o primeiro que\n    // unitOfFeature usa para pintura) resolve para `code` neste nível — sem isto, um setor cujo\n    // único município é compartilhado com outro setor \"mais cedo\" no CSV (Recife etc.) nunca\n    // encontrava suas próprias features, mesmo possuindo uma fatia real do município.\n    function unidadeContemMunicipio(f,code,level){\n      const mk=muniKeyOfFeature(f);\n      const owners=DOM.setoresByMuni&&DOM.setoresByMuni[mk];\n      if(!owners||!owners.size) return unitOfFeature(f,level)===code;\n      for(const raw of owners){\n        const sc=secKey(raw); if(!sc) continue;\n        if(level==='setor'){ if(sc===code) return true; }\n        else if(level==='gv'){ if(secKey((DOM.gvBySetor||{})[raw])===code) return true; }\n        else { if(txt((DOM.mercadoBySetor||{})[raw])===code) return true; }\n      }\n      return false;\n    }\n    function featuresDaUnidade(code,level){ return ALL_FEATURES.filter(f=>unidadeContemMunicipio(f,code,level)); }\n    // Blocos contíguos da unidade, do maior para o menor. Território de setor pode ser disperso: um\n    // município solto a centenas de quilômetros do miolo faria a caixa envolvente cobrir meio estado\n    // e o zoom resultante seria inútil. Enquadra-se o maior bloco, e o resto é AVISADO em vez de\n    // simplesmente sair de vista.\n    function bboxDe(fs,pr){\n      let x0=1/0,y0=1/0,x1=-1/0,y1=-1/0;\n      (fs||[]).forEach(f=>{ const g=f&&f.geometry; if(!g||!g.coordinates) return;\n        const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(poly=>poly.forEach(r=>r.forEach(p=>{ const q=pr(p);\n          if(q[0]<x0)x0=q[0]; if(q[0]>x1)x1=q[0]; if(q[1]<y0)y0=q[1]; if(q[1]>y1)y1=q[1]; })));});\n      return isFinite(x0)?{x0:x0,y0:y0,x1:x1,y1:y1}:null;\n    }\n    function blocosDaUnidade(fs){\n      const chaves=fs.map(f=>muniKeyOfFeature(f));\n      const dentro=new Set(chaves), porChave={};\n      // Lista por chave, e não um por chave: no modo degradado (base sem UF) os seis \"BOM JESUS\"\n      // de estados diferentes colapsam numa chave só, e guardar um deles descartava os outros\n      // cinco — o bloco saía com uma feature, o aviso de \"ficaram fora\" dizia zero, e o\n      // enquadramento ia parar no município que por acaso ficou por último no array, podendo ser\n      // o do Piauí quando o usuário buscou o setor gaúcho.\n      fs.forEach((f,i)=>{ (porChave[chaves[i]]=porChave[chaves[i]]||[]).push(f); });\n      const visto=new Set(); let blocos=[];\n      chaves.forEach(k=>{\n        if(visto.has(k)) return;\n        visto.add(k);\n        const pilha=[k], bloco=[];\n        while(pilha.length){\n          const x=pilha.pop();\n          if(porChave[x]) bloco.push.apply(bloco,porChave[x]);\n          ((MUNI_NBRS&&MUNI_NBRS[x])||new Set()).forEach(o=>{\n            if(dentro.has(o)&&!visto.has(o)){ visto.add(o); pilha.push(o); }\n          });\n        }\n        blocos.push(bloco);\n      });\n      // MUNI_NBRS não detecta vizinhança atravessando divisa de UF — os dois lados vêm de arquivos\n      // digitalizados em separado e não compartilham vértice. Isso era COSMÉTICO enquanto servia só\n      // para colorir. Aqui deixa de ser: toda GV ou mercado que encosta em mais de um estado se\n      // partiria num bloco por estado, o enquadramento mostraria uma fatia só e o status acusaria\n      // municípios \"fora\" de uma unidade perfeitamente contígua. Funde blocos cujas caixas\n      // envolventes se tocam: são poucos blocos por unidade, então o laço quadrático é trivial, e a\n      // fusão erra sempre para o lado seguro (um exclave de verdade tem caixa separada).\n      const pr=PROJECT;\n      if(pr && blocos.length>1){\n        const bbs=blocos.map(b=>bboxDe(b,pr));\n        const TOL=4;   // unidades do viewBox, ~25 km: a folga típica entre duas malhas estaduais\n        let mudou=true;\n        while(mudou){\n          mudou=false;\n          for(let i=0;i<blocos.length&&!mudou;i++){\n            for(let j=i+1;j<blocos.length&&!mudou;j++){\n              const a=bbs[i],b=bbs[j];\n              if(!a||!b) continue;\n              if(a.x1+TOL<b.x0||b.x1+TOL<a.x0||a.y1+TOL<b.y0||b.y1+TOL<a.y0) continue;\n              blocos[i]=blocos[i].concat(blocos[j]);\n              bbs[i]={x0:Math.min(a.x0,b.x0),y0:Math.min(a.y0,b.y0),x1:Math.max(a.x1,b.x1),y1:Math.max(a.y1,b.y1)};\n              blocos.splice(j,1); bbs.splice(j,1); mudou=true;\n            }\n          }\n        }\n      }\n      return blocos.sort((a,b)=>b.length-a.length);\n    }\n    function enquadrar(fs){\n      if(!fs||!fs.length) return;\n      // Garante a projeção em vez de desistir calado. syncMapDims anula PROJECT sempre que a\n      // proporção do container muda, e o handler de resize adia o redesenho em 250 ms — um Enter na\n      // busca dentro dessa janela caa num return silencioso: a seleção acontecia, o status dizia\n      // \"enquadrado o maior bloco\" e a câmera não se mexia. É a mesma linha que drawMap executa.\n      if(!PROJECT) PROJECT=project(ALL_FEATURES);\n      const pr=PROJECT; if(!pr) return;\n      const bb=bboxDe(fs,pr); if(!bb) return;\n      const x0=bb.x0,y0=bb.y0,x1=bb.x1,y1=bb.y1;\n      const folga=Math.max(x1-x0,y1-y0)*0.18+4;\n      let w=Math.max((x1-x0)+folga*2, MAP_W/MAP_ZOOM_MAX);\n      let h=Math.max((y1-y0)+folga*2, MAP_H/MAP_ZOOM_MAX);\n      // Mantém a proporção do viewBox — esticar um eixo só distorceria o mapa inteiro.\n      const asp=MAP_W/MAP_H;\n      if(w/h>asp) h=w/asp; else w=h*asp;\n      w=Math.min(w,MAP_W); h=Math.min(h,MAP_H);\n      let x=(x0+x1)/2-w/2, y=(y0+y1)/2-h/2;\n      x=Math.min(MAP_W-w,Math.max(0,x)); y=Math.min(MAP_H-h,Math.max(0,y));\n      mapView={x:x,y:y,w:w,h:h};\n      applyMapView();\n    }\n    // Texto de status de uma unidade, usado pelo clique no mapa e pela busca. A contagem de\n    // municípios vem das FEATURES desenhadas, nunca de rec.munis: esse campo só existe em setor —\n    // GV e mercado guardam munisSoma, que é a soma dos municípios dos setores e não o número de\n    // municípios distintos da unidade (uma capital é repartida entre dezenas de setores). Lendo\n    // rec.munis nesses níveis o `||0` engolia a ausência e a barra anunciava \"0 município(s)\" —\n    // logo ao lado de um aviso dizendo que 340 deles ficaram fora do enquadramento.\n    function statusDaUnidade(code,nivel,nMunis,fora){\n      const rec=unitRecord(code,nivel);\n      return LEVEL_LABEL[nivel]+' '+code+(rec&&rec.nome?' · '+rec.nome:'')\n        +(rec?' · '+fmtN(rec.cbs)+' CBs':'')\n        +(nMunis?' · '+fmtN(nMunis)+' município(s) no mapa':'')\n        +(fora?' — enquadrado o maior bloco; '+fmtN(fora)+' município(s) desta unidade ficaram fora':'');\n    }\n    // Seleção de uma unidade que JÁ está no nível ativo (clique no mapa): destaca e informa, sem\n    // reenquadrar — quem clicou já está olhando para o lugar certo, mover a câmera debaixo do\n    // cursor só desorienta.\n    // ---------- painel lateral ----------\n    const SB_KIND={setor:'Setor',gv:'Gerência de vendas',mercado:'Mercado'};\n    function ufsDe(fs){\n      const s=new Set(); (fs||[]).forEach(f=>{ if(f&&f.__uf) s.add(f.__uf); });\n      return [...s].sort();\n    }\n    // Barra da banda operacional. As faixas são as mesmas que o motor aplica ao decidir uma\n    // redistribuição (setor 2.200–2.700–3.500 CBs, GV 10.000–16.000–22.000), então o que o painel\n    // mostra aqui é exatamente o critério que a tela de trabalho vai cobrar depois — não um\n    // indicador paralelo inventado para a entrada.\n    function bandaHTML(cbs,banda){\n      if(!banda||!banda.minSpan||!banda.maxSpan) return '';\n      const lo=banda.minSpan*0.6, hi=banda.maxSpan*1.25, larg=hi-lo;\n      const pct=v=>Math.max(0,Math.min(100,(v-lo)/larg*100));\n      const abaixo=cbs<banda.minSpan, acima=cbs>banda.maxSpan;\n      const cls=abaixo?'baixo':(acima?'alto':'ok');\n      // `veredito`, e não `txt`: há um helper txt() no escopo do módulo, e declarar um const com o\n      // mesmo nome aqui o deixa em zona morta temporal da entrada da função até esta linha — a\n      // próxima linha que chamasse txt() aqui dentro estouraria sem motivo aparente.\n      const veredito=abaixo?('abaixo do mínimo em '+fmtN(banda.minSpan-cbs)+' CBs')\n        :(acima?('acima do máximo em '+fmtN(cbs-banda.maxSpan)+' CBs'):'dentro da banda');\n      const nota='mín '+fmtN(banda.minSpan)\n        +(banda.targetSpan?' · alvo '+fmtN(banda.targetSpan):'')\n        +' · máx '+fmtN(banda.maxSpan)+' — a marca escura é onde esta unidade está hoje';\n      return '<div class=\"sb-sec\">Banda operacional</div><div class=\"sb-banda\">'\n        +'<div class=\"sb-veredito '+cls+'\">'+esc(veredito)+'</div>'\n        +'<div class=\"sb-barra\">'\n        +'<div class=\"sb-faixa\" style=\"left:'+pct(banda.minSpan).toFixed(1)+'%;right:'+(100-pct(banda.maxSpan)).toFixed(1)+'%\"></div>'\n        +(banda.targetSpan?'<div class=\"sb-alvo\" style=\"left:'+pct(banda.targetSpan).toFixed(1)+'%\"></div>':'')\n        +'<div class=\"sb-marca\" style=\"left:'+pct(cbs).toFixed(1)+'%\"></div>'\n        +'</div><div class=\"sb-nota\">'+esc(nota)+'</div></div>';\n    }\n    // Escapa o VALOR também, não só o rótulo. Hoje todos os oito pontos de chamada passam fmtN() de\n    // campo numérico, então não há buraco — mas \"escapa metade\" é a meia-proteção que convida o\n    // próximo kpi(rec.nome, 'GV') a virar injeção.\n    function kpi(n,l){ return '<div class=\"sb-kpi\"><div class=\"n\">'+esc(n)+'</div><div class=\"l\">'+esc(l)+'</div></div>'; }\n    function renderSidebar(fsOpt){\n      const sb=$('#sidebar'), hd=$('#sbHead'), bd=$('#sbBody'), ft=$('#sbFoot');\n      if(!sb||!hd||!bd||!ft) return;\n      // O painel existe enquanto a seleção pertence ao escopo atual OU ao nível de baixo dele —\n      // trocar de escopo zera SELECIONADO (ver o handler do toggle), esta checagem é só para não\n      // deixar um painel órfão no meio de uma transição.\n      if(!SELECIONADO||(SELECIONADO.level!==LEVEL&&SELECIONADO.level!==INNER_LEVEL[LEVEL])){ sb.classList.add('hidden'); return; }\n      const nivel=SELECIONADO.level, code=SELECIONADO.code;\n      // Município não é uma UNIDADE com KPI (não está em UNITS) — é só o de baixo do escopo Setor.\n      // Painel mínimo: nome, UF e o setor que o atende. Sem grade de KPI e sem botão de fechamento,\n      // que não fazem sentido nesta granularidade.\n      if(nivel==='municipio'){\n        const uf=mUf(code), nome=(DOM.muniLabel&&DOM.muniLabel[code])||mNome(code);\n        const sc=secKey((DOM.setorByMuni||{})[code]), rec2=UNITS.setor[sc];\n        hd.innerHTML='<div class=\"sb-kind\">Município</div>'\n          +'<div class=\"sb-nome\">'+esc(nome)+'</div>'\n          +'<div class=\"sb-cod\">'+esc(uf?('UF '+uf):'')+'</div>';\n        bd.innerHTML='<div class=\"sb-caminho\">pertence ao <b>Setor '+esc(sc||'?')+'</b>'\n          +(rec2&&rec2.nome?' · '+esc(rec2.nome):'')+(rec2&&rec2.nomeGv?' · '+esc(rec2.nomeGv):'')+'</div>';\n        ft.classList.add('hidden');\n        sb.classList.remove('hidden');\n        return;\n      }\n      const rec=unitRecord(code,nivel);\n      // fsOpt vem de quem já varreu ALL_FEATURES — sem isso, cada seleção varria as 5.564 features\n      // duas vezes, uma aqui e outra no status.\n      const fs=fsOpt||featuresDaUnidade(code,nivel);\n      const ufs=ufsDe(fs);\n      hd.innerHTML='<div class=\"sb-kind\">'+SB_KIND[nivel]+'</div>'\n        +'<div class=\"sb-nome\">'+esc((rec&&rec.nome)||(SB_KIND[nivel]+' '+code))+'</div>'\n        +'<div class=\"sb-cod\">código '+esc(code)+(ufs.length?' · '+esc(ufs.join(', ')):'')+'</div>';\n      // Fechamento agora vale para os três níveis (pedido do usuário) — antes só setor oferecia\n      // o botão; GV e mercado ficam sujeitos às MESMAS duas condições abaixo (território\n      // desenhado + linha no CRM), não a uma checagem extra de nível.\n      let h='', podeFechar=true;\n      if(!fs.length){\n        // Sem território desenhado não há o que fechar: o motor redistribui municípios, e não existe\n        // nenhum aqui. Oferecer o botão faria o usuário recarregar a página inteira só para receber\n        // o erro do outro lado.\n        // Setor 3322 (pedido do usuário) mostrava esta mensagem mesmo tendo o município certinho na\n        // base e desenhado sem problema no simulador (mesma malha, mesma base) — o problema real era\n        // a malha de UMA UF não ter baixado nesta sessão específica (loadAllGeo, 27 fetches em\n        // paralelo, falha individual sempre silenciosa por desenho), não a base estar errada.\n        const munisEsperados=csvMunisDaUnidade(code,nivel,rec);\n        const ufsEsperadas=[...new Set(munisEsperados.map(m=>mUf(m)).filter(Boolean))];\n        const ufsFaltando=ufsEsperadas.filter(u=>GEO_FAILED_UFS.indexOf(u)>=0);\n        if(ufsFaltando.length){\n          h+='<div class=\"sb-nota\">A malha do IBGE de '+esc(ufsFaltando.join(', '))+' não carregou nesta '\n            +'sessão (falha de rede) — por isso nada aparece aqui, não é problema na base. Recarregue a página.</div>';\n        } else {\n          // Aponta o(s) NOME(S) exato(s) que a base espera, em vez de só dizer \"pode ser grafia\" —\n          // é exatamente o que falta para diferenciar um mismatch de grafia real (corrigível na tabela\n          // de correções de nome de município, gb9Mu-5_9AcCDK-myWwSy) de qualquer outra causa, sem\n          // precisar abrir o CSV ou o Databricks pra descobrir qual nome bateu ou não.\n          const nomes=munisEsperados.map(m=>{ const n=(DOM.muniLabel&&DOM.muniLabel[m])||mNome(m); const u=mUf(m); return n+(u?' ('+u+')':''); });\n          h+='<div class=\"sb-nota\">Esta unidade não tem nenhum município desenhado no mapa. A base atribui a ela '\n            +(nomes.length?esc(nomes.join(', ')):'nenhum município')\n            +' — nenhum desses nomes bateu com a malha do IBGE. Costuma ser grafia divergente (confira/adicione uma '\n            +'correção na tabela de nomes de município) ou UF errada na base.</div>';\n        }\n        podeFechar=false;\n      } else if(!rec){\n        // Duas causas muito diferentes cairiam na mesma frase. \"A consulta falhou\" manda recarregar;\n        // \"a base está na frente do CRM\" manda atualizar a base. Dizer a segunda quando foi a\n        // primeira custa a investigação inteira.\n        h+='<div class=\"sb-nota\">'+(UNITS.error\n          ? 'Os números não carregaram nesta sessão: '+esc(UNITS.error)\n          : 'Esta unidade está na base de dominância mas não tem linha no CRM — sem números para '\n            +'mostrar. Normalmente significa base desatualizada.')+'</div>';\n        podeFechar=false;\n      } else {\n        // Caminho completo, sempre da base de dominância — é a hierarquia que o motor usa. Mercado\n        // e GV são cliqueáveis (crumb): trocam de escopo para aquele nível e selecionam a unidade —\n        // a própria unidade atual (em negrito) não precisa, já é o que está selecionado.\n        const crumb=(nv,cd,label)=>cd?('<span class=\"sb-crumb\" data-nivel=\"'+nv+'\" data-code=\"'+esc(cd)+'\" role=\"button\" tabindex=\"0\">'+esc(label)+'</span>'):esc(label);\n        if(nivel==='setor'){\n          h+='<div class=\"sb-caminho\">'+crumb('mercado',rec.mercado,rec.mercado||'sem mercado')+' › <b>'\n            +crumb('gv',rec.gv,rec.nomeGv||('GV '+rec.gv))+'</b> › '+esc(rec.nome||code)+'</div>';\n        } else if(nivel==='gv'){\n          h+='<div class=\"sb-caminho\">'+crumb('mercado',rec.mercado,rec.mercado||'sem mercado')+' › <b>'+esc(rec.nome||code)+'</b></div>';\n        }\n        h+='<div class=\"sb-grid\">';\n        h+=kpi(fmtN(rec.cbs),'CBs');\n        h+=kpi(fmtN(rec.ativas),'ativas no ciclo');\n        if(nivel==='setor'){\n          h+=kpi(fmtN(fs.length),'municípios no mapa');\n          h+=kpi(fmtN(rec.grupos),'grupos (líderes)');\n        } else if(nivel==='gv'){\n          h+=kpi(fmtN((rec.setores||[]).length),'setores');\n          h+=kpi(fmtN(fs.length),'municípios no mapa');\n        } else {\n          h+=kpi(fmtN((rec.gvs||[]).length),'GVs');\n          h+=kpi(fmtN((rec.setores||[]).length),'setores');\n        }\n        h+='</div>';\n        h+=bandaHTML(rec.cbs,(DEFAULT_SETTINGS.span||{})[nivel]);\n        // Setor sem GV na base: dito explicitamente, porque a ausência de GV também tira a migalha\n        // de pão correspondente do breadcrumb sem nenhum outro aviso.\n        if(nivel==='setor'&&!rec.gv)\n          h+='<div class=\"sb-nota\" style=\"margin-top:12px\">⚠ Este setor está sem GV na base.</div>';\n      }\n      if(UNITS.snapshot) h+='<div class=\"sb-nota\" style=\"margin-top:14px\">números do snapshot de '+esc(fmtSnapshot(UNITS.snapshot))+'</div>';\n      bd.innerHTML=h;\n      // innerHTML apaga qualquer listener anterior — precisa reatar a cada render, igual à busca.\n      bd.querySelectorAll('.sb-crumb').forEach(el=>{\n        el.onclick=()=>irParaUnidade(el.dataset.nivel,el.dataset.code,el.textContent);\n      });\n      // Texto do botão segue o nível selecionado — \"desta GV\" concorda com GERÊNCIA (feminino),\n      // \"deste setor\"/\"deste mercado\" com o substantivo masculino de cada um.\n      if(podeFechar){\n        const sbFecharBtn=$('#sbFechar');\n        if(sbFecharBtn) sbFecharBtn.textContent='Simular fechamento '+({setor:'deste setor',gv:'desta GV',mercado:'deste mercado'}[nivel]||'');\n      }\n      // O botão só aparece quando há o que fechar de verdade (território desenhado + dado no CRM).\n      ft.classList.toggle('hidden', !podeFechar);\n      sb.classList.remove('hidden');\n    }\n    // Nome + UF do município, e o setor que o atende — usado só quando a seleção desce um nível\n    // abaixo de Setor (a própria cidade). Município não está em UNITS (não tem CB nem KPI), então\n    // não reaproveita statusDaUnidade.\n    function statusDoMunicipio(mk){\n      const nome=(DOM.muniLabel&&DOM.muniLabel[mk])||mNome(mk), uf=mUf(mk);\n      const sc=secKey((DOM.setorByMuni||{})[mk]);\n      return 'Município '+nome+(uf?' ('+uf+')':'')+' — setor '+(sc||'?');\n    }\n    // Seleciona uma unidade — o outer do escopo (outerCode===code) ou o inner dentro dele, uma vez\n    // que o outer já estava \"armado\" (ver o clique no mapa). Sem reenquadrar: quem clicou já está\n    // olhando para o lugar certo, mover a câmera debaixo do cursor só desorienta.\n    function selecionarUnidade(nivel,code,outerCode){\n      SELECIONADO={outerCode:outerCode,level:nivel,code:code};\n      HOVER=null;\n      drawMap();\n      if(nivel==='municipio'){\n        renderSidebar();\n        setStatus(statusDoMunicipio(code));\n        return;\n      }\n      const fs=featuresDaUnidade(code,nivel);\n      renderSidebar(fs);\n      setStatus(statusDaUnidade(code,nivel,fs.length,0));\n    }\n    // Muda de escopo (se preciso) e seleciona `code` como o outer daquele nível. Reaproveitado por\n    // DUAS origens: a busca (escolher) e as migalhas de pão clicáveis do painel lateral (crumb, em\n    // renderSidebar) — mesma navegação, só muda de onde o clique veio.\n    function irParaUnidade(nivel,code,label){\n      if(LEVEL!==nivel){\n        LEVEL=nivel;\n        $$('#levelToggle button').forEach(b=>b.classList.toggle('on', b.dataset.level===nivel));\n      }\n      SELECIONADO={outerCode:code,level:nivel,code:code};\n      HOVER=null;\n      const fs=featuresDaUnidade(code,nivel);\n      if(!fs.length){ drawMap(); renderSidebar(); setStatus('a unidade '+(label?'“'+label+'” ':'')+'não tem território desenhado no mapa', true); return; }\n      const blocos=blocosDaUnidade(fs);\n      // Enquadrar ANTES de desenhar: o corte de rótulo por enquadramento é avaliado no desenho, e\n      // na ordem inversa a tela sairia rotulada para o enquadramento antigo.\n      enquadrar(blocos[0]);\n      drawMap();\n      renderSidebar(fs);\n      const fora=blocos.slice(1).reduce((a,b)=>a+b.length,0);\n      setStatus(statusDaUnidade(code,nivel,fs.length,fora));\n    }\n    function escolher(it){\n      if(!it) return;\n      fecharBusca();\n      const inp=$('#searchInput'); if(inp) inp.value=it.label;\n      const cl=$('#searchClear'); if(cl) cl.classList.remove('hidden');\n      // Município seleciona o SETOR que o atende — é essa a pergunta de quem digita o nome de uma\n      // cidade. E a visão troca para o nível do que foi escolhido, senão a unidade selecionada\n      // sequer existe como unidade na tela.\n      const nivel=(it.kind==='municipio')?'setor':it.kind;\n      irParaUnidade(nivel,it.code,it.label);\n    }\n    // A partir de que zoom a fronteira INTERNA (o nível de baixo do escopo) entra. Dentro de GV ou\n    // Mercado o de baixo é pequeno (até ~600 setores, ~60 GVs) e cabe em qualquer enquadramento;\n    // dentro de Setor o de baixo é MUNICÍPIO — ~5,5 mil no país — e no zoom nacional viraria uma teia\n    // de linhas que esconde justamente o que deveria revelar.\n    const INNER_OUTLINE_ZOOM={setor:6,gv:1,mercado:1};\n    // Culling, DRAWN_AREA e o \"modo de detalhe\" por município saíram na Fase 7: o mapa inteiro vive na\n    // GPU em fidelidade total e o tooltip é geométrico (featureEm), então não há o que recortar nem\n    // modo para trocar.\n    const MAP_STATS={semDominancia:0,semHierarquia:0,semMercadoCor:0};\n    // O nível de contorno e quais rótulos cabem são decididos em drawMap, a partir do zoom. Zoom e\n    // arrasto redesenham a GPU a cada quadro (barato), mas NÃO refazem os rótulos — medir caixa de\n    // texto e anti-colisão a cada giro de roda não vale. Um único drawMap quando o gesto pára\n    // resolve, e só dispara se a chave mudou de fato.\n    let ZOOM_T=null, LAST_DRAW_KEY='';\n    function drawKeyNow(){\n      const z=MAP_W/mapView.w;\n      const thinOn=z>=(INNER_OUTLINE_ZOOM[LEVEL]||1)?1:0;\n      // Faixa logarítmica de zoom: só conta como mudança quando dobra ou cai pela metade. Sem isso,\n      // cada passo de roda pediria seu próprio redesenho.\n      return LEVEL+'|'+thinOn+'|'+Math.round(Math.log2(Math.max(z,0.01)))\n        +'|'+Math.round(mapView.x)+','+Math.round(mapView.y);\n    }\n    function scheduleRedraw(){\n      clearTimeout(ZOOM_T);\n      ZOOM_T=setTimeout(()=>{ if(drawKeyNow()!==LAST_DRAW_KEY) drawMap(); },220);\n    }\n    // Ponto de rótulo por município (chave 'm|índice'), derrubado com a projeção em syncMapDims.\n    const LABEL_CACHE={};\n    // ---------- motor WebGL ----------\n    // Fase 7 (15/09/2026). Em SVG, TODA mudança de viewBox mandava o navegador re-rasterizar o mapa\n    // inteiro em software — o perfil mediu 15,5 s de Painting em 38,7 s, e nenhuma redução de\n    // elementos ou de pontos muda QUANTAS VEZES isso acontece. Aqui a geometria sobe para a GPU uma\n    // vez, com fidelidade total (642 mil vértices é pouco para ela), e cada quadro de pan ou zoom\n    // é uma matriz nova e algumas dezenas de draw calls.\n    // PREENCHIMENTO POR PARIDADE NO STENCIL, sem triangular: cada anel vira um leque de triângulos a\n    // partir do primeiro vértice, desenhado com stencil em INVERT; onde a paridade ficou ímpar está\n    // dentro do polígono — côncavo e buraco inclusos, sem uma linha de triangulação. Depois um quad\n    // cobre a tela com a cor onde o stencil está marcado, e ZERA o que consumiu (nenhum clear entre\n    // lotes). Como unidades vizinhas não se sobrepõem, o lote é POR COR da paleta e não por unidade:\n    // até 16 lotes (8 cores × aceso/apagado) por quadro. Duas unidades da mesma cor lado a lado saem\n    // no mesmo passe, e a costura de antialiasing que exigia stroke da mesma cor em SVG não existe:\n    // com MSAA o stencil é por amostra, e cada amostra pertence a exatamente um polígono.\n    // CONTORNO POR ARESTA DE FRONTEIRA, exato. Cada segmento da malha sabe quem está do outro lado\n    // (buildSegOther): se as duas unidades diferem, é fronteira e vira linha branca POR CIMA dos\n    // preenchimentos. Some o truque de ordem contorno-antes-de-preenchimento, some o contorno de 3 px\n    // por baixo de tudo que vazava pelas frestas, e a seleção passa a contornar só a borda externa\n    // da unidade, não cada município dela.\n    // O que NÃO mudou: unidades de viewBox continuam sendo a moeda de mapView, busca, enquadramento,\n    // rótulos e hit-test. O shader só converte viewBox → clip. Os rótulos ficam num <svg> fino por\n    // cima (~50 textos), e o tooltip continua geométrico (featureEm), independente do desenho.\n    let GLX=null, GLU=null, QUAD=null;\n    // TOPO é topologia — índices de leque e de segmento, quem está do outro lado de cada aresta — e\n    // NÃO depende da projeção: sobrevive ao resize. GEO é o que depende (vértices projetados na GPU).\n    // Sem essa separação, cada resize que mudasse a proporção refazia a sondagem de vizinhos inteira\n    // (segundos com a aba congelada); com ela, refaz só 642 mil projeções (dezenas de ms).\n    let TOPO=null;\n    let GEO=null;          // vértices projetados na GPU + referências para TOPO\n    let BATCH=null;        // lotes de preenchimento do nível/seleção atuais (+ costuras de UF)\n    let SEL_GL=null;       // contorno da seleção\n    let HOVER=null;        // {level,code} da unidade que um clique selecionaria AGORA, ou null\n    let HOVER_GL=null;     // preenchimento GPU do realce de hover — muda a cada mousemove\n    let GL_ERRO=null;      // última falha do motor, para o resumo do cabeçalho não escondê-la\n    // drawMap só desenha depois que o boot terminou de carregar malha, base e CRM. Antes disso ele é\n    // alcançável — toggle de nível, botão limpar, e o ResizeObserver dispara na inscrição — e um\n    // desenho precoce envenenaria caches que nunca são invalidados: TOPO.units todo nulo, f.__mk\n    // chaveado sem UF, LEVEL_COLORS sem adjacência. O nível ficaria cinza para sempre com o resumo\n    // anunciando setores normalmente.\n    let BOOT_PRONTO=false;\n    const OUTLINE_CACHE={};\n    const hexRgb=h=>{ const n=parseInt(String(h).slice(1),16); return [((n>>16)&255)/255,((n>>8)&255)/255,(n&255)/255]; };\n    const BG_RGB=hexRgb('#f5f5f7');\n    // Tom do realce de hover: preto translúcido escurece o que já está pintado ali (fill de\n    // qualquer cor/opacidade), sem precisar saber a cor por baixo. Sutil de propósito — é uma\n    // pré-visualização de clique, não a seleção em si (essa já tem seu próprio contorno escuro).\n    const HOVER_ALPHA=0.12;\n    function glInvalidate(){\n      const g=GLX;\n      if(g){\n        try{\n          if(GEO&&GEO.vbuf) g.deleteBuffer(GEO.vbuf);\n          if(BATCH&&BATCH.ibo) g.deleteBuffer(BATCH.ibo);\n          if(BATCH&&BATCH.seams) BATCH.seams.forEach(s=>{ if(s.ibo) g.deleteBuffer(s.ibo); });\n          if(SEL_GL&&SEL_GL.ibo) g.deleteBuffer(SEL_GL.ibo);\n          if(HOVER_GL&&HOVER_GL.ibo) g.deleteBuffer(HOVER_GL.ibo);\n          if(HOVER_GL&&HOVER_GL.seamIbo) g.deleteBuffer(HOVER_GL.seamIbo);\n          Object.keys(OUTLINE_CACHE).forEach(k=>{ const o=OUTLINE_CACHE[k]; ['bright','dim','gray'].forEach(n=>{ if(o[n]&&o[n].ibo) g.deleteBuffer(o[n].ibo); }); });\n        }catch(e){ }\n      }\n      GEO=null; BATCH=null; SEL_GL=null; HOVER_GL=null;\n      Object.keys(OUTLINE_CACHE).forEach(k=>delete OUTLINE_CACHE[k]);\n    }\n    function glInit(){\n      if(GLX) return GLX;\n      const cv=$('#gl'); if(!cv) return null;\n      const opts={antialias:true,stencil:true,depth:false,alpha:false,premultipliedAlpha:false,preserveDrawingBuffer:false};\n      // Índice de 32 bits é obrigatório (1,9 milhão de índices de leque): nativo no WebGL2, extensão no 1.\n      let g=cv.getContext('webgl2',opts);\n      if(!g){ g=cv.getContext('webgl',opts); if(g&&!g.getExtension('OES_element_index_uint')) g=null; }\n      if(!g){ GL_ERRO='este navegador não tem WebGL (ou não tem índice de 32 bits)'; setStatus(GL_ERRO, true); return null; }\n      // u_ident=1 passa a_pos direto como clip space: é o quad de cobertura. u_px é um deslocamento\n      // em clip por pixel de tela, para engrossar linha redesenhando-a deslocada (gl.lineWidth>1\n      // não existe na prática).\n      const vs='attribute vec2 a_pos;uniform vec2 u_off;uniform vec2 u_scale;uniform vec2 u_px;uniform float u_ident;'\n        +'void main(){vec2 p=(a_pos-u_off)*u_scale+vec2(-1.0,1.0);gl_Position=vec4(mix(p,a_pos,u_ident)+u_px,0.0,1.0);}';\n      const fs='precision mediump float;uniform vec4 u_color;void main(){gl_FragColor=u_color;}';\n      const mk=(t,s)=>{ const sh=g.createShader(t); g.shaderSource(sh,s); g.compileShader(sh);\n        if(!g.getShaderParameter(sh,g.COMPILE_STATUS)) throw new Error('shader: '+g.getShaderInfoLog(sh)); return sh; };\n      const prog=g.createProgram();\n      g.attachShader(prog,mk(g.VERTEX_SHADER,vs)); g.attachShader(prog,mk(g.FRAGMENT_SHADER,fs)); g.linkProgram(prog);\n      if(!g.getProgramParameter(prog,g.LINK_STATUS)) throw new Error('program: '+g.getProgramInfoLog(prog));\n      g.useProgram(prog);\n      GLU={pos:g.getAttribLocation(prog,'a_pos'),off:g.getUniformLocation(prog,'u_off'),scale:g.getUniformLocation(prog,'u_scale'),\n           px:g.getUniformLocation(prog,'u_px'),ident:g.getUniformLocation(prog,'u_ident'),color:g.getUniformLocation(prog,'u_color')};\n      g.enableVertexAttribArray(GLU.pos);\n      QUAD=g.createBuffer(); g.bindBuffer(g.ARRAY_BUFFER,QUAD);\n      g.bufferData(g.ARRAY_BUFFER,new Float32Array([-1,-1, 1,-1, -1,1, 1,1]),g.STATIC_DRAW);\n      g.enable(g.BLEND); g.blendFunc(g.SRC_ALPHA,g.ONE_MINUS_SRC_ALPHA);\n      // Perda de contexto é real (GPU trocada, aba em segundo plano por muito tempo). preventDefault\n      // pede a restauração; quando ela chega, programa, buffers e lotes são remontados — TOPO (CPU)\n      // sobrevive. Registrado UMA vez por canvas.\n      if(!cv.__glHooked){\n        cv.__glHooked=true;\n        cv.addEventListener('webglcontextlost',e=>{ e.preventDefault(); GLX=null; GEO=null; BATCH=null; SEL_GL=null; HOVER_GL=null; Object.keys(OUTLINE_CACHE).forEach(k=>delete OUTLINE_CACHE[k]); });\n        cv.addEventListener('webglcontextrestored',()=>{ drawMap(); });\n      }\n      GLX=g;\n      return g;\n    }\n    function fitCanvas(){\n      const cv=$('#gl'); if(!cv) return false;\n      const dpr=window.devicePixelRatio||1, w=Math.round(cv.clientWidth*dpr), h=Math.round(cv.clientHeight*dpr);\n      if(!w||!h) return false;\n      if(cv.width!==w||cv.height!==h){ cv.width=w; cv.height=h; }\n      return true;\n    }\n    // Vértices projetados de TODOS os anéis num Float32Array; índices de leque (preenchimento) e de\n    // segmento (contorno) em Uint32Array, com o intervalo de cada feature. Uma passada só por projeção.\n    function buildGeo(pr){\n      const feats=ALL_FEATURES;\n      const ringN=r=>{ let n=r.length; if(n>1&&r[0][0]===r[n-1][0]&&r[0][1]===r[n-1][1]) n--; return n<3?0:n; };\n      const first=!TOPO;\n      let nv=0,nfan=0,nseg=0;\n      feats.forEach(f=>{ const g=f&&f.geometry; if(!g||!g.coordinates) return;\n        const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n        ps.forEach(poly=>poly.forEach(r=>{ const n=ringN(r); if(!n) return; nv+=n; nfan+=3*(n-2); nseg+=2*n; })); });\n      const pos=new Float32Array(nv*2);\n      const fan=first?new Uint32Array(nfan):null, seg=first?new Uint32Array(nseg):null;\n      const segFeat=first?new Int32Array(nseg/2):null, fanRange=first?new Int32Array(feats.length*2):null;\n      let vi=0,fi=0,si=0;\n      // A ordem de percurso é determinística, então reprojetar produz os vértices na MESMA ordem que a\n      // topologia guardada indexa — é o que permite refazer só `pos` no resize.\n      feats.forEach((f,idx)=>{\n        if(f) f.__gi=idx;\n        if(first) fanRange[idx*2]=fi;\n        const g=f&&f.geometry;\n        if(g&&g.coordinates){\n          const ps=g.type==='MultiPolygon'?g.coordinates:[g.coordinates];\n          ps.forEach(poly=>poly.forEach(r=>{\n            const n=ringN(r); if(!n) return;\n            const base=vi;\n            for(let i=0;i<n;i++){ const q=pr(r[i]); pos[vi*2]=q[0]; pos[vi*2+1]=q[1]; vi++; }\n            if(first){\n              for(let i=1;i<n-1;i++){ fan[fi++]=base; fan[fi++]=base+i; fan[fi++]=base+i+1; }\n              for(let i=0;i<n;i++){ segFeat[si>>1]=idx; seg[si++]=base+i; seg[si++]=base+((i+1)%n); }\n            }\n          }));\n        }\n        if(first) fanRange[idx*2+1]=fi-fanRange[idx*2];\n      });\n      if(first){\n        const so=buildSegOther(pos,seg,segFeat,feats,pr);\n        TOPO={fan:fan,seg:seg,segFeat:segFeat,segOther:so.other,segProbed:so.probed,fanRange:fanRange,nseg:nseg/2,units:{},unitFeats:{}};\n      }\n      const g=GLX, vbuf=g.createBuffer();\n      g.bindBuffer(g.ARRAY_BUFFER,vbuf); g.bufferData(g.ARRAY_BUFFER,pos,g.STATIC_DRAW);\n      // `pos` não fica: depois do upload só a GPU precisa dele (5 MB a menos na heap).\n      return {vbuf:vbuf,fan:TOPO.fan,seg:TOPO.seg,segFeat:TOPO.segFeat,segOther:TOPO.segOther,segProbed:TOPO.segProbed,fanRange:TOPO.fanRange,nseg:TOPO.nseg,units:TOPO.units,unitFeats:TOPO.unitFeats};\n    }\n    // Quem está do OUTRO lado de cada segmento. Primeiro por vértice compartilhado: dentro de uma UF os\n    // dois lados de uma fronteira têm os mesmos vértices (é o que buildMuniAdjacency explora), então\n    // a aresta aparece duas vezes com a mesma chave — par SIMÉTRICO, e só ele pode ser deduplicado\n    // pelo índice (a<b). O que sobra sem par — costa, fronteira do país e divisa de UF, onde os dois\n    // arquivos foram digitalizados em separado e os vértices não batem — é sondado por geometria:\n    // pontos a ~500 m, 1,5 km e 3,6 km de cada lado do meio do segmento, e featureEm diz de quem é.\n    // Sem isto, toda divisa de UF dentro de uma unidade viraria linha branca. Par por sondagem é\n    // ASSIMÉTRICO (cada lado sonda sozinho, e as polilinhas dos dois lados são diferentes), por isso\n    // fica marcado em `probed` e nunca é deduplicado: desenhar os dois lados é inócuo; descartar o\n    // lado certo não. Roda uma vez por SESSÃO (é topologia, vive em TOPO) e custa 1–3 s.\n    function buildSegOther(pos,seg,segFeat,feats,pr){\n      const nseg=segFeat.length, other=new Int32Array(nseg).fill(-1), probed=new Uint8Array(nseg);\n      const K=100, vk=i=>(Math.round(pos[i*2]*K)*400000+Math.round(pos[i*2+1]*K));\n      const map=new Map();\n      for(let s=0;s<nseg;s++){\n        const a=vk(seg[s*2]), b=vk(seg[s*2+1]), key=a<b?(a+':'+b):(b+':'+a);\n        const prev=map.get(key);\n        if(prev===undefined) map.set(key,s);\n        else if(prev>=0){ other[s]=segFeat[prev]; other[prev]=segFeat[s]; map.set(key,-1); }\n      }\n      map.clear();\n      if(!HIT_GRID) buildHitGrid(pr);\n      const EPS=0.08;\n      for(let s=0;s<nseg;s++){\n        if(other[s]!==-1) continue;\n        const i0=seg[s*2], i1=seg[s*2+1];\n        const ax=pos[i0*2],ay=pos[i0*2+1],bx=pos[i1*2],by=pos[i1*2+1];\n        const mx=(ax+bx)/2,my=(ay+by)/2; let nx=-(by-ay),ny=bx-ax; const L=Math.hypot(nx,ny); if(!L) continue;\n        nx=nx/L*EPS; ny=ny/L*EPS;\n        const self=feats[segFeat[s]];\n        // Os dois lados, EXCLUINDO a própria feature — senão, num sliver de sobreposição entre malhas\n        // de UF, o lado de fora devolvia a própria e a aresta virava borda externa. E uma segunda\n        // tentativa mais longe (3×) para vãos maiores entre as duas digitalizações.\n        let f=null;\n        // Lista explícita: a versão anterior fazia m*=3 com teto 6 e produzia 1, 3, 9 — a terceira\\n        // sonda nunca rodava.\n        const MULT=[1,3,6];\n        for(let q=0;q<MULT.length&&!f;q++){ const m=MULT[q]; f=featureEm(mx+m*nx,my+m*ny,self)||featureEm(mx-m*nx,my-m*ny,self); }\n        if(f&&f.__gi!==undefined){ other[s]=f.__gi; probed[s]=1; }\n      }\n      return {other:other,probed:probed};\n    }\n    // Unidade de cada feature no nível, por índice — e as features de cada unidade. Cacheado em TOPO\n    // (GEO só referencia) porque depende só de DOM/UNITS, que não mudam depois do boot — e drawMap\n    // só chega aqui depois do boot (BOOT_PRONTO), senão o cache nasceria todo nulo e ficaria.\n    function unitsAt(level){\n      if(GEO.units[level]) return GEO.units[level];\n      const arr=new Array(ALL_FEATURES.length), by={};\n      ALL_FEATURES.forEach((f,i)=>{ const u=unitOfFeature(f,level)||null; arr[i]=u; if(u) (by[u]=by[u]||[]).push(f); });\n      GEO.units[level]=arr; GEO.unitFeats[level]=by;\n      return arr;\n    }\n    // Setor selecionado — direto (escopo Setor) ou vindo de um \"clique de dentro\" (escopo GV/Mercado\n    // com um setor selecionado como inner). Independe de LEVEL: é sempre o setor de fato selecionado,\n    // não o outer do escopo.\n    // Unidade externa \"em foco\" — o outer já armado, independente de já ter descido para um inner.\n    // ÚNICA regra de foco agora: só a própria unidade externa selecionada fica acesa, o resto do\n    // mapa apaga — inclusive dentro do MESMO escopo (uma GV vizinha da GV selecionada não ganha\n    // tratamento especial). A versão anterior tinha uma regra extra — setor selecionado iluminava a\n    // GV INTEIRA, pensada para mostrar destinos elegíveis de redistribuição — mas isso confundia\n    // com \"selecionei X e o mapa realça Y\": reportado como bug, removida.\n    function outerSelecionado(){\n      return SELECIONADO?SELECIONADO.outerCode:null;\n    }\n    // Lotes de preenchimento: por classe de tom (externo×interno) × aceso/apagado. Refeitos só\n    // quando escopo ou seleção mudam.\n    function buildBatches(){\n      const g=GLX, outerSel=outerSelecionado();\n      const key=LEVEL+'|'+(outerSel||'');\n      if(BATCH&&BATCH.key===key) return BATCH;\n      const outerIdx=outerIdxForLevel(LEVEL), innerIdx=innerIdxForScope(LEVEL);\n      const innerLevel=INNER_LEVEL[LEVEL], innerArr=innerLevel==='municipio'?null:unitsAt(innerLevel);\n      const outerUnits=unitsAt(LEVEL), monoRgb=hexRgb(MONO_COLOR), mercIdx=mercadoIdxPorCodigo();\n      const groups=new Map(), cls=new Array(ALL_FEATURES.length);\n      let nodomTodos=0, semMercCor=0; MAP_STATS.semHierarquia=0;\n      ALL_FEATURES.forEach((f,i)=>{\n        const uOuter=outerUnits[i]; let k,rgbaBase,dim;\n        if(!uOuter){\n          // Contado SEMPRE: o número no cabeçalho é diagnóstico da BASE. Duas causas distintas caem\n          // no mesmo cinza — município sem setor na base, e setor que existe mas está sem GV/mercado.\n          nodomTodos++;\n          if(DOM.setorByMuni&&DOM.setorByMuni[muniKeyOfFeature(f)]) MAP_STATS.semHierarquia++;\n          // Com qualquer foco ativo, o resto do país apaga — sem-dominância entra nessa regra\n          // também, não fica \"sempre aceso\" enquanto tudo ao redor apaga.\n          dim=!!outerSel;\n          k='gray'+(dim?'d':'b'); rgbaBase=[...hexRgb(NODOM_COLOR),dim?0.26:1];\n        } else {\n          const oi=outerIdx[uOuter]||0;\n          // Variação de tom pelo nível de baixo (INNER_DELTA) só entra DENTRO da unidade ARMADA —\n          // mesma regra do contorno fino (buildOutline): sem seleção, ou fora da unidade\n          // selecionada, o preenchimento fica CHAPADO, sem denunciar a subdivisão por textura de\n          // opacidade antes de ela ser pedida (bug reportado: \"GVs ainda aparecem, só a linha saiu\").\n          const armadoAqui=!!(outerSel&&uOuter===outerSel);\n          let ii=0;\n          if(armadoAqui){\n            const uInner=innerLevel==='municipio'?muniKeyOfFeature(f):innerArr[i];\n            ii=(uInner!=null&&innerIdx[uInner]!=null)?innerIdx[uInner]:0;\n          }\n          const alpha=Math.max(0.08,Math.min(0.92,OUTER_ALPHA[oi]+INNER_DELTA[ii]));\n          // Só a própria unidade externa selecionada fica acesa — todo o resto apaga, mesmo outras\n          // unidades do mesmo tipo (outra GV, outro setor).\n          dim=!!(outerSel&&uOuter!==outerSel);\n          // O MATIZ vem do MERCADO da feature, sempre — independe de LEVEL ser Setor, GV ou Mercado\n          // (quando LEVEL já é 'mercado', uOuter É o mercado, e esta busca devolve o mesmo código).\n          // Setor com dominância mas sem mercado na base cai no azul de MONO_COLOR, contado à parte\n          // em vez de escondido atrás de qualquer matiz da paleta.\n          const merc=unitOfFeature(f,'mercado');\n          const mi=(merc!=null&&mercIdx[merc]!=null)?mercIdx[merc]:-1;\n          const rgb=mi>=0?mercadoRgb(mi):monoRgb;\n          if(mi<0) semMercCor++;\n          // Apagado é um alfa PLANO, e não uma fração do alfa próprio: \"de-enfatizado\" precisa ler\n          // igual em toda a área apagada, independente de a unidade por baixo ser clara ou escura.\n          // O índice de cor entra na chave do lote: dois setores no mesmo tom de opacidade (oi/ii)\n          // mas de mercados diferentes não podem cair no mesmo lote — um pintaria por cima do outro\n          // com a cor errada.\n          k=mi+'_'+oi+'_'+ii+(dim?'d':'b');\n          rgbaBase=[rgb[0],rgb[1],rgb[2],dim?0.14:alpha];\n        }\n        cls[i]=k;\n        let gr=groups.get(k);\n        if(!gr) groups.set(k,gr={rgba:rgbaBase,ranges:[],total:0});\n        const a=GEO.fanRange[i*2], n=GEO.fanRange[i*2+1];\n        if(n){ gr.ranges.push(a,n); gr.total+=n; }\n      });\n      MAP_STATS.semDominancia=nodomTodos;\n      MAP_STATS.semMercadoCor=semMercCor;\n      let total=0; groups.forEach(gr=>{ total+=gr.total; });\n      const idx=new Uint32Array(total), list=[]; let off=0;\n      groups.forEach(gr=>{\n        const start=off;\n        for(let j=0;j<gr.ranges.length;j+=2){ idx.set(GEO.fan.subarray(gr.ranges[j],gr.ranges[j]+gr.ranges[j+1]),off); off+=gr.ranges[j+1]; }\n        list.push({rgba:gr.rgba,start:start,count:off-start});\n      });\n      if(BATCH&&BATCH.ibo) g.deleteBuffer(BATCH.ibo);\n      if(BATCH&&BATCH.seams) BATCH.seams.forEach(s=>{ if(s.ibo) g.deleteBuffer(s.ibo); });\n      const ibo=g.createBuffer(); g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,ibo); g.bufferData(g.ELEMENT_ARRAY_BUFFER,idx,g.STATIC_DRAW);\n      // COSTURA DE UF: aresta cujos dois lados caem na MESMA classe de tom (cls) mas vêm de arquivos\n      // de UF diferentes. Ali as duas digitalizações não coincidem — ora deixam um vão (o fundo\n      // aparece como fio claro), ora se sobrepõem num sliver que a paridade do stencil CANCELA\n      // (buraco da cor de fundo). Uma linha na cor do preenchimento por cima dessas arestas faz o\n      // papel que o stroke-da-mesma-cor fazia em SVG. São poucas (só divisas de UF).\n      const seamIdx=new Map();\n      for(let s=0;s<GEO.nseg;s++){\n        const a=GEO.segFeat[s], b=GEO.segOther[s];\n        if(b<0||(a>b&&!GEO.segProbed[s])) continue;\n        if(!cls[a]||cls[a]!==cls[b]) continue;\n        const fa=ALL_FEATURES[a], fb=ALL_FEATURES[b];\n        if(!fa||!fb||fa.__uf===fb.__uf) continue;\n        const k=cls[a]; let arr=seamIdx.get(k); if(!arr){ arr=[]; seamIdx.set(k,arr); }\n        arr.push(GEO.seg[s*2],GEO.seg[s*2+1]);\n      }\n      const seams=[];\n      seamIdx.forEach((arr,k)=>{\n        const sb=g.createBuffer(); g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,sb);\n        g.bufferData(g.ELEMENT_ARRAY_BUFFER,new Uint32Array(arr),g.STATIC_DRAW);\n        // Costura apagada sai OPACA na cor já misturada com o fundo: uma linha com alpha baixo por\n        // cima de um preenchimento igualmente baixo escurecia a divisa (dois blends), justamente\n        // onde deveria sumir.\n        const r=groups.get(k).rgba, al=r[3];\n        const rgba=al>=1?r:[r[0]*al+BG_RGB[0]*(1-al), r[1]*al+BG_RGB[1]*(1-al), r[2]*al+BG_RGB[2]*(1-al), 1];\n        seams.push({rgba:rgba,ibo:sb,count:arr.length});\n      });\n      BATCH={key:key,ibo:ibo,list:list,outerSel:outerSel,seams:seams};\n      return BATCH;\n    }\n    // Arestas de fronteira, em DUAS camadas agora: GROSSA entre unidades do ESCOPO atual (LEVEL —\n    // Setor, GV ou Mercado, o \"outer\") e FINA entre unidades do nível de baixo (INNER_LEVEL[LEVEL])\n    // dentro do MESMO outer — é o traço que revela \"por dentro\" a subdivisão que o pedido do\n    // Fernando descreveu (GV por fora, setor por dentro; setor por fora, município por dentro).\n    // A fronteira ENTRE outers diferentes nunca desce para fina: mesmo que o inner também mude ali,\n    // a grossa já conta a história inteira daquele ponto. Cinza-cinza continua à parte (malha dos\n    // sem dominância, traço leve). Aresta compartilhada aparece duas vezes na malha; só entra pela\n    // feature de índice menor.\n    function buildOutline(outerSel){\n      const key=LEVEL+'|'+(outerSel||''); if(OUTLINE_CACHE[key]) return OUTLINE_CACHE[key];\n      const g=GLX, outerUnits=unitsAt(LEVEL), innerLevel=INNER_LEVEL[LEVEL];\n      const innerArr=innerLevel==='municipio'?null:unitsAt(innerLevel);\n      const innerOf=i=>innerArr?innerArr[i]:muniKeyOfFeature(ALL_FEATURES[i]);\n      // Mesma regra do buildBatches: só a própria unidade externa selecionada fica acesa.\n      const dimOf=i=>!!outerSel&&outerUnits[i]!==outerSel;\n      const n=GEO.nseg, boldBright=[], boldDim=[], thin=[], gray=[];\n      for(let s=0;s<n;s++){\n        const a=GEO.segFeat[s], b=GEO.segOther[s];\n        if(b>=0&&a>b&&!GEO.segProbed[s]) continue;\n        const oa=outerUnits[a], ob=b<0?null:outerUnits[b];\n        if(!oa&&!ob){ gray.push(GEO.seg[s*2],GEO.seg[s*2+1]); continue; }\n        if(oa!==ob){\n          const dest=(dimOf(a)&&(b<0||dimOf(b)))?boldDim:boldBright;\n          dest.push(GEO.seg[s*2],GEO.seg[s*2+1]);\n          continue;\n        }\n        // Pedido do usuário: o sub-escopo (GV dentro de Mercado, Setor dentro de GV, município\n        // dentro de Setor) só aparece depois de uma unidade ser ARMADA, e só por DENTRO dela —\n        // sem seleção, ou fora da unidade selecionada, a subdivisão fica escondida (antes disto\n        // ela entrava sempre que o zoom passava de INNER_OUTLINE_ZOOM, mesmo sem nada selecionado).\n        if(!outerSel||oa!==outerSel) continue;\n        const ia=innerOf(a), ib=b<0?null:innerOf(b);\n        if(ia===ib) continue;\n        thin.push(GEO.seg[s*2],GEO.seg[s*2+1]);\n      }\n      const up=arr=>{ if(!arr.length) return {ibo:null,count:0}; const ibo=g.createBuffer(); g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,ibo);\n        g.bufferData(g.ELEMENT_ARRAY_BUFFER,new Uint32Array(arr),g.STATIC_DRAW); return {ibo:ibo,count:arr.length}; };\n      OUTLINE_CACHE[key]={boldBright:up(boldBright),boldDim:up(boldDim),thin:up(thin),gray:up(gray)};\n      return OUTLINE_CACHE[key];\n    }\n    // Contorno da seleção: só a borda EXTERNA da unidade — arestas em que exatamente um dos lados é\n    // ela. `nivel` é o que está de fato selecionado (SELECIONADO.level), que agora tanto pode ser o\n    // outer do escopo quanto o inner dentro dele — a mesma checagem serve para os dois, e para\n    // município (chaveado pela própria chave, não por um array unitsAt).\n    function buildSel(){\n      const g=GLX, sel=SELECIONADO, nivel=sel?sel.level:null, code=sel?String(sel.code):null;\n      const key=(nivel||'')+'|'+(code||'');\n      if(SEL_GL&&SEL_GL.key===key) return;\n      if(SEL_GL&&SEL_GL.ibo) g.deleteBuffer(SEL_GL.ibo);\n      SEL_GL={key:key,ibo:null,count:0};\n      if(!code) return;\n      const arr2=nivel==='municipio'?null:unitsAt(nivel);\n      const codeOf=i=>arr2?arr2[i]:muniKeyOfFeature(ALL_FEATURES[i]);\n      const idx=[];\n      for(let s=0;s<GEO.nseg;s++){\n        const a=GEO.segFeat[s], b=GEO.segOther[s];\n        if(b>=0&&a>b&&!GEO.segProbed[s]) continue;\n        const ia=(codeOf(a)===code), ib=(b>=0&&codeOf(b)===code);\n        if(ia===ib) continue;\n        idx.push(GEO.seg[s*2],GEO.seg[s*2+1]);\n      }\n      if(!idx.length) return;\n      const arr=new Uint32Array(idx), ibo=g.createBuffer();\n      g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,ibo); g.bufferData(g.ELEMENT_ARRAY_BUFFER,arr,g.STATIC_DRAW);\n      SEL_GL={key:key,ibo:ibo,count:arr.length};\n    }\n    // Realce de hover — Fase 10 (pedido do usuário): as duas tentativas anteriores tentavam TAPAR\n    // a costura de UF com uma camada translúcida ou uma linha extra, e a costura continuava\n    // aparecendo (relatado duas vezes). O problema é a transparência em si: pra uma camada\n    // translúcida ficar perfeita ela precisa cobrir o recorte EXATO do hover, pixel a pixel — e\n    // é exatamente isso que a costura entre arquivos de UF impede. A saída é não depender de\n    // cobertura pixel-perfeita: em vez de uma camada translúcida por cima, calcula a cor que a\n    // unidade JÁ mostra (mesma fórmula de buildBatches) e escurece por MULTIPLICAÇÃO — um filtro\n    // de brilho, não alfa — pintando essa cor final OPACA. Opaco repintado não acumula (mesma\n    // técnica das costuras do preenchimento principal), então cobrir a costura com folga não tem\n    // risco de escurecer ela mais que o resto.\n    const HOVER_DARKEN=0.82;\n    function corAtualDaUnidade(nivel,code){\n      const arr=nivel==='municipio'?null:unitsAt(nivel);\n      const codeOf=i=>arr?arr[i]:muniKeyOfFeature(ALL_FEATURES[i]);\n      let fi=-1;\n      for(let i=0;i<ALL_FEATURES.length;i++){ if(codeOf(i)===code){ fi=i; break; } }\n      if(fi<0) return null;\n      const f=ALL_FEATURES[fi];\n      const outerUnits=unitsAt(LEVEL), uOuter=outerUnits[fi];\n      if(!uOuter) return null;\n      const oi=(outerIdxForLevel(LEVEL)[uOuter])||0;\n      const innerLevel=INNER_LEVEL[LEVEL], innerArr=innerLevel==='municipio'?null:unitsAt(innerLevel);\n      const armadoAqui=!!(outerSelecionado()&&uOuter===outerSelecionado());\n      let ii=0;\n      if(armadoAqui){\n        const innerIdx=innerIdxForScope(LEVEL);\n        const uInner=innerLevel==='municipio'?muniKeyOfFeature(f):innerArr[fi];\n        ii=(uInner!=null&&innerIdx[uInner]!=null)?innerIdx[uInner]:0;\n      }\n      const alpha=Math.max(0.08,Math.min(0.92,OUTER_ALPHA[oi]+INNER_DELTA[ii]));\n      const merc=unitOfFeature(f,'mercado'), mercIdx=mercadoIdxPorCodigo();\n      const mi=(merc!=null&&mercIdx[merc]!=null)?mercIdx[merc]:-1;\n      const rgb=mi>=0?mercadoRgb(mi):hexRgb(MONO_COLOR);\n      return [rgb[0],rgb[1],rgb[2],alpha];\n    }\n    function buildHoverGL(){\n      const g=GLX, hv=HOVER, nivel=hv?hv.level:null, code=hv?String(hv.code):null;\n      const key=(nivel||'')+'|'+(code||'');\n      if(HOVER_GL&&HOVER_GL.key===key) return;\n      if(HOVER_GL&&HOVER_GL.ibo) g.deleteBuffer(HOVER_GL.ibo);\n      if(HOVER_GL&&HOVER_GL.seamIbo) g.deleteBuffer(HOVER_GL.seamIbo);\n      HOVER_GL={key:key,ibo:null,count:0,seamIbo:null,seamCount:0,rgba:[0,0,0,1]};\n      if(!code) return;\n      const arr2=nivel==='municipio'?null:unitsAt(nivel);\n      const codeOf=i=>arr2?arr2[i]:muniKeyOfFeature(ALL_FEATURES[i]);\n      let total=0;\n      for(let i=0;i<ALL_FEATURES.length;i++){ if(codeOf(i)===code) total+=GEO.fanRange[i*2+1]; }\n      if(!total) return;\n      const idxArr=new Uint32Array(total); let off=0;\n      for(let i=0;i<ALL_FEATURES.length;i++){\n        if(codeOf(i)!==code) continue;\n        const a=GEO.fanRange[i*2], n=GEO.fanRange[i*2+1];\n        if(n){ idxArr.set(GEO.fan.subarray(a,a+n),off); off+=n; }\n      }\n      const ibo=g.createBuffer();\n      g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,ibo); g.bufferData(g.ELEMENT_ARRAY_BUFFER,idxArr,g.STATIC_DRAW);\n      // Mesma costura de UF de sempre, agora só como GEOMETRIA (a cor sai da mesma amostra abaixo).\n      const seamArr=[];\n      for(let s=0;s<GEO.nseg;s++){\n        const a=GEO.segFeat[s], b=GEO.segOther[s];\n        if(b<0||(a>b&&!GEO.segProbed[s])) continue;\n        if(codeOf(a)!==code||codeOf(b)!==code) continue;\n        const fa=ALL_FEATURES[a], fb=ALL_FEATURES[b];\n        if(!fa||!fb||fa.__uf===fb.__uf) continue;\n        seamArr.push(GEO.seg[s*2],GEO.seg[s*2+1]);\n      }\n      let seamIbo=null, seamCount=0;\n      if(seamArr.length){\n        seamIbo=g.createBuffer(); g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,seamIbo);\n        g.bufferData(g.ELEMENT_ARRAY_BUFFER,new Uint32Array(seamArr),g.STATIC_DRAW);\n        seamCount=seamArr.length;\n      }\n      // Amostra QUALQUER feature do hover: mi/oi/ii são constantes dentro de uma mesma unidade\n      // (mesmo mercado, mesmo tom), então a primeira que achar já dá a cor certa pra todas.\n      const base=corAtualDaUnidade(nivel,code);\n      let rgba=[0,0,0,1];\n      if(base){\n        const bg=BG_RGB, [r,gg,b,a]=base;\n        const or_=a>=1?r:r*a+bg[0]*(1-a), og=a>=1?gg:gg*a+bg[1]*(1-a), ob=a>=1?b:b*a+bg[2]*(1-a);\n        rgba=[or_*HOVER_DARKEN,og*HOVER_DARKEN,ob*HOVER_DARKEN,1];\n      }\n      HOVER_GL={key:key,ibo:ibo,count:idxArr.length,seamIbo:seamIbo,seamCount:seamCount,rgba:rgba};\n    }\n    function glDraw(){\n      const g=GLX; if(!g||!GEO||!BATCH||!GLU) return;\n      if(!fitCanvas()) return;\n      const cv=g.canvas;\n      g.viewport(0,0,cv.width,cv.height);\n      g.clearColor(BG_RGB[0],BG_RGB[1],BG_RGB[2],1); g.clearStencil(0);\n      g.clear(g.COLOR_BUFFER_BIT|g.STENCIL_BUFFER_BIT);\n      g.uniform2f(GLU.off,mapView.x,mapView.y);\n      g.uniform2f(GLU.scale,2/mapView.w,-2/mapView.h);\n      g.uniform2f(GLU.px,0,0);\n      const useGeo=()=>{ g.uniform1f(GLU.ident,0); g.bindBuffer(g.ARRAY_BUFFER,GEO.vbuf); g.vertexAttribPointer(GLU.pos,2,g.FLOAT,false,0,0); };\n      // Preenchimentos: paridade no stencil, cobertura por cor, e a cobertura zera o que consumiu.\n      g.enable(g.STENCIL_TEST);\n      for(let i=0;i<BATCH.list.length;i++){\n        const b=BATCH.list[i];\n        useGeo();\n        g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,BATCH.ibo);\n        g.colorMask(false,false,false,false);\n        g.stencilFunc(g.ALWAYS,0,0xFF); g.stencilOp(g.KEEP,g.KEEP,g.INVERT); g.stencilMask(0xFF);\n        g.drawElements(g.TRIANGLES,b.count,g.UNSIGNED_INT,b.start*4);\n        g.colorMask(true,true,true,true);\n        g.stencilFunc(g.EQUAL,1,0x01); g.stencilOp(g.KEEP,g.KEEP,g.ZERO);\n        g.uniform4f(GLU.color,b.rgba[0],b.rgba[1],b.rgba[2],b.rgba[3]);\n        g.uniform1f(GLU.ident,1); g.bindBuffer(g.ARRAY_BUFFER,QUAD); g.vertexAttribPointer(GLU.pos,2,g.FLOAT,false,0,0);\n        g.drawArrays(g.TRIANGLE_STRIP,0,4);\n      }\n      g.disable(g.STENCIL_TEST);\n      // Linhas por cima. gl.LINES tem 1 px de dispositivo e gl.lineWidth>1 não existe na prática, então\n      // a linha é redesenhada em deslocamentos SIMÉTRICOS (±0,5 px em DPR 1; -1/0/+1 em DPR 2) para\n      // engrossar sem escorregar para um lado. Só cor OPACA engrossa assim: cópias com alpha se\n      // acumulam onde caem no mesmo pixel (numa linha horizontal, todas as cópias com o mesmo dy), e\n      // 0,26 virava 0,45 — e dependia da orientação. Linha apagada sai em uma cópia só.\n      useGeo();\n      const ol=buildOutline(BATCH.outerSel);\n      // Traço GROSSO afinado a pedido: não escala mais com o devicePixelRatio (numa tela retina\n      // isso dava 2-3 px físicos — grosso demais). ±0,6 → ±0,42 (-30%) → ±0,25 (-40% de novo).\n      const offs=[-0.25,0.25];\n      const lines=(o,rgba,grossa)=>{\n        if(!o||!o.count) return;\n        g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,o.ibo);\n        g.uniform4f(GLU.color,rgba[0],rgba[1],rgba[2],rgba[3]);\n        const os=grossa?offs:[0];\n        for(let a=0;a<os.length;a++) for(let b=0;b<os.length;b++){\n          g.uniform2f(GLU.px,os[a]*2/cv.width,-os[b]*2/cv.height);\n          g.drawElements(g.LINES,o.count,g.UNSIGNED_INT,0);\n        }\n        g.uniform2f(GLU.px,0,0);\n      };\n      const emFoco=BATCH.outerSel;\n      // Costuras de UF primeiro, na cor do preenchimento: tapam vão e sliver entre malhas estaduais\n      // dentro da mesma unidade, ANTES do contorno branco passar por cima do que é fronteira de fato.\n      if(BATCH.seams) BATCH.seams.forEach(s=>lines(s,s.rgba,true));\n      // Cinza apaga junto quando há foco: todo sem-dominância é \"dim\" nesse estado (buildBatches).\n      lines(ol.gray,emFoco?[0.84,0.84,0.86,0.26]:[0.84,0.84,0.86,1],false);\n      // Interna (nível de baixo do escopo) é sempre um traço FINO, na mesma opacidade esteja a área\n      // apagada ou não. Já vem vazia de buildOutline quando nada está selecionado (ou fora da\n      // unidade selecionada) — a chamada aqui é incondicional de propósito.\n      lines(ol.thin,[1,1,1,0.55],false);\n      // Externa (o escopo — Setor/GV/Mercado) é sempre GROSSA: é ela que \"segmenta o mapa\" pelo\n      // escopo, como pedido — apagada quando fora da GV elegível, cheia quando dentro.\n      lines(ol.boldDim,[1,1,1,0.32],true);\n      lines(ol.boldBright,[1,1,1,1],true);\n      // Hover só AGORA, depois de toda linha — e é essa ordem, não o remendo em si, que faltava\n      // nas três tentativas anteriores: a costura de UF do PREENCHIMENTO NORMAL (BATCH.seams,\n      // logo acima) é redesenhada na cor CLARA de sempre por cima de tudo que veio antes dela —\n      // inclusive de um hover pintado antes dela, como nas tentativas anteriores. O problema\n      // nunca foi a costura DO HOVER: era essa aqui, sempre presente, sendo repintada depois que\n      // o hover já tinha escurecido aquele pixel. Escurecendo por ÚLTIMO (ainda antes do contorno\n      // de seleção, que deve continuar nítido por cima de tudo), o hover cobre essa linha também.\n      if(HOVER_GL&&HOVER_GL.count){\n        useGeo();\n        g.enable(g.STENCIL_TEST);\n        g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,HOVER_GL.ibo);\n        g.colorMask(false,false,false,false);\n        g.stencilFunc(g.ALWAYS,0,0xFF); g.stencilOp(g.KEEP,g.KEEP,g.INVERT); g.stencilMask(0xFF);\n        g.drawElements(g.TRIANGLES,HOVER_GL.count,g.UNSIGNED_INT,0);\n        g.colorMask(true,true,true,true);\n        g.stencilFunc(g.EQUAL,1,0x01); g.stencilOp(g.KEEP,g.KEEP,g.ZERO);\n        g.uniform4f(GLU.color,HOVER_GL.rgba[0],HOVER_GL.rgba[1],HOVER_GL.rgba[2],1);\n        g.uniform1f(GLU.ident,1); g.bindBuffer(g.ARRAY_BUFFER,QUAD); g.vertexAttribPointer(GLU.pos,2,g.FLOAT,false,0,0);\n        g.drawArrays(g.TRIANGLE_STRIP,0,4);\n        g.disable(g.STENCIL_TEST);\n        if(HOVER_GL.seamCount){\n          useGeo();\n          g.bindBuffer(g.ELEMENT_ARRAY_BUFFER,HOVER_GL.seamIbo);\n          g.uniform4f(GLU.color,HOVER_GL.rgba[0],HOVER_GL.rgba[1],HOVER_GL.rgba[2],1);\n          const seamOffs=[-0.75,0,0.75];\n          for(let sa=0;sa<seamOffs.length;sa++) for(let sb=0;sb<seamOffs.length;sb++){\n            g.uniform2f(GLU.px,seamOffs[sa]*2/cv.width,-seamOffs[sb]*2/cv.height);\n            g.drawElements(g.LINES,HOVER_GL.seamCount,g.UNSIGNED_INT,0);\n          }\n          g.uniform2f(GLU.px,0,0);\n        }\n        // Restaura ident=0/GEO.vbuf incondicionalmente — o SEL_GL abaixo usa lines(), que espera\n        // esse estado, e o caminho sem costura (seamCount===0) sai daqui com ident=1/QUAD ainda\n        // ligado (do cover acima), o que desalinharia a posição do contorno de seleção.\n        useGeo();\n      }\n      if(SEL_GL&&SEL_GL.count) lines(SEL_GL,[0.11,0.11,0.12,1],true);\n    }\n    // Rótulos no <svg> fino por cima — Fase 8 (pedido do usuário): uma etiqueta por UNIDADE DO\n    // ESCOPO, não mais por município. Sem seleção, uma por unidade do nível ATIVO (Setor/GV/\n    // Mercado — o que o toggle mostra); com uma unidade ARMADA (outerCode selecionado), uma por\n    // unidade do nível DE BAIXO dela, só dentro da armada — a MESMA dualidade outer/inner do\n    // clique e do hover, agora para rótulo. Município só volta a aparecer quando o nível de\n    // baixo É município (escopo Setor, com um setor armado).\n    // Aparece SEMPRE, \"regardless of zoom level\" — nenhum corte por tamanho de tela. O único\n    // filtro é anti-colisão contra um rótulo maior já colocado (do maior para o menor em área):\n    // isso é legibilidade, não é esconder por zoom.\n    function drawLabels(svg,pr){\n      svg.innerHTML='';\n      if(!LABELS_ON) return;\n      const gL=document.createElementNS(NS,'g'); gL.setAttribute('class','lbl');\n      const upp=unitsPerPxDe(svg); if(!upp){ svg.appendChild(gL); return; }\n      // Estiliza o grupo NOVO aqui. applyMapView também estiliza — mas o grupo que existia quando ele\n      // rodou acabou de ser destruído pelo innerHTML='' acima.\n      estilizarRotulos(gL,upp);\n      const pxPerUnit=1/upp, fontPx=LABEL_PX;\n      const vx0=mapView.x, vy0=mapView.y, vx1=mapView.x+mapView.w, vy1=mapView.y+mapView.h;\n      const labelLevel=SELECIONADO?INNER_LEVEL[LEVEL]:LEVEL;\n      const restrictOuter=SELECIONADO?SELECIONADO.outerCode:null;\n      const outerArr=restrictOuter!=null?unitsAt(LEVEL):null;\n      const labelArr=labelLevel==='municipio'?null:unitsAt(labelLevel);\n      // Agrupa as FEATURES por unidade de rótulo: uma etiqueta de Setor/GV/Mercado representa,\n      // em geral, vários municípios — o ponto de inacessibilidade precisa da geometria UNIDA de\n      // todos eles, não da de um município isolado.\n      const grupos={};\n      for(let i=0;i<ALL_FEATURES.length;i++){\n        if(outerArr&&outerArr[i]!==restrictOuter) continue;\n        const f=ALL_FEATURES[i]; if(!f) continue;\n        const code=labelArr?labelArr[i]:muniKeyOfFeature(f);\n        if(!code) continue;\n        (grupos[code]=grupos[code]||[]).push(f);\n      }\n      // Candidatos visíveis, do maior para o menor em área de tela — só decide QUEM cede lugar a\n      // quem quando dois rótulos colidem, nunca descarta por ser pequeno.\n      const cands=[];\n      Object.keys(grupos).forEach(code=>{\n        const fs=grupos[code];\n        let x0=1/0,y0=1/0,x1=-1/0,y1=-1/0;\n        fs.forEach(f=>{ const bb=featBBox(f,pr); if(!bb) return;\n          if(bb.x0<x0)x0=bb.x0; if(bb.x1>x1)x1=bb.x1; if(bb.y0<y0)y0=bb.y0; if(bb.y1>y1)y1=bb.y1; });\n        if(!isFinite(x0)) return;\n        if(x1<vx0||x0>vx1||y1<vy0||y0>vy1) return;\n        cands.push({code:code,fs:fs,area:(x1-x0)*pxPerUnit*(y1-y0)*pxPerUnit});\n      });\n      cands.sort((a,b)=>b.area-a.area);\n      const placed=[];\n      cands.forEach(c=>{\n        const ck=labelLevel+'|'+c.code;\n        if(!(ck in LABEL_CACHE)) LABEL_CACHE[ck]=labelPointFor(c.fs,pr);\n        const lp=LABEL_CACHE[ck]; if(!lp||!lp.p) return;\n        let txt;\n        if(labelLevel==='municipio'){\n          const pp=(c.fs[0]&&c.fs[0].properties)||{};\n          txt=pp.name||pp.NOME||pp.nome||'';\n        } else {\n          const rec=unitRecord(c.code,labelLevel);\n          txt=(rec&&rec.nome&&rec.nome!==c.code?rec.nome+' — ':'')+c.code;\n        }\n        if(!txt) return;\n        const cx=(lp.p[0]-vx0)*pxPerUnit, cy=(lp.p[1]-vy0)*pxPerUnit;\n        const w=String(txt).length*fontPx*0.6+6, h=fontPx*1.3;\n        const bb={x0:cx-w/2,y0:cy-h/2,x1:cx+w/2,y1:cy+h/2};\n        for(let i=0;i<placed.length;i++){ const p=placed[i]; if(!(bb.x1<p.x0||bb.x0>p.x1||bb.y1<p.y0||bb.y0>p.y1)) return; }\n        placed.push(bb);\n        const t=document.createElementNS(NS,'text');\n        t.setAttribute('x',lp.p[0].toFixed(1)); t.setAttribute('y',lp.p[1].toFixed(1));\n        t.textContent=txt;\n        gL.appendChild(t);\n      });\n      svg.appendChild(gL);\n    }\n    // Ponto de entrada de sempre: todos os chamadores (nível, busca, seleção, boot, resize) continuam\n    // chamando drawMap. Ele só remonta o que ficou velho — geometria se a projeção mudou, lotes se\n    // nível/seleção mudaram — e desenha. O quadro em si sai de applyMapView.\n    function drawMap(){\n      const svg=$('#map'); if(!svg) return;\n      // ANTES de qualquer cálculo: chamado cedo (ResizeObserver na inscrição, toggle durante a carga)\n      // não pode computar PROJECT com a malha vazia nem popular caches sem base. Ver BOOT_PRONTO.\n      if(!BOOT_PRONTO||!ALL_FEATURES.length) return;\n      syncMapDims();\n      if(!PROJECT) PROJECT=project(ALL_FEATURES);\n      const pr=PROJECT; if(!pr) return;\n      LAST_DRAW_KEY=drawKeyNow();\n      // TUDO dentro do try, e não só a inicialização: fora do boot (toggle de nível, busca, seleção)\n      // uma exceção em qualquer etapa subiria sem mensagem e a tela ficaria como estava, calada.\n      // GL_ERRO fica gravado para o resumo do cabeçalho não o sobrescrever, e limpa no sucesso.\n      try{\n        const g=glInit(); if(!g) return;\n        if(!GEO) GEO=buildGeo(pr);\n        buildBatches();\n        buildSel();\n        buildHoverGL();\n        GL_ERRO=null;\n        applyMapView();\n        drawLabels(svg,pr);\n      }catch(e){\n        GL_ERRO='falha no motor de desenho: '+((e&&e.message)||e);\n        setStatus(GL_ERRO, true);\n      }\n    }\n    function showTip(e,text){\n      const tip=$('#tip'); if(!tip) return;\n      tip.textContent=text;\n      tip.style.left=(e.clientX+14)+'px';\n      tip.style.top=(e.clientY+14)+'px';\n      tip.classList.add('on');\n    }\n    function hideTip(){ const tip=$('#tip'); if(tip) tip.classList.remove('on'); }\n\n    $$('#levelToggle button').forEach(btn=>{\n      btn.onclick=()=>{\n        LEVEL=btn.dataset.level;\n        $$('#levelToggle button').forEach(b=>b.classList.toggle('on', b===btn));\n        // Trocar de escopo zera a seleção: outerCode se refere a uma unidade do escopo ANTERIOR, e\n        // mantê-la faria o próximo clique no mapa comparar contra uma unidade de um nível que nem\n        // está mais na tela — \"já armado\" nunca bateria, ou pior, bateria por coincidência de código.\n        SELECIONADO=null; HOVER=null;\n        drawMap();\n        renderSidebar();\n        if(UNITS.loaded) setStatus(unitSummary());\n      };\n    });\n    const inpBusca=$('#searchInput'), boxBusca=$('#searchBox'), clrBusca=$('#searchClear');\n    if(inpBusca){\n      inpBusca.addEventListener('input',abrirBusca);\n      inpBusca.addEventListener('focus',abrirBusca);\n      inpBusca.addEventListener('keydown',e=>{\n        if(e.key==='ArrowDown'||e.key==='ArrowUp'){\n          if(!SR_ITENS.length) return;\n          e.preventDefault();\n          SR_IDX=(SR_IDX+(e.key==='ArrowDown'?1:SR_ITENS.length-1))%SR_ITENS.length;\n          renderBusca();\n          const el=$('#searchRes')&&$('#searchRes').querySelector('.sr-item.on');\n          if(el&&el.scrollIntoView) el.scrollIntoView({block:'nearest'});\n        } else if(e.key==='Enter'){ if(SR_IDX>=0) escolher(SR_ITENS[SR_IDX]); }\n        else if(e.key==='Escape'){ fecharBusca(); inpBusca.blur(); }\n      });\n    }\n    if(clrBusca) clrBusca.onclick=()=>{\n      if(inpBusca){ inpBusca.value=''; inpBusca.focus(); }\n      clrBusca.classList.add('hidden');\n      SELECIONADO=null; HOVER=null; fecharBusca(); renderSidebar();\n      // Limpar desfaz a busca INTEIRA, não só o texto: a busca pode ter trocado o nível e com\n      // certeza trocou o enquadramento. Deixar o mapa parado no zoom da última unidade, em outro\n      // nível, com o campo vazio, é um estado que o usuário não tem como explicar.\n      mapView={x:0,y:0,w:MAP_W,h:MAP_H};\n      applyMapView();\n      drawMap();\n      // Só sobrescreve o status se houver resumo de verdade: com UNITS não carregado, unitSummary\n      // devolve string vazia e apagaria a mensagem de erro do boot, que era a única pista na tela.\n      if(UNITS.loaded) setStatus(unitSummary());\n    };\n    // Clique fora fecha a lista. No próprio root, e não no document: tudo desta tela vive dentro\n    // dele, e um listener no document sobreviveria a uma remontagem do componente.\n    root.addEventListener('pointerdown',e=>{ if(boxBusca&&!boxBusca.contains(e.target)) fecharBusca(); },true);\n\n    const sbCloseEl=$('#sbClose'), sbFecharEl=$('#sbFechar');\n    if(sbCloseEl) sbCloseEl.onclick=()=>{\n      SELECIONADO=null; HOVER=null;\n      drawMap(); renderSidebar();\n      if(UNITS.loaded) setStatus(unitSummary());\n    };\n    // Navegação de documento inteiro: voltar pelo bfcache restaura o DOM exatamente como ficou, com\n    // o botão desabilitado e escrito \"abrindo simulador…\" para sempre.\n    window.addEventListener('pageshow',()=>{\n      const b=$('#sbFechar'); if(b){ b.disabled=false; }\n    });\n    // Escape fecha o painel. Só quando a lista de busca já está fechada — senão um único Escape\n    // fecharia as duas coisas de uma vez, e quem está digitando espera fechar só a lista.\n    root.addEventListener('keydown',e=>{\n      if(e.key!=='Escape'||!SELECIONADO) return;\n      const r=$('#searchRes'); if(r&&!r.classList.contains('hidden')) return;\n      SELECIONADO=null; HOVER=null; drawMap(); renderSidebar();\n      if(UNITS.loaded) setStatus(unitSummary());\n    });\n    if(sbFecharEl) sbFecharEl.onclick=()=>{\n      if(!SELECIONADO) return;\n      // Fase 13 (pedido do usuário — simplifica a Fase 12): a Home NÃO cria mais sessão nenhuma\n      // — só navega direto pra /simulador com a intenção na URL, sem :session_id nenhum. É o\n      // simulador que cria a sessão, no final, depois de calcular a movimentação (o mesmo que\n      // \"Salvar sessão\" já faz manualmente) — a Home nunca grava nada em geo-timeline-sessions.\n      sbFecharEl.disabled=true; sbFecharEl.textContent='abrindo simulador…';\n      window.location.href=window.location.origin+'/simulador?apply='\n        +encodeURIComponent(SELECIONADO.level)+':'+encodeURIComponent(SELECIONADO.code);\n    };\n\n    // Redesenhar a malha inteira a cada evento de resize congela a aba enquanto se arrasta a borda\n    // da janela: são ~11 mil nós refeitos por evento, e o resize dispara dezenas deles por segundo.\n    // O ajuste de altura e o reenquadramento são baratos e continuam imediatos; só o redesenho\n    // (que existe para reavaliar quais rótulos cabem) espera a janela parar.\n    let RESIZE_T=null;\n    const aoRedimensionar=()=>{\n      fitMapHeight(); syncMapDims(); applyMapView();\n      clearTimeout(RESIZE_T); RESIZE_T=setTimeout(drawMap,250);\n    };\n    window.addEventListener('resize',aoRedimensionar);\n    // O SVG re-layoutava sozinho quando o CONTAINER mudava sem evento de janela (menu lateral do\n    // shell colapsando, painel que começa oculto e aparece). O canvas não: o bitmap antigo seria\n    // esticado pelo CSS enquanto o tooltip usaria o retângulo novo — desenho e mouse desalinhados.\n    // Em rAF: fitMapHeight muda a altura do PRÓPRIO elemento observado, e fazer isso dentro do callback\n    // gera \"ResizeObserver loop completed with undelivered notifications\" no console a cada disparo.\n    if(typeof ResizeObserver!=='undefined'){\n      const wrap=$('.map-wrap');\n      if(wrap) new ResizeObserver(()=>requestAnimationFrame(aoRedimensionar)).observe(wrap);\n    }\n    // Guarda em vez de acesso direto, e a razão é ferramental: o dry_render do MCP chama mounted()\n    // com o root VAZIO (a saída de render() não é injetada nele), então a primeira linha sem guarda\n    // estoura ali antes de qualquer outra coisa ser exercitada — e o dry_render só consegue dizer\n    // \"mounted threw\", deixando de valer como teste de fumaça do resto da montagem. Estas quatro\n    // linhas eram a única exceção num arquivo em que todo o resto do acesso ao DOM já é guardado.\n    // A ordem de registro é preservada de propósito: o listener de clique em captura no #map tem\n    // de continuar depois dos de root.\n    // svgEl é o <canvas>: é ele quem recebe mouse e roda. O <svg> #map é só a camada de rótulos.\n    const svgEl=$('#gl');\n    // Alternador novo no painel esquerdo, no lugar do botão de zoom removido (a roda do mouse já\n    // zoomava; o botão só duplicava o gesto).\n    // 18/09 (pedido do usuario): a Home abre com os rotulos DESLIGADOS. A classe `on` do botao no\n    // markup de render() foi removida junto — se so um dos dois mudasse, o botao apareceria aceso\n    // com o mapa sem rotulo (ou o contrario), e o primeiro clique pareceria nao fazer nada.\n    let LABELS_ON=false;\n    const toggleLabelsEl=$('#toggleLabels');\n    if(toggleLabelsEl) toggleLabelsEl.onclick=()=>{\n      LABELS_ON=!LABELS_ON;\n      toggleLabelsEl.classList.toggle('on',LABELS_ON);\n      drawMap();\n    };\n    if(svgEl) svgEl.addEventListener('pointerdown',mapPointerDown);\n    if(svgEl) svgEl.addEventListener('wheel',e=>{ e.preventDefault(); zoomMapAt(e.deltaY<0?1.15:1/1.15, e.clientX, e.clientY); }, {passive:false});\n    root.addEventListener('pointermove',mapPointerMove);\n    root.addEventListener('pointerup',mapPointerUp);\n    // Sair do componente encerra o arrasto E limpa o sinalizador de \"arrastou\": nenhum clique vai\n    // chegar depois disso, então o supressor ficaria armado e engoliria o PRÓXIMO clique legítimo.\n    // Bug anterior à delegação, que passou a depender do mesmo sinalizador.\n    root.addEventListener('pointerleave',()=>{ mapPointerUp(); mapDidDrag=false; });\n    // Três listeners no <canvas>, e nenhum elemento por município: tooltip E clique são resolvidos por\n    // geometria. O canvas não tem filhos — e.target é sempre ele —, então \"quem está sob o mouse\" só\n    // pode vir de featureEm. (A primeira versão do motor ainda lia data-unit do alvo, que não existe\n    // mais: o clique não selecionava nada e o cabeçalho continuava prometendo \"clique direto no mapa\".)\n    // Mesma conversão linear de zoomMapAt: o viewBox acompanha a proporção do container (syncMapDims),\n    // então não há letterbox do preserveAspectRatio a compensar.\n    const featureSob=e=>{\n      const rect=svgEl.getBoundingClientRect();\n      if(!rect.width||!rect.height) return null;\n      const x=mapView.x+(e.clientX-rect.left)/rect.width*mapView.w;\n      const y=mapView.y+(e.clientY-rect.top)/rect.height*mapView.h;\n      return featureEm(x,y);\n    };\n    if(svgEl){\n      svgEl.addEventListener('mousemove',e=>{\n        const f=featureSob(e);\n        // Cursor: default no fundo do canvas (fora de qualquer município), pointer sobre\n        // território — dragging (CSS #gl.dragging, mesma especificidade) sempre vence os dois\n        // enquanto o mapa está sendo arrastado, pela ordem das regras na folha de estilo.\n        svgEl.classList.toggle('hoverable',!!f);\n        // tooltipDe devolve null fora da unidade armada (mesma regra do realce abaixo) — trata\n        // como \"sem tooltip\", não como \"vazio mas mostra a caixa\".\n        const tip=f?tooltipDe(f):null;\n        if(tip) showTip(e,tip); else hideTip();\n        // Realce de hover: a MESMA regra do clique, e a MESMA restrição do tooltip acima — com\n        // algo ARMADO, hover só existe DENTRO da unidade selecionada. Fora dela (outra unidade do\n        // mesmo escopo, já apagada) não realça nem troca o tooltip: clicar ali reinicia a\n        // seleção do zero, não é \"mais uma opção disponível agora\".\n        let novoHover=null;\n        if(f){\n          const outerHit=unitOfFeature(f,LEVEL);\n          if(outerHit){\n            if(SELECIONADO){\n              if(SELECIONADO.outerCode===outerHit){\n                const innerLevel=INNER_LEVEL[LEVEL];\n                const innerHit=innerLevel==='municipio'?muniKeyOfFeature(f):unitOfFeature(f,innerLevel);\n                if(innerHit) novoHover={level:innerLevel,code:innerHit};\n              }\n            } else {\n              novoHover={level:LEVEL,code:outerHit};\n            }\n          }\n        }\n        const mudou=(!!novoHover)!==(!!HOVER)||(novoHover&&HOVER&&(novoHover.level!==HOVER.level||novoHover.code!==HOVER.code));\n        if(mudou){ HOVER=novoHover; buildHoverGL(); glDraw(); }\n      });\n      svgEl.addEventListener('mouseleave',()=>{ svgEl.classList.remove('hoverable'); hideTip(); if(HOVER){ HOVER=null; buildHoverGL(); glDraw(); } });\n      // Selecionar DESTACA e para — nunca navega. Navegar custa criar sessão e carregar o motor\n      // inteiro, e quem clicou num mapa de 5.564 municípios pode perfeitamente ter errado o alvo.\n      // A confirmação explícita para iniciar o fechamento vive na barra lateral. Município cinza\n      // (sem unidade no nível) não seleciona nada, como antes.\n      // Um listener só, que também consome o sinalizador de arrasto. Havia um segundo listener em\n      // captura fazendo stopPropagation — que NÃO impede outro listener no MESMO elemento (só\n      // stopImmediatePropagation faria), então ele zerava mapDidDrag e este selecionava: todo\n      // arrasto que terminasse dentro do canvas abria a sidebar.\n      svgEl.addEventListener('click',e=>{\n        if(mapDidDrag){ mapDidDrag=false; return; }\n        const f=featureSob(e);\n        if(!f){\n          // Clique no fundo do canvas — fora de qualquer município — desmarca a seleção atual,\n          // igual ao X do painel lateral: é o gesto natural de \"não quero mais isto selecionado\".\n          if(SELECIONADO){ SELECIONADO=null; HOVER=null; drawMap(); renderSidebar(); if(UNITS.loaded) setStatus(unitSummary()); }\n          return;\n        }\n        const outerHit=unitOfFeature(f,LEVEL);\n        if(!outerHit) return;\n        const innerLevel=INNER_LEVEL[LEVEL];\n        const innerHit=innerLevel==='municipio'?muniKeyOfFeature(f):unitOfFeature(f,innerLevel);\n        // Descer de escopo (LEVEL) só quando o clique cai numa unidade de baixo que já ERA a\n        // selecionada (mesmo nível, mesmo código) — checado pela IDENTIDADE da seleção atual, e\n        // não recalculando outerHit/armado do zero: perto de uma fronteira o clique pode cair num\n        // município tecnicamente de outra unidade externa, e recalcular tudo de novo faria esse\n        // clique reagarrar um outer errado em vez de aprofundar a MESMA unidade já selecionada.\n        // MUNICIPIO nunca desce escopo (é folha): todo clique nele só seleciona.\n        if(innerLevel!=='municipio'&&innerHit&&SELECIONADO&&SELECIONADO.level===innerLevel&&SELECIONADO.code===innerHit){\n          // ENTRAR NO NIVEL: a unidade já selecionada, clicada de novo, vira a ARMADA do novo\n          // nivel (outerCode===code) — o mesmo estado que o mapa já sabia desenhar como\n          // \"interior\" de uma unidade, o mesmo que o clique normal produz um nível acima.\n          LEVEL=innerLevel;\n          // O toggle da esquerda e so o espelho de LEVEL. Deixa-lo para tras mostraria um escopo\n          // no painel e desenharia outro no mapa - o mesmo par markup+variavel do estado inicial.\n          $$('#levelToggle button').forEach(b=>b.classList.toggle('on', b.dataset.level===LEVEL));\n          // HOVER foi calculado sob o nivel ANTERIOR; mante-lo realcaria uma unidade que nao\n          // existe mais neste escopo. drawMap (chamado por selecionarUnidade) reconstroi o realce.\n          HOVER=null;\n          selecionarUnidade(innerLevel,innerHit,innerHit);\n          return;\n        }\n        // Primeiro clique numa unidade externa nova: só ela é selecionada (o outer). Um SEGUNDO\n        // clique DENTRO da mesma unidade já armada seleciona a unidade de baixo — é o que\n        // outerCode===outerHit detecta, sem precisar de um terceiro estado guardado à parte.\n        const armado=SELECIONADO&&SELECIONADO.outerCode===outerHit;\n        if(!armado){ selecionarUnidade(LEVEL,outerHit,outerHit); return; }\n        if(!innerHit) return;\n        // Um clique simples so SELECIONA a unidade de baixo, sem trocar o escopo — trocar de\n        // escopo (acima) exige clicar de novo na MESMA unidade já selecionada.\n        selecionarUnidade(innerLevel,innerHit,outerHit);\n      });\n    }\n\n    // O botão temporário \"testar deck.gl\" (15/09) saiu com a decisão: o CDN respondeu em 1,5 s (rede OK),\n    // o pacote falhou por desalinhamento de versões no esm.sh, e a Fase 7 foi feita SEM biblioteca.\n\n    (async()=>{\n      // try/catch em volta de TUDO: sem ele, uma exceção em qualquer passo posterior ao await\n      // deixaria a tela em branco com o status congelado em \"carregando…\", que é pior do que\n      // qualquer mensagem de erro.\n      try{\n        setStatus('carregando mapa, dominância e unidades…');\n        // Aliases PRIMEIRO, e sozinho. ensureDominantes chaveia DOM.setorByMuni chamando muniN(),\n        // que lê MUNI_ALIAS — rodando as duas em paralelo, parte das chaves sai normalizada com a\n        // tabela de aliases ainda vazia e parte com ela cheia, enquanto o desenho chama muniN() de\n        // novo já com a tabela completa e não encontra essas chaves. O município cai em \"sem\n        // dominância\" de forma não determinística, mudando de um boot para o outro. Custa um passo\n        // serial (normalmente instantâneo) e elimina uma classe inteira de bug intermitente.\n        await loadMuniAliases();\n        const results=await Promise.allSettled([ensureDominantes(), loadAllGeo(), fetchUnitRows()]);\n        const [domR,geoR,unitR]=results;\n        const msgOf=r=>((r&&r.reason&&r.reason.message)||String(r&&r.reason)||'falha desconhecida');\n        fitMapHeight();\n        if(geoR.status==='rejected'){ setStatus('erro ao carregar mapa: '+msgOf(geoR), true); return; }\n        // loadAllGeo engole a falha de cada UF individualmente e por isso NUNCA rejeita: com as 27\n        // falhando, ele \"resolve\" com zero feature e a tela mostraria um mapa em branco por baixo\n        // de um resumo confiante.\n        if(!ALL_FEATURES.length){ setStatus('nenhum contorno de município carregou — o mapa ficaria em branco', true); return; }\n        // A adjacência entre municípios é construída UMA vez, porque a coloração depende dela. Custa\n        // uma passada sobre os 642 mil pontos; o JSON.parse dos 22,6 MB que acabou de acontecer, na\n        // mesma thread, custou bem mais.\n        const domErr=domR.status==='rejected'?msgOf(domR):null;\n        const unitErr=unitR.status==='rejected'?msgOf(unitR):null;\n        // DEPOIS de saber que a base carregou, nunca antes: buildMuniAdjacency congela a chave de\n        // cada município em f.__mk e em MUNI_NBRS, e a forma dessa chave depende de DOM.hasUf, que\n        // só existe quando ensureDominantes termina. Rodando antes, um retry futuro carregaria a\n        // base em NOME/UF com os features presos em NOME e o mapa inteiro viraria cinza — com o\n        // resumo anunciando setores e CBs normalmente.\n        if(!domErr) buildMuniAdjacency();\n        // O índice só existe com as DUAS fontes: a hierarquia vem do CSV, o nome e o número do CRM.\n        if(!domErr&&!unitErr){ buildUnitIndex(unitR.value); buildSearchIndex(); }\n        // UM desenho só, e sempre DEPOIS do índice. Desenhar antes parecia dar um primeiro quadro\n        // mais rápido, mas saía um mapa rotulado com o CÓDIGO em vez do nome — e, pior, gravava no\n        // cache de cores um resultado calculado com o índice ainda vazio, deixando ilhas e unidades\n        // sem vizinho detectado cinzas PARA SEMPRE, indistinguíveis de \"sem dominância\". Redesenhar\n        // depois consertaria a tela, mas ao custo da malha inteira de novo.\n        BOOT_PRONTO=true;\n        drawMap();\n        // O motor pode ter falhado sem lançar (drawMap guarda em GL_ERRO). Sem este teste o boot\n        // seguia para o resumo feliz e a tela ficava em branco anunciando setores e CBs.\n        if(GL_ERRO){\n          setStatus(GL_ERRO+(domErr?' · dominância: '+domErr:'')+(unitErr?' · CRM: '+unitErr:''), true);\n          return;\n        }\n        if(domErr){\n          UNITS.error=unitErr;\n          setStatus('mapa carregado, mas sem dominância (clique não funciona ainda): '+domErr\n            +(unitErr?' · os nomes e números também falharam: '+unitErr:''), true);\n          return;\n        }\n        if(unitErr){ UNITS.error=unitErr; setStatus('mapa pronto, mas os nomes e números das unidades falharam: '+unitErr, true); return; }\n        setStatus(unitSummary());\n      }catch(e){\n        setStatus('falha no boot da Home: '+((e&&e.message)||e), true);\n      }\n    })();\n  }\n}\n"}},"dominantesCsvUrl":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"dominantesCsvUrl"}}}},"sessionsDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"sessionsDataset"}}}},"muniAliasDataset":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"muniAliasDataset"}}}},"osmProxy":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"osmProxy"}}}}}}}},"extends":"data2-core-primitives@Block"}},{"_id":"6aac805aad994b41d42ab054","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"extends":"data2-core-primitives@Block","component":"secGestaoUsuarios","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const ROLES = [\n  { v: 'viewer', t: 'Visualizador', d: 'So abre sessoes compartilhadas (/sessions/...)' },\n  { v: 'editor', t: 'Editor', d: 'Ve tudo e opera o simulador' },\n  { v: 'admin', t: 'Administrador', d: 'Ve tudo e gerencia usuarios' }\n];\n\nconst ERROS = {\n  'nao-autenticado': 'Sua sessao expirou. Entre novamente.',\n  'sem-perfil': 'Seu e-mail nao tem perfil cadastrado nesta gestao.',\n  'usuario-inativo': 'Seu acesso esta desativado.',\n  'requer-admin': 'Apenas administradores podem fazer isso.',\n  'email-invalido': 'E-mail invalido.',\n  'nome-obrigatorio': 'Informe o nome.',\n  'senha-curta': 'A senha precisa de pelo menos 8 caracteres.',\n  'role-invalido': 'Selecione um perfil valido.',\n  'email-ja-existe': 'Ja existe um usuario com esse e-mail.',\n  'usuario-nao-encontrado': 'Usuario nao encontrado.',\n  'nao-pode-remover-o-proprio-admin': 'Voce nao pode remover o seu proprio perfil de administrador.',\n  'nao-pode-desativar-a-si-mesmo': 'Voce nao pode desativar o seu proprio acesso.'\n};\n\nexport default {\n  css: `\n  .gu{--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--acc2:#0060df;--closing:#ff3b30;--ok:#28cd41;\n    --radius-ctrl:6px;--shadow-ctrl:0 1px 3px rgba(0,0,0,.15);\n    font-family:-apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif;\n    display:flex;flex-direction:column;height:100%;min-height:100%;width:100%;background:var(--bg);color:var(--ink);\n    box-sizing:border-box;font-size:13px;-webkit-font-smoothing:antialiased;}\n  .gu *{box-sizing:border-box;}\n  .gu-tabs{display:flex;gap:6px;padding:16px 20px 0;flex:none;flex-wrap:wrap;}\n  .gu-tab{border:1px solid var(--line);background:var(--panel);padding:6px 12px;font-size:12.5px;font-weight:600;color:var(--mut);\n    cursor:pointer;border-radius:8px;font-family:inherit;box-shadow:var(--shadow-ctrl);transition:background .1s,box-shadow .1s;}\n  .gu-tab.on{background:#fff;color:var(--ink);border-color:var(--acc);box-shadow:0 1px 3px rgba(0,0,0,.2);}\n  .gu-body{flex:1 1 auto;min-height:0;overflow:auto;padding:16px 20px 24px;}\n  .gu-panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px;box-shadow:0 1px 3px rgba(0,0,0,.08);}\n  .gu-bar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap;}\n  .gu-in{height:30px;border:1px solid var(--line);border-radius:var(--radius-ctrl);padding:0 9px;font-size:12.5px;color:var(--ink);\n    background:var(--panel);outline:none;font-family:inherit;box-shadow:var(--shadow-ctrl);}\n  .gu-in:focus{outline:2px solid rgba(0,122,255,.35);outline-offset:0;border-color:var(--acc);}\n  .gu-busca{flex:1 1 220px;min-width:160px;}\n  .gu-b{height:30px;padding:0 13px;border-radius:var(--radius-ctrl);border:1px solid var(--line);background:var(--panel);color:var(--ink);\n    font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap;box-shadow:var(--shadow-ctrl);transition:background .1s;}\n  .gu-b:hover{background:var(--panel2);}\n  .gu-b:active{background:#e5e5ea;box-shadow:inset 0 1px 2px rgba(0,0,0,.1);}\n  .gu-b.pri{background:var(--acc);border-color:var(--acc2);color:#fff;}\n  .gu-b.pri:hover{background:var(--acc2);}\n  .gu-b.pri:active{background:#0052c4;box-shadow:inset 0 1px 2px rgba(0,0,0,.25);}\n  .gu-b.dang{color:var(--closing);border-color:#f3b4ae;}\n  .gu-b.dang:hover{background:#fdeceb;}\n  .gu-b[disabled]{opacity:.4;cursor:default;box-shadow:none;}\n  .gu-b.mini{height:26px;padding:0 9px;font-size:11px;font-weight:600;}\n  .gu-scroll{overflow-x:auto;}\n  .gu-tb{width:100%;border-collapse:collapse;font-size:12.5px;min-width:720px;}\n  .gu-tb th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);\n    font-weight:600;padding:0 10px 8px;border-bottom:1px solid var(--line);white-space:nowrap;position:sticky;top:0;background:var(--panel);}\n  .gu-tb td{padding:10px 10px;border-bottom:1px solid var(--line);vertical-align:middle;}\n  .gu-tb tr:last-child td{border-bottom:0;}\n  .gu-nm{font-weight:600;}\n  .gu-em{color:var(--mut);font-size:11.5px;margin-top:1px;}\n  .gu-tag{display:inline-block;padding:2.5px 8px;border-radius:99px;font-size:10.5px;font-weight:600;white-space:nowrap;\n    background:var(--panel2);border:1px solid var(--line);color:var(--mut);}\n  .gu-tag.admin{background:#eef0fd;color:#5856d6;border-color:#d6d9f8;}\n  .gu-tag.editor{background:#e9f9ec;color:#1f9c3a;border-color:#bdeecb;}\n  .gu-tag.viewer{background:var(--panel2);color:var(--mut);}\n  .gu-tag.semperfil{background:#fff4e5;color:#c96500;border-color:#ffd8a8;}\n  .gu-tag.on{background:#e9f9ec;color:#1f9c3a;border-color:#bdeecb;}\n  .gu-tag.off{background:#fdeceb;color:var(--closing);border-color:#f3b4ae;}\n  .gu-ac{display:flex;gap:6px;justify-content:flex-end;flex-wrap:wrap;}\n  .gu-dim{color:var(--mut);font-size:11.5px;}\n  .gu-form{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:14px;margin-bottom:16px;}\n  .gu-ft{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);margin-bottom:12px;}\n  .gu-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;}\n  .gu-lb{display:block;font-size:11px;font-weight:600;color:var(--mut);margin-bottom:5px;}\n  .gu-f .gu-in,.gu-f select{width:100%;}\n  select.gu-in{height:30px;appearance:none;-webkit-appearance:none;padding-right:24px;\n    background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5.5 8L10 12.5L14.5 8' fill='none' stroke='%238e8e93' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n    background-repeat:no-repeat;background-position:right 8px center;background-size:12px;}\n  .gu-hint{font-size:11px;color:var(--mut);margin-top:5px;line-height:1.45;}\n  .gu-fa{display:flex;gap:8px;margin-top:14px;flex-wrap:wrap;}\n  .gu-msg{font-size:12.5px;border-radius:8px;padding:8px 11px;margin-bottom:14px;line-height:1.45;}\n  .gu-err{color:var(--closing);background:#fdeceb;border:1px solid #f3b4ae;}\n  .gu-ok{color:#1f9c3a;background:#e9f9ec;border:1px solid #bdeecb;}\n  .gu-vazio{padding:34px 10px;text-align:center;color:var(--mut);font-size:12.5px;}\n  .gu-lg{width:100%;border-collapse:collapse;font-size:12.5px;min-width:620px;}\n  .gu-lg th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);\n    font-weight:600;padding:0 10px 8px;border-bottom:1px solid var(--line);white-space:nowrap;position:sticky;top:0;background:var(--panel);}\n  .gu-lg td{padding:8px 10px;border-bottom:1px solid var(--line);}\n  `,\n\n  render(d2) {\n    const esc = (s) => String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n    const dt = (v) => { if (!v) return '--'; try { return d2.fmt.date(v) + ' ' + d2.fmt.time(v); } catch (e) { return String(v); } };\n    const st = d2.state;\n\n    if (st.bloqueio) {\n      return `<div class=\"gu\"><div class=\"gu-body\" style=\"padding-top:24px\">\n        <div class=\"gu-panel\" style=\"border-radius:12px;max-width:520px;margin:40px auto;text-align:center\">\n          <div class=\"gu-ft\">Acesso negado</div>\n          <div class=\"gu-msg gu-err\" style=\"text-align:left\">${esc(st.bloqueio)}</div>\n        </div></div></div>`;\n    }\n\n    const eu = st.eu || null;\n    const aba = st.aba || 'usuarios';\n    const busca = String(st.busca || '').trim().toLowerCase();\n    const todos = Array.isArray(st.usuarios) ? st.usuarios : [];\n    const lista = busca\n      ? todos.filter((u) => (String(u.nome || '') + ' ' + String(u.email || '')).toLowerCase().indexOf(busca) >= 0)\n      : todos;\n    // Desativado guarda o perfil real em roleAnterior, porque role vira \"bloqueado\".\n    const roleReal = (u) => String(((u.ativo !== false) ? u.role : (u.roleAnterior || u.role)) || '');\n    const semPerfil = todos.filter((u) => ROLES.filter((x) => x.v === roleReal(u)).length === 0).length;\n\n    const tabs = `<div class=\"gu-tabs\">\n      <button class=\"gu-tab${aba === 'usuarios' ? ' on' : ''} gu-aba\" data-aba=\"usuarios\" type=\"button\">Usuarios</button>\n      <button class=\"gu-tab${aba === 'auditoria' ? ' on' : ''} gu-aba\" data-aba=\"auditoria\" type=\"button\">Auditoria</button>\n    </div>`;\n\n    const avisos = `${st.erro ? `<div class=\"gu-msg gu-err\">${esc(st.erro)}</div>` : ''}${st.ok ? `<div class=\"gu-msg gu-ok\">${esc(st.ok)}</div>` : ''}`;\n\n    if (aba === 'auditoria') {\n      const regs = Array.isArray(st.logs) ? st.logs : [];\n      const linhas = regs.map((r, i) => `<tr data-key=\"${esc(r._id || ('l' + i))}\">\n          <td class=\"gu-dim\">${esc(dt(r.quando))}</td>\n          <td>${esc(r.acao || '')}</td>\n          <td>${esc(r.alvo || '')}</td>\n          <td class=\"gu-dim\">${esc(r.ator || '')}</td>\n          <td class=\"gu-dim\">${esc(r.detalhe || '')}</td>\n        </tr>`).join('');\n      return `<div class=\"gu\">${tabs}<div class=\"gu-body\"><div class=\"gu-panel\">\n        ${avisos}\n        <div class=\"gu-bar\"><button class=\"gu-b gu-recarregar\" type=\"button\">Recarregar</button>\n          <span class=\"gu-dim\">${regs.length} registro(s), mais recentes primeiro</span></div>\n        <div class=\"gu-scroll\">${regs.length ? `<table class=\"gu-lg\">\n          <thead><tr><th>Quando</th><th>Acao</th><th>Usuario alvo</th><th>Quem fez</th><th>Detalhe</th></tr></thead>\n          <tbody>${linhas}</tbody></table>` : `<div class=\"gu-vazio\">${st.carregando ? 'Carregando...' : 'Nenhum registro de auditoria ainda.'}</div>`}</div>\n      </div></div></div>`;\n    }\n\n    const f = st.form || null;\n    const opcoes = ROLES.map((r) => `<option value=\"${r.v}\">${esc(r.t)}</option>`).join('');\n    const formHtml = f ? `<div class=\"gu-form gu-f\">\n      <div class=\"gu-ft\">${f._id ? 'Editar usuario' : 'Novo usuario'}</div>\n      <div class=\"gu-grid\">\n        <div><label class=\"gu-lb\" for=\"gu-nome\">Nome</label>\n          <input class=\"gu-in\" id=\"gu-nome\" type=\"text\" placeholder=\"Nome completo\"></div>\n        <div><label class=\"gu-lb\" for=\"gu-email\">E-mail</label>\n          <input class=\"gu-in\" id=\"gu-email\" type=\"email\" placeholder=\"pessoa@natura.net\"${f._id ? ' disabled' : ''}>\n          ${f._id ? '<div class=\"gu-hint\">O e-mail e a credencial de login e nao pode ser alterado.</div>' : ''}</div>\n        <div><label class=\"gu-lb\" for=\"gu-role\">Perfil</label>\n          <select class=\"gu-in\" id=\"gu-role\">${opcoes}</select>\n          <div class=\"gu-hint\">${ROLES.map((r) => r.t + ': ' + r.d).join('<br>')}</div></div>\n        ${f._id ? '' : `<div><label class=\"gu-lb\" for=\"gu-senha\">Senha inicial</label>\n          <input class=\"gu-in\" id=\"gu-senha\" type=\"password\" placeholder=\"Minimo 8 caracteres\">\n          <div class=\"gu-hint\">Combine com a pessoa e peca a troca no primeiro acesso.</div></div>`}\n        <div><label class=\"gu-lb\" for=\"gu-obs\">Observacao</label>\n          <input class=\"gu-in\" id=\"gu-obs\" type=\"text\" placeholder=\"Opcional\"></div>\n      </div>\n      <div class=\"gu-fa\">\n        <button class=\"gu-b pri gu-salvar\" type=\"button\"${st.salvando ? ' disabled' : ''}>${st.salvando ? 'Salvando...' : 'Salvar'}</button>\n        <button class=\"gu-b gu-cancelar\" type=\"button\">Cancelar</button>\n      </div>\n    </div>` : '';\n\n    const linhas = lista.map((u, i) => {\n      const ativo = u.ativo !== false;\n      const role = roleReal(u);\n      const meta = ROLES.filter((r) => r.v === role)[0] || null;\n      const rotuloRole = meta ? meta.t : (role ? ('Desconhecido: ' + role) : 'Sem perfil');\n      const classeRole = meta ? role : 'semperfil';\n      const souEu = eu && u._id === eu._id;\n      return `<tr data-key=\"${esc(u._id || ('u' + i))}\">\n        <td><div class=\"gu-nm\">${esc(u.nome || '(sem nome)')}${souEu ? ' <span class=\"gu-dim\">(voce)</span>' : ''}</div>\n          <div class=\"gu-em\">${esc(u.email || '')}</div></td>\n        <td><span class=\"gu-tag ${esc(classeRole)}\">${esc(rotuloRole)}</span></td>\n        <td><span class=\"gu-tag ${ativo ? 'on' : 'off'}\">${ativo ? 'Ativo' : 'Desativado'}</span></td>\n        <td class=\"gu-dim\">${esc(dt(u.ultimoAcesso))}</td>\n        <td><div class=\"gu-ac\">\n          <button class=\"gu-b mini gu-editar\" data-id=\"${esc(u._id)}\" type=\"button\">Editar</button>\n          <button class=\"gu-b mini gu-senha\" data-id=\"${esc(u._id)}\" type=\"button\">Senha</button>\n          <button class=\"gu-b mini ${ativo ? 'dang' : ''} gu-toggle\" data-id=\"${esc(u._id)}\" data-ativo=\"${ativo ? '0' : '1'}\" type=\"button\"${souEu && ativo ? ' disabled' : ''}>${ativo ? 'Desativar' : 'Reativar'}</button>\n        </div></td>\n      </tr>`;\n    }).join('');\n\n    return `<div class=\"gu\">${tabs}<div class=\"gu-body\"><div class=\"gu-panel\">\n      ${avisos}\n      <div class=\"gu-bar\">\n        <input class=\"gu-in gu-busca gu-campobusca\" type=\"search\" placeholder=\"Buscar por nome ou e-mail\">\n        <button class=\"gu-b pri gu-novo\" type=\"button\">Novo usuario</button>\n        <button class=\"gu-b gu-recarregar\" type=\"button\">Recarregar</button>\n        <span class=\"gu-dim\">${lista.length} de ${todos.length}</span>\n        ${semPerfil ? `<span class=\"gu-tag semperfil\">${semPerfil} sem perfil valido</span>` : ''}\n      </div>\n      ${formHtml}\n      <div class=\"gu-scroll\">${lista.length ? `<table class=\"gu-tb\">\n        <thead><tr><th>Usuario</th><th>Perfil</th><th>Status</th><th>Ultimo acesso</th><th></th></tr></thead>\n        <tbody>${linhas}</tbody></table>` : `<div class=\"gu-vazio\">${st.carregando ? 'Carregando...' : (todos.length ? 'Nenhum usuario bate com a busca.' : 'Nenhum usuario cadastrado.')}</div>`}</div>\n    </div></div></div>`;\n  },\n\n  mounted(d2, root) {\n    const norm = (o) => (o && o.ok === undefined && o.data && typeof o.data === 'object') ? o.data : o;\n\n    const chamar = async (nomeParam, payload) => {\n      const mod = d2.param(nomeParam);\n      if (!mod) throw new Error('parametro ' + nomeParam + ' nao esta ligado no bloco');\n      const r = norm(await d2.rush(mod).invoke(payload || {}));\n      return r || {};\n    };\n\n    const falhou = (r) => {\n      const cod = r && r.erro ? r.erro : '';\n      if (cod === 'nao-autenticado' || cod === 'sem-perfil' || cod === 'usuario-inativo' || cod === 'requer-admin') {\n        d2.state.bloqueio = ERROS[cod] || cod;\n        return true;\n      }\n      d2.state.erro = ERROS[cod] || ('Nao foi possivel concluir a operacao. (' + (cod || 'erro') + ')');\n      return true;\n    };\n\n    const carregarUsuarios = async () => {\n      d2.state.carregando = true;\n      try {\n        const r = await chamar('rListar', {});\n        if (!r.ok) { falhou(r); } else { d2.state.usuarios = r.usuarios || []; }\n      } catch (e) { d2.state.erro = 'Falha ao carregar a lista.'; d2.log(e); }\n      d2.state.carregando = false;\n    };\n\n    const carregarLogs = async () => {\n      d2.state.carregando = true;\n      try {\n        const r = await chamar('rLogs', {});\n        if (!r.ok) { falhou(r); } else { d2.state.logs = r.registros || []; }\n      } catch (e) { d2.state.erro = 'Falha ao carregar a auditoria.'; d2.log(e); }\n      d2.state.carregando = false;\n    };\n\n    const iniciar = async () => {\n      d2.state.carregando = true;\n      try {\n        const r = await chamar('rMe', {});\n        if (!r.ok) { falhou(r); d2.state.carregando = false; return; }\n        d2.state.eu = r.usuario;\n        if (!r.ehAdmin) {\n          d2.state.bloqueio = 'Seu perfil e \"' + String(r.usuario.role) + '\". Apenas administradores acessam a gestao de usuarios.';\n          d2.state.carregando = false;\n          return;\n        }\n        await carregarUsuarios();\n      } catch (e) {\n        // Listener gateado: sem sessao valida o invoke lanca (401), nao devolve corpo.\n        d2.state.bloqueio = 'Sem sessao valida nesta aplicacao. Entre pela tela de login do SEC e volte a esta pagina.';\n        d2.log(e);\n      }\n      d2.state.carregando = false;\n    };\n\n    const abrirForm = (registro) => {\n      d2.state.erro = ''; d2.state.ok = '';\n      d2.state.form = registro || { nome: '', email: '', role: 'viewer', observacao: '' };\n      d2.state.formSeq = (d2.state.formSeq || 0) + 1;\n    };\n\n    d2.on('.gu-aba', 'click', async (e) => {\n      const alvo = e.currentTarget || e.target;\n      const aba = alvo.getAttribute('data-aba');\n      d2.state.aba = aba; d2.state.erro = ''; d2.state.ok = '';\n      if (aba === 'auditoria') await carregarLogs();\n    });\n\n    d2.on('.gu-recarregar', 'click', async () => {\n      d2.state.erro = ''; d2.state.ok = '';\n      if ((d2.state.aba || 'usuarios') === 'auditoria') { await carregarLogs(); } else { await carregarUsuarios(); }\n    });\n\n    d2.on('.gu-campobusca', 'input', (e) => { d2.state.busca = (e.target && e.target.value) || ''; });\n\n    d2.on('.gu-novo', 'click', () => abrirForm(null));\n    d2.on('.gu-cancelar', 'click', () => { d2.state.form = null; });\n\n    d2.on('.gu-editar', 'click', (e) => {\n      const alvo = e.currentTarget || e.target;\n      const id = alvo.getAttribute('data-id');\n      const u = (d2.state.usuarios || []).filter((x) => x._id === id)[0];\n      if (u) {\n        const at = u.ativo !== false;\n        abrirForm({ _id: u._id, nome: u.nome, email: u.email, role: (at ? u.role : (u.roleAnterior || u.role)), observacao: u.observacao });\n      }\n    });\n\n    d2.on('.gu-salvar', 'click', async () => {\n      if (d2.state.salvando) return;\n      const f = d2.state.form || {};\n      const val = (sel) => { const el = d2.$(sel); return el ? String(el.value || '').trim() : ''; };\n      const nome = val('#gu-nome');\n      const role = val('#gu-role') || 'viewer';\n      const obs = val('#gu-obs');\n      d2.state.erro = ''; d2.state.ok = '';\n      d2.state.salvando = true;\n      try {\n        let r;\n        if (f._id) {\n          r = await chamar('rSalvar', { _id: f._id, nome: nome, role: role, observacao: obs });\n        } else {\n          const email = String(val('#gu-email')).toLowerCase();\n          const senha = (d2.$('#gu-senha') || {}).value || '';\n          r = await chamar('rCriar', { nome: nome, email: email, senha: senha, role: role, observacao: obs });\n        }\n        d2.state.salvando = false;\n        if (!r.ok) { falhou(r); return; }\n        d2.state.ok = f._id ? 'Usuario atualizado.' : 'Usuario criado.';\n        d2.state.form = null;\n        await carregarUsuarios();\n      } catch (e) {\n        d2.state.salvando = false;\n        d2.state.erro = 'Falha ao salvar.';\n        d2.log(e);\n      }\n    });\n\n    d2.on('.gu-toggle', 'click', async (e) => {\n      const alvo = e.currentTarget || e.target;\n      const id = alvo.getAttribute('data-id');\n      const querAtivo = alvo.getAttribute('data-ativo') === '1';\n      const u = (d2.state.usuarios || []).filter((x) => x._id === id)[0];\n      const rotulo = u ? (u.nome || u.email) : 'este usuario';\n      const texto = querAtivo ? ('Reativar o acesso de ' + rotulo + '?') : ('Desativar o acesso de ' + rotulo + '? A pessoa deixa de entrar no SEC.');\n      const sim = await d2.dialog.confirm(texto);\n      if (!sim) return;\n      d2.state.erro = ''; d2.state.ok = '';\n      try {\n        const r = await chamar('rAtivo', { _id: id, ativo: querAtivo });\n        if (!r.ok) { falhou(r); return; }\n        d2.state.ok = querAtivo ? 'Acesso reativado.' : 'Acesso desativado.';\n        await carregarUsuarios();\n      } catch (er) { d2.state.erro = 'Falha ao alterar o status.'; d2.log(er); }\n    });\n\n    d2.on('.gu-senha', 'click', async (e) => {\n      const alvo = e.currentTarget || e.target;\n      const id = alvo.getAttribute('data-id');\n      const u = (d2.state.usuarios || []).filter((x) => x._id === id)[0];\n      const nova = await d2.dialog.prompt('Nova senha para ' + (u ? (u.nome || u.email) : 'o usuario') + ' (minimo 8 caracteres):');\n      if (!nova) return;\n      d2.state.erro = ''; d2.state.ok = '';\n      try {\n        const r = await chamar('rResetar', { _id: id, novaSenha: String(nova) });\n        if (!r.ok) { falhou(r); return; }\n        d2.state.ok = 'Senha redefinida. Combine a entrega com a pessoa por um canal seguro.';\n      } catch (er) { d2.state.erro = 'Falha ao redefinir a senha.'; d2.log(er); }\n    });\n\n    iniciar();\n  },\n\n  updated(d2, root) {\n    const seq = d2.state.formSeq || 0;\n    if (root.__guSeq === seq) return;\n    root.__guSeq = seq;\n    const f = d2.state.form;\n    if (!f) return;\n    const por = (sel, v) => { const el = d2.$(sel); if (el) el.value = v == null ? '' : String(v); };\n    por('#gu-nome', f.nome);\n    por('#gu-email', f.email);\n    por('#gu-obs', f.observacao);\n    por('#gu-senha', '');\n    const sel = d2.$('#gu-role');\n    if (sel) sel.value = f.role || 'viewer';\n  }\n}"}},"auth":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"auth"}}}},"rMe":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rMe"}}}},"rListar":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rListar"}}}},"rCriar":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rCriar"}}}},"rSalvar":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rSalvar"}}}},"rAtivo":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rAtivo"}}}},"rResetar":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rResetar"}}}},"rMinhaSenha":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rMinhaSenha"}}}},"rLogs":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rLogs"}}}}}}}}}},{"_id":"6aac90fd8f873e8e1e3ef3a8","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"extends":"data2-core-primitives@Block","component":"secPerfilUsuario","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const ROLES = {\n  viewer: 'Visualizador',\n  editor: 'Editor',\n  admin: 'Administrador',\n  bloqueado: 'Bloqueado'\n};\n\nconst ERROS = {\n  'nao-autenticado': 'Sua sessao expirou. Entre novamente.',\n  'sem-perfil': 'Seu e-mail nao tem cadastro nesta aplicacao.',\n  'usuario-inativo': 'Seu acesso esta desativado.',\n  'senha-curta': 'A senha precisa de pelo menos 8 caracteres.'\n};\n\nconst LADO = 192;\n\nexport default {\n  css: `\n  .pf{--bg:#f5f5f7;--panel:#ffffff;--panel2:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--mut:#6e6e73;--acc:#007aff;--acc2:#0060df;--closing:#ff3b30;--ok:#28cd41;\n    --radius-ctrl:6px;--shadow-ctrl:0 1px 3px rgba(0,0,0,.15);\n    font-family:-apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif;\n    display:flex;flex-direction:column;height:100%;min-height:100%;width:100%;background:var(--bg);color:var(--ink);\n    box-sizing:border-box;font-size:13px;-webkit-font-smoothing:antialiased;}\n  .pf *{box-sizing:border-box;}\n  .pf-body{flex:1 1 auto;min-height:0;overflow:auto;padding:24px 20px 40px;}\n  .pf-wrap{max-width:640px;margin:0 auto;display:flex;flex-direction:column;gap:16px;}\n  .pf-card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;box-shadow:0 1px 3px rgba(0,0,0,.08);}\n  .pf-ct{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:var(--mut);margin-bottom:6px;}\n  .pf-cs{font-size:12px;color:var(--mut);margin-bottom:16px;line-height:1.5;}\n  .pf-foto{display:flex;align-items:center;gap:18px;flex-wrap:wrap;}\n  .pf-av{width:88px;height:88px;border-radius:50%;overflow:hidden;flex:none;background:var(--panel2);border:1px solid var(--line);display:block;}\n  .pf-av img{width:100%;height:100%;object-fit:cover;display:block;}\n  .pf-av svg{width:100%;height:100%;display:block;}\n  .pf-fa{display:flex;gap:8px;flex-wrap:wrap;}\n  .pf-lb{display:block;font-size:11px;font-weight:600;color:var(--mut);margin-bottom:5px;}\n  .pf-in{width:100%;height:30px;border:1px solid var(--line);border-radius:var(--radius-ctrl);padding:0 9px;font-size:12.5px;\n    color:var(--ink);background:var(--panel);outline:none;font-family:inherit;box-shadow:var(--shadow-ctrl);}\n  .pf-in:focus{outline:2px solid rgba(0,122,255,.35);outline-offset:0;border-color:var(--acc);}\n  .pf-in:disabled{background:var(--panel2);color:var(--mut);box-shadow:none;}\n  .pf-campo{margin-bottom:14px;}\n  .pf-senha{position:relative;}\n  .pf-senha .pf-in{padding-right:40px;}\n  .pf-olho{position:absolute;right:3px;top:2px;width:26px;height:26px;border:0;background:transparent;cursor:pointer;\n    display:flex;align-items:center;justify-content:center;border-radius:6px;padding:0;color:var(--mut);}\n  .pf-olho:hover{background:var(--panel2);color:var(--ink);}\n  .pf-olho svg{width:17px;height:17px;display:block;}\n  .pf-b{height:30px;padding:0 13px;border-radius:var(--radius-ctrl);border:1px solid var(--line);background:var(--panel);color:var(--ink);\n    font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap;box-shadow:var(--shadow-ctrl);transition:background .1s;}\n  .pf-b:hover{background:var(--panel2);}\n  .pf-b:active{background:#e5e5ea;box-shadow:inset 0 1px 2px rgba(0,0,0,.1);}\n  .pf-b.pri{background:var(--acc);border-color:var(--acc2);color:#fff;}\n  .pf-b.pri:hover{background:var(--acc2);}\n  .pf-b.pri:active{background:#0052c4;box-shadow:inset 0 1px 2px rgba(0,0,0,.25);}\n  .pf-b.dang{color:var(--closing);border-color:#f3b4ae;}\n  .pf-b.dang:hover{background:#fdeceb;}\n  .pf-b[disabled]{opacity:.4;cursor:default;box-shadow:none;}\n  .pf-msg{font-size:12.5px;border-radius:8px;padding:8px 11px;line-height:1.45;margin-bottom:14px;}\n  .pf-err{color:var(--closing);background:#fdeceb;border:1px solid #f3b4ae;}\n  .pf-ok{color:#1f9c3a;background:#e9f9ec;border:1px solid #bdeecb;}\n  .pf-tag{display:inline-block;padding:2.5px 8px;border-radius:99px;font-size:10.5px;font-weight:600;\n    background:var(--panel2);border:1px solid var(--line);color:var(--mut);}\n  .pf-hint{font-size:11px;color:var(--mut);margin-top:5px;line-height:1.45;}\n  .pf-file{display:none;}\n  .pf-carr{padding:40px;text-align:center;color:var(--mut);font-size:12.5px;}\n  `,\n\n  render(d2) {\n    const esc = (s) => String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n    const st = d2.state;\n\n    if (st.bloqueio) {\n      return `<div class=\"pf\"><div class=\"pf-body\"><div class=\"pf-wrap\"><div class=\"pf-card\">\n        <div class=\"pf-ct\">Acesso negado</div>\n        <div class=\"pf-msg pf-err\">${esc(st.bloqueio)}</div>\n      </div></div></div></div>`;\n    }\n\n    if (!st.eu) {\n      return `<div class=\"pf\"><div class=\"pf-body\"><div class=\"pf-carr\">Carregando seu perfil...</div></div></div>`;\n    }\n\n    const eu = st.eu;\n    const foto = st.fotoPreview || (st.removerFoto ? '' : (eu.fotoUrl || ''));\n    const avatar = foto\n      ? `<img src=\"${esc(foto)}\" alt=\"Foto de perfil\">`\n      : `<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"12\" fill=\"#dadce0\"></circle><circle cx=\"12\" cy=\"9\" r=\"3.6\" fill=\"#ffffff\"></circle><path d=\"M12 13.8c-3.5 0-6.4 2.1-6.9 4.8a12 12 0 0 0 13.8 0c-.5-2.7-3.4-4.8-6.9-4.8z\" fill=\"#ffffff\"></path></svg>`;\n\n    const olho = (aberto) => aberto\n      ? `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 3l18 18\"></path><path d=\"M10.6 10.6a2 2 0 002.8 2.8\"></path><path d=\"M9.4 5.2A9.5 9.5 0 0112 5c5 0 9 4.5 9 7 0 .9-.6 2.1-1.6 3.2\"></path><path d=\"M6.2 6.6C4.1 8 3 9.9 3 12c0 2.5 4 7 9 7 1.3 0 2.5-.3 3.6-.8\"></path></svg>`\n      : `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12s3.6-7 9-7 9 7 9 7-3.6 7-9 7-9-7-9-7z\"></path><circle cx=\"12\" cy=\"12\" r=\"2.6\"></circle></svg>`;\n\n    const avisos = `${st.erro ? `<div class=\"pf-msg pf-err\">${esc(st.erro)}</div>` : ''}${st.ok ? `<div class=\"pf-msg pf-ok\">${esc(st.ok)}</div>` : ''}`;\n\n    return `<div class=\"pf\"><div class=\"pf-body\"><div class=\"pf-wrap\">\n      ${avisos}\n\n      <div class=\"pf-card\">\n        <div class=\"pf-ct\">Foto e nome</div>\n        <div class=\"pf-cs\">A foto aparece no canto superior direito do SEC.</div>\n        <div class=\"pf-foto\">\n          <span class=\"pf-av\">${avatar}</span>\n          <div class=\"pf-fa\">\n            <button class=\"pf-b pf-escolher\" type=\"button\">${foto ? 'Trocar foto' : 'Adicionar foto'}</button>\n            ${foto ? '<button class=\"pf-b dang pf-remover\" type=\"button\">Remover</button>' : ''}\n            <input class=\"pf-file\" id=\"pf-arquivo\" type=\"file\" accept=\"image/*\">\n          </div>\n        </div>\n        <div class=\"pf-hint\">A imagem e recortada em quadrado e reduzida para ${LADO}px antes de ser salva.</div>\n        <div class=\"pf-campo\" style=\"margin-top:16px\">\n          <label class=\"pf-lb\" for=\"pf-nome\">Nome</label>\n          <input class=\"pf-in\" id=\"pf-nome\" type=\"text\" placeholder=\"Seu nome\">\n        </div>\n        <div class=\"pf-campo\">\n          <label class=\"pf-lb\" for=\"pf-email\">E-mail</label>\n          <input class=\"pf-in\" id=\"pf-email\" type=\"text\" value=\"${esc(eu.email || '')}\" disabled>\n          <div class=\"pf-hint\">O e-mail e a sua credencial de login e so um administrador pode altera-lo.</div>\n        </div>\n        <div class=\"pf-campo\">\n          <label class=\"pf-lb\">Perfil de acesso</label>\n          <span class=\"pf-tag\">${esc(ROLES[String(eu.role)] || eu.role || 'Sem perfil')}</span>\n        </div>\n        <button class=\"pf-b pri pf-salvarperfil\" type=\"button\"${st.salvandoPerfil ? ' disabled' : ''}>${st.salvandoPerfil ? 'Salvando...' : 'Salvar perfil'}</button>\n      </div>\n\n      <div class=\"pf-card\">\n        <div class=\"pf-ct\">Alterar senha</div>\n        <div class=\"pf-cs\">Minimo de 8 caracteres. Digite duas vezes para confirmar.</div>\n        <div class=\"pf-campo\">\n          <label class=\"pf-lb\" for=\"pf-s1\">Nova senha</label>\n          <div class=\"pf-senha\">\n            <input class=\"pf-in\" id=\"pf-s1\" type=\"${st.ver1 ? 'text' : 'password'}\" autocomplete=\"new-password\" placeholder=\"Nova senha\">\n            <button class=\"pf-olho pf-ver1\" type=\"button\" title=\"${st.ver1 ? 'Ocultar senha' : 'Mostrar senha'}\" aria-label=\"${st.ver1 ? 'Ocultar senha' : 'Mostrar senha'}\">${olho(st.ver1)}</button>\n          </div>\n        </div>\n        <div class=\"pf-campo\">\n          <label class=\"pf-lb\" for=\"pf-s2\">Confirmar nova senha</label>\n          <div class=\"pf-senha\">\n            <input class=\"pf-in\" id=\"pf-s2\" type=\"${st.ver2 ? 'text' : 'password'}\" autocomplete=\"new-password\" placeholder=\"Repita a nova senha\">\n            <button class=\"pf-olho pf-ver2\" type=\"button\" title=\"${st.ver2 ? 'Ocultar senha' : 'Mostrar senha'}\" aria-label=\"${st.ver2 ? 'Ocultar senha' : 'Mostrar senha'}\">${olho(st.ver2)}</button>\n          </div>\n        </div>\n        <button class=\"pf-b pri pf-salvarsenha\" type=\"button\"${st.salvandoSenha ? ' disabled' : ''}>${st.salvandoSenha ? 'Alterando...' : 'Alterar senha'}</button>\n      </div>\n    </div></div></div>`;\n  },\n\n  mounted(d2, root) {\n    const norm = (o) => (o && o.ok === undefined && o.data && typeof o.data === 'object') ? o.data : o;\n\n    const chamar = async (nomeParam, payload) => {\n      const mod = d2.param(nomeParam);\n      if (!mod) throw new Error('parametro ' + nomeParam + ' nao esta ligado no bloco');\n      const r = norm(await d2.rush(mod).invoke(payload || {}));\n      return r || {};\n    };\n\n    const falhou = (r) => {\n      const cod = r && r.erro ? r.erro : '';\n      if (cod === 'nao-autenticado' || cod === 'sem-perfil' || cod === 'usuario-inativo') {\n        d2.state.bloqueio = ERROS[cod] || cod;\n        return;\n      }\n      d2.state.erro = ERROS[cod] || ('Nao foi possivel concluir. (' + (cod || 'erro') + ')');\n    };\n\n    const carregar = async () => {\n      try {\n        const r = await chamar('rMe', {});\n        if (!r.ok) { falhou(r); return; }\n        d2.state.eu = r.usuario;\n        d2.state.nomeSeq = (d2.state.nomeSeq || 0) + 1;\n      } catch (e) {\n        d2.state.bloqueio = 'Sem sessao valida nesta aplicacao. Entre pela tela de login do SEC.';\n        d2.log(e);\n      }\n    };\n\n    const lerArquivo = (file) => new Promise((res, rej) => {\n      const fr = new FileReader();\n      fr.onload = () => res(fr.result);\n      fr.onerror = () => rej(new Error('falha ao ler o arquivo'));\n      fr.readAsDataURL(file);\n    });\n\n    const redimensionar = (dataUri) => new Promise((res, rej) => {\n      const img = new Image();\n      img.onload = () => {\n        try {\n          const c = document.createElement('canvas');\n          c.width = LADO; c.height = LADO;\n          const ctx = c.getContext('2d');\n          const m = Math.min(img.width, img.height);\n          const sx = (img.width - m) / 2;\n          const sy = (img.height - m) / 2;\n          ctx.drawImage(img, sx, sy, m, m, 0, 0, LADO, LADO);\n          res(c.toDataURL('image/jpeg', 0.85));\n        } catch (e) { rej(e); }\n      };\n      img.onerror = () => rej(new Error('arquivo nao e uma imagem valida'));\n      img.src = dataUri;\n    });\n\n    d2.on('.pf-escolher', 'click', () => { const el = d2.$('#pf-arquivo'); if (el) el.click(); });\n\n    d2.on('.pf-remover', 'click', () => {\n      d2.state.erro = ''; d2.state.ok = '';\n      d2.state.fotoPreview = '';\n      d2.state.removerFoto = true;\n    });\n\n    d2.on('#pf-arquivo', 'change', async (e) => {\n      const alvo = e.target;\n      const file = alvo && alvo.files && alvo.files[0];\n      if (!file) return;\n      d2.state.erro = ''; d2.state.ok = '';\n      if (file.size > 8 * 1024 * 1024) {\n        d2.state.erro = 'Imagem muito grande. Use um arquivo de ate 8 MB.';\n        alvo.value = '';\n        return;\n      }\n      try {\n        const bruto = await lerArquivo(file);\n        const pequena = await redimensionar(bruto);\n        d2.state.removerFoto = false;\n        d2.state.fotoPreview = pequena;\n        d2.state.ok = 'Previa carregada. Clique em Salvar perfil para gravar.';\n      } catch (err) {\n        d2.state.erro = 'Nao foi possivel ler essa imagem.';\n        d2.log(err);\n      }\n      try { alvo.value = ''; } catch (e2) { d2.log(e2); }\n    });\n\n    d2.on('.pf-salvarperfil', 'click', async () => {\n      if (d2.state.salvandoPerfil) return;\n      const campo = d2.$('#pf-nome');\n      const nome = campo ? String(campo.value || '').trim() : '';\n      if (nome.length < 2) { d2.state.erro = 'Informe um nome com pelo menos 2 caracteres.'; return; }\n      d2.state.erro = ''; d2.state.ok = '';\n      d2.state.salvandoPerfil = true;\n      try {\n        const corpo = { nome: nome };\n        if (d2.state.removerFoto) { corpo.removerFoto = true; }\n        else if (d2.state.fotoPreview) { corpo.fotoUrl = d2.state.fotoPreview; }\n        const r = await chamar('rPerfil', corpo);\n        d2.state.salvandoPerfil = false;\n        if (!r.ok) { falhou(r); return; }\n        d2.state.fotoPreview = '';\n        d2.state.removerFoto = false;\n        d2.state.ok = 'Perfil atualizado.';\n        await carregar();\n        try { if (d2.bus && d2.bus.emit) d2.bus.emit('sec:perfil-mudou'); } catch (e2) { d2.log(e2); }\n      } catch (err) {\n        d2.state.salvandoPerfil = false;\n        d2.state.erro = 'Falha ao salvar o perfil.';\n        d2.log(err);\n      }\n    });\n\n    d2.on('.pf-ver1', 'click', () => { d2.state.ver1 = !d2.state.ver1; });\n    d2.on('.pf-ver2', 'click', () => { d2.state.ver2 = !d2.state.ver2; });\n\n    d2.on('.pf-salvarsenha', 'click', async () => {\n      if (d2.state.salvandoSenha) return;\n      const a = d2.$('#pf-s1');\n      const b = d2.$('#pf-s2');\n      const s1 = a ? String(a.value || '') : '';\n      const s2 = b ? String(b.value || '') : '';\n      d2.state.erro = ''; d2.state.ok = '';\n      if (s1.length < 8) { d2.state.erro = 'A senha precisa de pelo menos 8 caracteres.'; return; }\n      if (s1 !== s2) { d2.state.erro = 'As duas senhas nao sao iguais.'; return; }\n      d2.state.salvandoSenha = true;\n      try {\n        const r = await chamar('rSenha', { novaSenha: s1 });\n        d2.state.salvandoSenha = false;\n        if (!r.ok) { falhou(r); return; }\n        if (a) a.value = '';\n        if (b) b.value = '';\n        d2.state.ver1 = false;\n        d2.state.ver2 = false;\n        d2.state.ok = 'Senha alterada.';\n      } catch (err) {\n        d2.state.salvandoSenha = false;\n        d2.state.erro = 'Falha ao alterar a senha.';\n        d2.log(err);\n      }\n    });\n\n    carregar();\n  },\n\n  updated(d2, root) {\n    const seq = d2.state.nomeSeq || 0;\n    if (root.__pfSeq === seq) return;\n    root.__pfSeq = seq;\n    const eu = d2.state.eu;\n    if (!eu) return;\n    const campo = d2.$('#pf-nome');\n    if (campo) campo.value = eu.nome == null ? '' : String(eu.nome);\n  }\n}"}},"auth":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"auth"}}}},"rMe":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rMe"}}}},"rPerfil":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rPerfil"}}}},"rSenha":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rSenha"}}}},"roteador":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"roteador"}}}},"rotaVoltar":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rotaVoltar"}}}}}}}}}},{"_id":"6aac913a8f873e8e1e3ef3fa","component":"data2-core-types@Component","system":"6a57cfe9673eb7e3c5e57784","params":{"extends":"data2-core-primitives@Block","component":"secHeaderPerfil","package":"natura-geomarketing","definition":{"preventEvaluation":true,"component":"data2-core-types@Definition","params":{"def":{"component":"data2-code-components@CodeHost","params":{"code":{"component":"data2-core-primitives@String","params":{"content":"const ROLES = {\n  viewer: 'Visualizador',\n  editor: 'Editor',\n  admin: 'Administrador',\n  bloqueado: 'Bloqueado'\n};\n\nconst AVATAR_VAZIO = `<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"12\" fill=\"#dadce0\"></circle><circle cx=\"12\" cy=\"9\" r=\"3.6\" fill=\"#ffffff\"></circle><path d=\"M12 13.8c-3.5 0-6.4 2.1-6.9 4.8a12 12 0 0 0 13.8 0c-.5-2.7-3.4-4.8-6.9-4.8z\" fill=\"#ffffff\"></path></svg>`;\n\nexport default {\n  css: `\n  /* fixed + inset:0 em vez de relative/100%: o host pode acabar no fluxo do documento\n     dependendo de como o player dimensiona a artboard, e ai o overlay descia para baixo\n     do mapa e criava scroll. Fixo ao viewport isso nao acontece e nao ocupa altura. */\n  .hp{position:fixed;inset:0;pointer-events:none;z-index:2147483000;\n    font-family:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif;\n    color:#111827;font-size:14px;box-sizing:border-box;}\n  .hp *{box-sizing:border-box;}\n  .hp-in{position:absolute;top:14px;right:16px;display:flex;flex-direction:column;align-items:flex-end;gap:10px;}\n  .hp-btn{pointer-events:auto;width:40px;height:40px;border-radius:50%;border:0;padding:0;cursor:pointer;\n    background:#fff;overflow:hidden;display:block;box-shadow:0 1px 3px rgba(0,0,0,.16),0 1px 8px rgba(0,0,0,.08);}\n  .hp-btn:hover{box-shadow:0 2px 6px rgba(0,0,0,.22),0 2px 12px rgba(0,0,0,.1);}\n  .hp-btn:focus-visible{outline:2px solid #00713c;outline-offset:2px;}\n  .hp-btn img{width:100%;height:100%;object-fit:cover;display:block;}\n  .hp-btn svg{width:100%;height:100%;display:block;}\n  .hp-menu{pointer-events:auto;width:300px;background:#fff;border-radius:16px;overflow:hidden;\n    box-shadow:0 2px 6px rgba(0,0,0,.16),0 8px 28px rgba(0,0,0,.18);}\n  .hp-topo{padding:20px 18px 16px;text-align:center;border-bottom:1px solid #eef0f3;}\n  .hp-av{width:72px;height:72px;border-radius:50%;overflow:hidden;margin:0 auto 10px;display:block;background:#dadce0;}\n  .hp-av img{width:100%;height:100%;object-fit:cover;display:block;}\n  .hp-av svg{width:100%;height:100%;display:block;}\n  .hp-nome{font-size:14.5px;font-weight:650;line-height:1.3;word-break:break-word;}\n  .hp-mail{font-size:12.5px;color:#6b7280;margin-top:2px;word-break:break-all;}\n  .hp-tag{display:inline-block;margin-top:9px;padding:3px 10px;border-radius:999px;font-size:11.5px;font-weight:650;}\n  .hp-tag.admin{background:#eef2ff;color:#3538cd;}\n  .hp-tag.editor{background:#ecfdf3;color:#05603a;}\n  .hp-tag.viewer{background:#f2f4f7;color:#475467;}\n  .hp-tag.bloqueado{background:#fef3f2;color:#b42318;}\n  .hp-tag.semperfil{background:#fffaeb;color:#b54708;}\n  .hp-itens{padding:6px;}\n  .hp-it{width:100%;display:flex;align-items:center;gap:12px;padding:10px 12px;border:0;background:transparent;\n    border-radius:10px;cursor:pointer;font:inherit;font-size:13.5px;color:#111827;text-align:left;}\n  .hp-it:hover{background:#f3f4f6;}\n  .hp-it svg{width:19px;height:19px;flex:none;color:#5f6368;}\n  .hp-sep{height:1px;background:#eef0f3;margin:6px 0;}\n  .hp-aviso{padding:14px 18px;font-size:12.5px;color:#b42318;background:#fef3f2;line-height:1.45;}\n  `,\n\n  render(d2) {\n    const esc = (s) => String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n    const st = d2.state;\n    const eu = st.eu || null;\n    const aberto = !!st.aberto;\n\n    const foto = eu && eu.fotoUrl ? String(eu.fotoUrl) : '';\n    const miniatura = foto ? `<img src=\"${esc(foto)}\" alt=\"\">` : AVATAR_VAZIO;\n    const grande = foto ? `<img src=\"${esc(foto)}\" alt=\"\">` : AVATAR_VAZIO;\n\n    const rotuloBotao = eu ? ('Conta de ' + (eu.nome || eu.email || '')) : 'Conta';\n\n    let corpo = '';\n    if (!eu) {\n      corpo = `<div class=\"hp-aviso\">Nao foi possivel identificar sua sessao nesta aplicacao.${st.motivo ? `<br><b>Motivo:</b> ${esc(st.motivo)}` : ''}</div>\n        <div class=\"hp-itens\"><button class=\"hp-it hp-sair\" type=\"button\">\n          <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4\"></path><path d=\"M16 17l5-5-5-5\"></path><path d=\"M21 12H9\"></path></svg>\n          Sair</button></div>`;\n    } else {\n      const role = String(eu.role || '');\n      const classe = ROLES[role] ? role : 'semperfil';\n      const rotulo = ROLES[role] || (role ? ('Desconhecido: ' + role) : 'Sem perfil');\n      corpo = `<div class=\"hp-topo\">\n          <span class=\"hp-av\">${grande}</span>\n          <div class=\"hp-nome\">${esc(eu.nome || '(sem nome)')}</div>\n          <div class=\"hp-mail\">${esc(eu.email || '')}</div>\n          <span class=\"hp-tag ${esc(classe)}\">${esc(rotulo)}</span>\n        </div>\n        <div class=\"hp-itens\">\n          <button class=\"hp-it hp-perfil\" type=\"button\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"8\" r=\"4\"></circle><path d=\"M4 21c0-4 3.6-6 8-6s8 2 8 6\"></path></svg>\n            Meu perfil</button>\n          ${st.ehAdmin ? `<button class=\"hp-it hp-usuarios\" type=\"button\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"9\" cy=\"8\" r=\"3.4\"></circle><path d=\"M2.5 20c0-3.4 3-5.2 6.5-5.2s6.5 1.8 6.5 5.2\"></path><path d=\"M17 4.5a3.4 3.4 0 010 6.8\"></path><path d=\"M18.5 14.6c2 .7 3 2.2 3 4.4\"></path></svg>\n            Gerenciar usuarios</button>` : ''}\n          <div class=\"hp-sep\"></div>\n          <button class=\"hp-it hp-sair\" type=\"button\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4\"></path><path d=\"M16 17l5-5-5-5\"></path><path d=\"M21 12H9\"></path></svg>\n            Sair</button>\n        </div>`;\n    }\n\n    return `<div class=\"hp\"><div class=\"hp-in\">\n      <button class=\"hp-btn hp-toggle\" type=\"button\" aria-haspopup=\"menu\" aria-expanded=\"${aberto ? 'true' : 'false'}\" title=\"${esc(rotuloBotao)}\" aria-label=\"${esc(rotuloBotao)}\">${miniatura}</button>\n      ${aberto ? `<div class=\"hp-menu\" role=\"menu\">${corpo}</div>` : ''}\n    </div></div>`;\n  },\n\n  mounted(d2, root) {\n    const norm = (o) => (o && o.ok === undefined && o.data && typeof o.data === 'object') ? o.data : o;\n\n    const carregar = async () => {\n      const mod = d2.param('rMe');\n      if (!mod) { d2.log('rMe nao ligado no bloco'); return; }\n      try {\n        const r = norm(await d2.rush(mod).invoke({}));\n        if (r && r.ok && r.usuario) {\n          d2.state.eu = r.usuario;\n          d2.state.ehAdmin = !!r.ehAdmin;\n        } else {\n          d2.state.eu = null;\n          d2.state.ehAdmin = false;\n          d2.state.motivo = (r && r.erro) ? r.erro : 'resposta-inesperada';\n        }\n      } catch (e) {\n        d2.state.eu = null;\n        d2.state.ehAdmin = false;\n        d2.state.motivo = 'chamada-falhou: ' + ((e && e.message) || 'erro');\n        d2.log('header: sem sessao', e);\n      }\n    };\n\n    const irPara = (destino) => {\n      if (!destino) return;\n      d2.state.aberto = false;\n      try {\n        const r = d2.router(d2.param('roteador'));\n        if (r && r.goTo) { r.goTo(destino); return; }\n      } catch (e) { d2.log('goTo falhou', e); }\n      if (d2.context.isBuilder) { d2.log('builder: navegacao suprimida, destino ' + destino); return; }\n      try { window.location.assign(destino); } catch (e2) { d2.log(e2); }\n    };\n\n    d2.on('.hp-toggle', 'click', () => { d2.state.aberto = !d2.state.aberto; });\n    d2.on('.hp-perfil', 'click', () => irPara(d2.param('rotaPerfil', '/perfil')));\n    d2.on('.hp-usuarios', 'click', () => irPara(d2.param('rotaUsuarios', '/usuarios')));\n\n    d2.on('.hp-sair', 'click', async () => {\n      d2.state.aberto = false;\n      try { await d2.auth(d2.param('auth')).logout(); } catch (e) { d2.log('logout falhou', e); }\n      if (d2.context.isBuilder) { d2.log('builder: reload suprimido apos logout'); return; }\n      try { window.location.assign('/'); } catch (e2) { d2.log(e2); }\n    });\n\n    // Fecha ao clicar fora. O clique dentro do shadow root aparece no composedPath com d2.root,\n    // entao o proprio toggle nao se fecha sozinho.\n    d2.on(document, 'click', (e) => {\n      if (!d2.state.aberto) return;\n      const caminho = e.composedPath ? e.composedPath() : [];\n      if (caminho.indexOf(d2.root) < 0) d2.state.aberto = false;\n    });\n\n    d2.on(document, 'keydown', (e) => {\n      if (e.key === 'Escape' && d2.state.aberto) d2.state.aberto = false;\n    });\n\n    // d2.bus nao existe em todos os builds; sem ele o header so recarrega ao abrir a pagina.\n    try { if (d2.bus && d2.bus.on) d2.bus.on('sec:perfil-mudou', carregar); } catch (e) { d2.log(e); }\n\n    // (sonda de scroll removida em 18/09 depois de medir: nenhum container rola)\n\n    // CORRECAO DO SCROLL. Medido no player (sonda acima): o container da artboard e um DIV com\n    // overflow-y:auto de altura 911 e conteudo 1822, e seus filhos sao DOIS DIVs position:static\n    // de 911px cada — um por bloco-raiz. O player renderiza cada bloco-raiz como uma linha de\n    // altura 100% no fluxo, ignorando anchors, `fixed` e dimensoes (o bloco esta em 1x1 e mesmo\n    // assim o wrapper mede 911). Logo um segundo bloco-raiz e sempre uma segunda tela.\n    // Como todo o conteudo deste componente e position:fixed (`.hp`), o wrapper do NOSSO bloco\n    // nao precisa de altura nenhuma: zeramos so ele. O wrapper do mapa nao e tocado. No builder\n    // os blocos-raiz sao absolutos e a busca nao encontra container com scroll: vira no-op.\n    const subir = (el) => {\n      if (!el) return null;\n      if (el.parentElement) return el.parentElement;\n      const rn = el.getRootNode ? el.getRootNode() : null;\n      return (rn && rn.host) ? rn.host : null;\n    };\n    const zerarWrapper = () => {\n      try {\n        let el = d2.root;\n        let passos = 0;\n        while (el && passos < 8) {\n          const pai = subir(el);\n          if (!pai || pai === document.documentElement) return;\n          const ov = getComputedStyle(pai).overflowY;\n          if ((ov === 'auto' || ov === 'scroll') && pai.clientHeight > 100 && pai.children.length > 1) {\n            if (getComputedStyle(el).position === 'static' && el.offsetHeight > 100) {\n              el.style.height = '0px';\n              el.style.minHeight = '0px';\n              el.style.overflow = 'visible';\n              el.setAttribute('data-sec-header-wrapper', 'zerado');\n              console.log('[SEC diag] wrapper do header zerado (era ' + el.offsetHeight + 'px)');\n            }\n            return;\n          }\n          el = pai; passos++;\n        }\n      } catch (e) { d2.log(e); }\n    };\n    zerarWrapper();\n    setTimeout(zerarWrapper, 1500);\n    setTimeout(zerarWrapper, 5000);\n\n    carregar();\n  }\n}"}},"auth":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"auth"}}}},"rMe":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rMe"}}}},"roteador":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"roteador"}}}},"rotaPerfil":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rotaPerfil"}}}},"rotaUsuarios":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"rotaUsuarios"}}}}}}}}}},{"_id":"5ed9bed7b9e0891ce913e884","component":"data2-core-types@Component","params":{"package":"data2-core","component":"DataSource","renderable":{"dimensions":{"disabled":false,"component":"data2-core-types@Dimensions","params":{"width":1366,"exactWidth":false,"exactHeight":false,"height":768}}}}},{"_id":"5f472ae463c680de4ff296e9","component":"data2-core-types@Component","params":{"package":"data2-core","component":"Data2TableDataSource","extends":"data2-core@DataSource","renderable":{"dimensions":{"disabled":false,"component":"data2-core-types@Dimensions","params":{"width":1366,"exactWidth":false,"exactHeight":false,"height":768}}}}}]}},{"reference":"ExternalComponentInstancesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"_id":"5f060d9b1ca96def42f3c40a","displayName":"Builder AuthStrategy","component":"data2-core@AuthenticationStrategy","system":"5e3b796d6228227ca637d0f4","params":{"credentials":[{"component":"data2-core-types@DataSetColumnReference","params":{"column":"email"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"password"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"status"}}],"verifier":{"component":"data2-core-types@DataSetReference","params":{"reference":"UsersDataSet"}},"sessionShared":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"password":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"password"}},"email":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"email"}},"resetPassword":{"resetEmail":{"artboard":{"params":{"preventEvaluation":true,"reference":"nR6Q9d7qdaPtZhJ28Q8Dy"},"component":"data2-core-types@ArtboardReference"}},"resetSuccessEmail":{"artboard":{"params":{"preventEvaluation":true,"reference":"yPCJBTKo8q1Mw0B_3Kpwl"},"component":"data2-core-types@ArtboardReference"}}},"session":[{"component":"data2-core-types@DataSetColumnReference","params":{"column":"name"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"email"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"password"}},{"component":"data2-core-types@DataSetColumnReference","params":{"column":"status"}}],"sessionMetadata":{"params":{"actions":[{"params":{"compare":{"params":{"subject":{"params":{"components":[{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user._id"}}}},{"params":{"comparisonSubject":{"component":"data2-core-primitives@String","params":{"content":"active"}},"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user.status"}}}}},"component":"data2-core-comparison-operators@Equal","displayName":"is active user"}]},"component":"data2-core-logical-operators@And","displayName":"is active and has _id"}},"component":"data2-core-logical-operators@Not"},"then":{"params":{"message":{"component":"data2-core-primitives@String","params":{"content":"Invalid Credentials"}},"code":{"component":"data2-core-primitives@String","params":{"content":"401"}}},"component":"data2-core-types@Error"}},"component":"data2-core-flow@If","displayName":"check $user"},{"params":{"action":{"params":{"pattern":{"component":"data2-core-primitives@String","params":{"content":"@data2.app"}},"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user.email"}}}}},"component":"data2-core-functions@Includes","displayName":"isData2User"},"resultName":{"component":"data2-core-primitives@String","params":{"content":"is_data2_user"}}},"component":"data2-core-flow@Step"},{"params":{"action":{"params":{"pattern":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.d2_app"}}}},"subject":[{"component":"data2-core-primitives@String","params":{"content":"management"}}]},"component":"data2-core-functions@Includes","displayName":"isData2App Exception"},"resultName":{"component":"data2-core-primitives@String","params":{"content":"is_data2_app_exception"}}},"component":"data2-core-flow@Step"},{"params":{"resultName":{"component":"data2-core-primitives@String","params":{"content":"has_access"}},"action":{"params":{"compare":{"params":{"components":[{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"is_data2_user"}}}},{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"is_data2_app_exception"}}}}]},"component":"data2-core-logical-operators@Or"},"else":{"params":{"actions":[{"params":{"actions":[{"params":{"compare":{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.system"}}}}},"component":"data2-core-logical-operators@Not"},"then":{"params":{"message":{"component":"data2-core-primitives@String","params":{"content":"Invalid Credentials"}},"code":{"component":"data2-core-primitives@String","params":{"content":"401"}}},"component":"data2-core-types@Error"}},"component":"data2-core-flow@If","displayName":"check $metadata.system"},{"params":{"module":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"WuUa-VKHhW9uYU0E3-mJ8"}},"prop":"filter","args":[{"_id":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.system"}}}}}]},"component":"data2-core-types@Interaction"},{"params":{"compare":{"params":{"subject":{"component":"data2-core-types@Getter","params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"WuUa-VKHhW9uYU0E3-mJ8"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records.length"}}}}},"component":"data2-core-logical-operators@Not","displayName":"systems not found"},"then":{"params":{"message":{"component":"data2-core-primitives@String","params":{"content":"Invalid Credentials"}},"code":{"component":"data2-core-primitives@String","params":{"content":"401"}}},"component":"data2-core-types@Error"}},"component":"data2-core-flow@If","displayName":"check system found"},{"params":{"actions":[{"params":{"module":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":"filter","args":[{"user":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user._id"}}}},"organization":{"component":"data2-core-types@Getter","params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"WuUa-VKHhW9uYU0E3-mJ8"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"cursor.current.organization._id"}}}}}]},"component":"data2-core-types@Interaction"},{"params":{"module":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"NqKAY34n2ffFL64zamp7t"}},"prop":"filter","args":[{"user":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$user._id"}}}},"system":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.system"}}}}}]},"component":"data2-core-types@Interaction"}]},"component":"data2-core-flow@Parallel"}]},"component":"data2-core-flow@Series","displayName":"common setup"},{"params":{"then":{"params":{"content":true},"component":"data2-core-primitives@Boolean"},"else":{"params":{"content":false},"component":"data2-core-primitives@Boolean"},"compare":{"params":{"components":{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"WuUa-VKHhW9uYU0E3-mJ8"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"mapper":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"system"}}],"body":{"params":{"expression":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.visibility"}}}},"defaultCase":{"params":{"content":false},"component":"data2-core-primitives@Boolean"},"cases":[{"component":"data2-core-flow@Case","params":{"expression":{"component":"data2-core-primitives@String","params":{"content":"public"}},"value":{"params":{"components":[{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"member"}}],"body":{"params":{"components":[{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.organization._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.organization._id"}}}}},"component":"data2-core-comparison-operators@Equal","displayName":"is org member"},{"params":{"pattern":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.role"}}}},"subject":[{"component":"data2-core-primitives@String","params":{"content":"owner"}},{"component":"data2-core-primitives@String","params":{"content":"billing-owner"}},{"component":"data2-core-primitives@String","params":{"content":"admin"}},{"component":"data2-core-primitives@String","params":{"content":"member"}}]},"component":"data2-core-functions@Includes","displayName":"has role with access"}]},"component":"data2-core-logical-operators@And","displayName":"has org member role with access"}}}},"component":"data2-core-functions@ArrayFind","displayName":"is org member"},{"params":{"components":[{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"member"}}],"body":{"params":{"components":[{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.organization._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.organization._id"}}}}},"component":"data2-core-comparison-operators@Equal","displayName":"is org member"},{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.role"}}}},"comparisonSubject":{"component":"data2-core-primitives@String","params":{"content":"collaborator"}}},"component":"data2-core-comparison-operators@Equal","displayName":"is collaborator"}]},"component":"data2-core-logical-operators@And","displayName":"is org member and collaborator"}}}},"component":"data2-core-functions@ArrayFind","displayName":"is collaborator"},{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"NqKAY34n2ffFL64zamp7t"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"access"}}],"body":{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"access.system._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system._id"}}}}},"component":"data2-core-comparison-operators@Equal"}}}},"component":"data2-core-functions@ArrayFind","displayName":"has system access"}]},"component":"data2-core-logical-operators@And","displayName":"is collaborator and has system access"}]},"component":"data2-core-logical-operators@Or"}}},{"component":"data2-core-flow@Case","params":{"expression":{"component":"data2-core-primitives@String","params":{"content":"private"}},"value":{"params":{"components":[{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"member"}}],"body":{"params":{"components":[{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.organization._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.organization._id"}}}}},"component":"data2-core-comparison-operators@Equal","displayName":"is org member"},{"params":{"pattern":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.role"}}}},"subject":[{"component":"data2-core-primitives@String","params":{"content":"owner"}},{"component":"data2-core-primitives@String","params":{"content":"admin"}}]},"component":"data2-core-functions@Includes","displayName":"is owner/admin"}]},"component":"data2-core-logical-operators@And","displayName":"has org member role with access"}}}},"component":"data2-core-functions@ArrayFind","displayName":"is owner/admin"},{"params":{"components":[{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"Ok6Tsu5XkS7r8PyW_7TyW"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"member"}}],"body":{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"member.organization._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system.organization._id"}}}}},"component":"data2-core-comparison-operators@Equal"}}}},"component":"data2-core-functions@ArrayFind","displayName":"is org member"},{"params":{"array":{"params":{"module":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"NqKAY34n2ffFL64zamp7t"}},"prop":{"component":"data2-core-primitives@String","params":{"content":"data.records"}}},"component":"data2-core-types@Getter"},"filter":{"disabled":false,"component":"data2-core-functions@Function","params":{"parameters":[{"component":"data2-core-primitives@String","params":{"content":"access"}}],"body":{"params":{"subject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"access.system._id"}}}},"comparisonSubject":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"system._id"}}}}},"component":"data2-core-comparison-operators@Equal"}}}},"component":"data2-core-functions@ArrayFind","displayName":"has system access"}]},"component":"data2-core-logical-operators@And","displayName":"is member and has system access"}]},"component":"data2-core-logical-operators@Or","displayName":"is owner/admin or has system access"}}}]},"component":"data2-core-flow@Switch"}}}},"component":"data2-core-functions@ArrayMap"}},"component":"data2-core-logical-operators@And","displayName":"has org member for all systems in request"}},"component":"data2-core-flow@If"}]},"component":"data2-core-flow@Series"},"then":{"params":{"content":true},"component":"data2-core-primitives@Boolean"}},"component":"data2-core-flow@If"}},"component":"data2-core-flow@Step"},{"params":{"then":{"system":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"$metadata.system"}}}}},"else":{"params":{"message":{"component":"data2-core-primitives@String","params":{"content":"Invalid Credentials"}},"code":{"component":"data2-core-primitives@String","params":{"content":"401"}}},"component":"data2-core-types@Error"},"compare":{"component":"data2-core-types@Reference","params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"has_access"}}}}},"component":"data2-core-flow@If"}]},"component":"data2-core-flow@Series"},"sessionExpiresIn":{"component":"data2-core-primitives@Number","params":{"content":604800}},"sessionRenewal":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"identityProviders":[{"provider":{"component":"data2-core-primitives@String","params":{"content":"GOOGLE"}},"clientId":{"component":"data2-core-primitives@String","params":{"content":"55470861603-22pvfc1t49lp8cjnbfk71s5ct9hp1q1v.apps.googleusercontent.com"}}}]},"reference":"Data2BuilderAuthentication","updatedAt":"2026-08-06T19:31:23.557Z"},{"_id":"5f060ee51ca96def42f3fbd1","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"UsersTableDataSource"}},"pagination":{"size":{"component":"data2-core-primitives@Number","params":{"content":1}}}},"reference":"UsersDataSet","updatedAt":"2026-03-04T21:16:40.291Z"},{"_id":"68f938cf5fb002e33752a43d","displayName":"InternalApplicationsAuthenticationStrategy","component":"data2-core@AuthenticationStrategy","system":"5e3b796d6228227ca637d0f4","params":{"resetPassword":{"resetEmail":{"artboard":{"params":{"preventEvaluation":true},"component":"data2-core-types@ArtboardReference"}},"resetSuccessEmail":{"artboard":{"params":{"preventEvaluation":true},"component":"data2-core-types@ArtboardReference"}}},"verifier":{"disabled":false,"component":"data2-core-types@DataSetReference","params":{"reference":"InternalApplicationsVerifierDataSet"}},"password":{"disabled":false,"component":"data2-core-types@DataSetColumnReference","params":{"column":"secret"}},"credentials":[{"component":"data2-core-types@FieldReference","params":{"key":"application"}},{"component":"data2-core-types@FieldReference","params":{"key":"secret"}},{"component":"data2-core-types@FieldReference","params":{"key":"status"}}],"session":[{"component":"data2-core-types@FieldReference","params":{"key":"application"}},{"component":"data2-core-types@FieldReference","params":{"key":"secret"}},{"component":"data2-core-types@FieldReference","params":{"key":"status"}}]},"reference":"InternalApplicationsAuthenticationStrategy","updatedAt":"2025-10-22T20:08:54.442Z","createdAt":"2025-10-22T20:04:31.198Z"},{"component":"data2-core@DataSet","reference":"SessionsDataSet","params":{"requiresConfirmation":false,"pk":"key","datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"data2sessionsdatasource"}}}},{"reference":"UsersTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}},{"component":"data2-core@ServerSessionsDataSource","reference":"data2sessionsdatasource","params":{}},{"_id":"5f16172a0528b92601890f85","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"pagination":{"size":{"component":"data2-core-primitives@Number","params":{"content":10000}}},"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"DataComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"DataComponentInstancesDataSet","updatedAt":"2025-11-06T21:07:12.831Z","createdAt":"2020-07-20T22:14:02.097Z"},{"reference":"DataComponentInstancesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"6a74ae2663667431f75e45b2","component":"data2-core@AuthenticationStrategy","system":"6a57cfe9673eb7e3c5e57784","params":{"verifier":{"component":"data2-core-types@DataSetReference","params":{"reference":"AEKK_Z4OsZ2njfQxg7tS6"}},"credentials":[{"component":"data2-core-types@FieldReference","params":{"key":"email"}},{"component":"data2-core-types@FieldReference","params":{"key":"password"}}],"email":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"email"}},"password":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"password"}},"role":"role","roleDefault":"viewer"},"reference":"timelineSimAuthFinal"},{"_id":"6a74b5a363667431f75e53d9","component":"data2-core@DataSet","system":"6a57cfe9673eb7e3c5e57784","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"timeline-shared-sessions"}}},"reference":"timeline-shared-sessionsDataSet"},{"_id":"6a73b4fe95b7c25ed7cf54ee","component":"data2-core@DataSet","system":"6a57cfe9673eb7e3c5e57784","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"geo-timeline-sessions"}}},"reference":"geo-timeline-sessionsDataSet"},{"_id":"6a888cd8696364a2cbd09d2d","component":"data2-core@DataSet","system":"6a57cfe9673eb7e3c5e57784","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"gb9Mu-5_9AcCDK-myWwSy"}}},"reference":"gb9Mu-5_9AcCDK-myWwSyDataSet","deleteMetadata":{}},{"_id":"6a74bb3c32c4b185b93738ce","component":"data2-core@DataSet","system":"6a57cfe9673eb7e3c5e57784","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"timeline-sim-users"}},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"pagination":{"size":20}},"reference":"AEKK_Z4OsZ2njfQxg7tS6"},{"_id":"6a74b5a3a3c269c78fe7bf7c","component":"data2-core@Data2TableDataSource","system":"6a57cfe9673eb7e3c5e57784","params":{"table":"timeline-shared-sessions","schema":[{"component":"data2-core-types@Field","params":{"key":"label","title":"Nome da sessao","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"snapshotRaw","title":"Snapshot congelado","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"settings","title":"Regras da sessao","component":"data2-core-primitives@Object"}},{"component":"data2-core-types@Field","params":{"key":"stages","title":"Etapas (display model)","component":"data2-core-primitives@Array"}},{"component":"data2-core-types@Field","params":{"key":"summary","title":"Resumo","component":"data2-core-primitives@Object"}},{"component":"data2-core-types@Field","params":{"key":"sharedBy","title":"Compartilhado por","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"sharedAt","title":"Compartilhado em","component":"data2-core-primitives@Date"}},{"component":"data2-core-types@Field","params":{"key":"snapLiteral","title":"Snapshot (SQL literal)","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"eloMaxCycle","title":"Ciclo maximo ELO","component":"data2-core-primitives@Number"}},{"component":"data2-core-types@Field","params":{"key":"movements","title":"Movimentos","component":"data2-core-primitives@Array"}},{"component":"data2-core-types@Field","params":{"key":"createdAt","title":"Criado em","component":"data2-core-primitives@Date"}},{"component":"data2-core-types@Field","params":{"key":"updatedAt","title":"Atualizado em","component":"data2-core-primitives@Date"}}]},"reference":"timeline-shared-sessions"},{"_id":"6a73b4fd1204b3d36b62e37e","component":"data2-core@Data2TableDataSource","system":"6a57cfe9673eb7e3c5e57784","params":{"table":"geo-timeline-sessions","schema":[{"component":"data2-core-types@Field","params":{"key":"label","title":"Nome","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"snapshotRaw","title":"Snapshot (raw)","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"snapLiteral","title":"Snapshot (SQL literal)","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"eloMaxCycle","title":"Ciclo maximo ELO","component":"data2-core-primitives@Number"}},{"component":"data2-core-types@Field","params":{"key":"movements","title":"Movimentos","component":"data2-core-primitives@Array"}},{"component":"data2-core-types@Field","params":{"key":"createdAt","title":"Criado em","component":"data2-core-primitives@Date"}},{"component":"data2-core-types@Field","params":{"key":"updatedAt","title":"Atualizado em","component":"data2-core-primitives@Date"}},{"component":"data2-core-types@Field","params":{"key":"settings","title":"Configuracoes da sessao","component":"data2-core-primitives@Object"}},{"component":"data2-core-types@Field","params":{"key":"snapshotSyncedAt","title":"Snapshot sincronizado em","component":"data2-core-primitives@String"}}]},"reference":"geo-timeline-sessions"},{"_id":"6a888cd8c0d30aec2da3df33","component":"data2-core@Data2TableDataSource","system":"6a57cfe9673eb7e3c5e57784","params":{"table":"gb9Mu-5_9AcCDK-myWwSy","schema":[{"component":"data2-core-types@Field","params":{"key":"de","title":"Grafia encontrada (mapa/IBGE)","component":"data2-core-primitives@String","required":true}},{"component":"data2-core-types@Field","params":{"key":"para","title":"Grafia da base (CRM/dominantes)","component":"data2-core-primitives@String","required":true}},{"component":"data2-core-types@Field","params":{"key":"observacao","title":"Observação","component":"data2-core-primitives@String"}}]},"reference":"gb9Mu-5_9AcCDK-myWwSy","deleteMetadata":{}},{"_id":"6a74ae1b8bf67595bba6dcfb","component":"data2-core@Data2TableDataSource","system":"6a57cfe9673eb7e3c5e57784","params":{"table":"timeline-sim-users","schema":[{"component":"data2-core-types@Field","params":{"key":"nome","title":"Nome","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"email","title":"email","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"password","title":"senha","component":"data2-core-types@Password"}},{"component":"data2-core-types@Field","params":{"key":"role","title":"Perfil","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"ativo","title":"Ativo","component":"data2-core-primitives@Boolean"}},{"component":"data2-core-types@Field","params":{"key":"criadoEm","title":"Criado em","component":"data2-core-primitives@Date"}},{"component":"data2-core-types@Field","params":{"key":"criadoPor","title":"Criado por","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"atualizadoEm","title":"Atualizado em","component":"data2-core-primitives@Date"}},{"component":"data2-core-types@Field","params":{"key":"atualizadoPor","title":"Atualizado por","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"ultimoAcesso","title":"Último acesso","component":"data2-core-primitives@Date"}},{"component":"data2-core-types@Field","params":{"key":"observacao","title":"Observação","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"roleAnterior","title":"Perfil anterior (antes de desativar)","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"fotoUrl","title":"Foto de perfil","component":"data2-core-primitives@String"}}]},"reference":"timeline-sim-users"}]}},{"_id":"5f1600b70528b92601890f75","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ServiceComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"ServiceComponentInstancesDataSet","updatedAt":"2025-11-06T21:07:20.208Z","createdAt":"2020-07-20T20:38:15.436Z"},{"_id":"5f1602190528b92601890f7d","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"pagination":{"size":40000},"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ComponentInstancesTableDataSource"}},"sort":{"_id":1},"pk":"_id","autoEdit":true,"master":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}},"policies":{"params":{"allowGet":{"component":"data2-core-primitives@Boolean","params":{"content":true}}},"component":"data2@hkXy-UhMjhyfptdE6_fpW"}},"reference":"ComponentInstancesDataSet","updatedAt":"2025-11-06T21:07:07.089Z","createdAt":"2020-07-20T20:44:09.350Z"},{"_id":"5f16017f0528b92601890f7a","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"FileComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"filter":{"domain":{"component":"data2-core-types@Reference","params":{"reference":"clientApplicationDomain"}}},"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}}},"reference":"FileComponentInstancesDataSet","updatedAt":"2023-08-03T16:08:18.193Z","createdAt":"2020-07-20T20:41:35.797Z"},{"_id":"5f1601990528b92601890f7b","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"ApplicationsTableDataSource"}},"pk":"_id","autoEdit":{"component":"data2-core-primitives@Boolean","params":{"content":true}},"master":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"params":{"column":"_id"},"component":"data2-core-types@DataSetColumnReference"}},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}}},"reference":"ApplicationsDataSet","updatedAt":"2022-12-13T21:51:13.995Z","createdAt":"2020-07-20T20:42:01.657Z"},{"_id":"60ddc1bbda3861ca65407c5a","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"FileComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"filter":{"domain":{"component":"data2-core-types@Reference","params":{"reference":"clientApplicationDomain"}}},"pagination":{"size":1},"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"}},"reference":"OneFileComponentInstancesDataSet","updatedAt":"2022-02-09T19:44:38.036Z","createdAt":"2020-07-20T20:41:35.797Z"},{"_id":"63ee3d38b8c9838c62fb05fb","displayName":"DriveFileComponentInstancesDataSet","component":"data2-core@DataSet","system":"5e3b796d6228227ca637d0f4","params":{"datasource":{"component":"data2-core-types@DataSourceReference","params":{"reference":"FileComponentInstancesTableDataSource"}},"pk":"_id","autoEdit":true,"filter":{"component":{"component":"data2-core-primitives@String","params":{"content":"data2-core@Drive"}}},"master":{"params":{"dataset":{"component":"data2-core-types@DataSetReference","params":{"reference":"SystemsDataSet"}},"localField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"system"}},"masterField":{"component":"data2-core-types@DataSetColumnReference","params":{"column":"_id"}}},"component":"data2-core-types@DataSetMaster"},"autoSelect":{"component":"data2-core-primitives@Boolean","params":{"content":false}}},"reference":"DriveFileComponentInstancesDataSet","updatedAt":"2025-12-04T21:58:32.367Z","createdAt":"2023-02-16T14:27:07.797Z"},{"reference":"ServiceComponentInstancesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"6a7240abab11eee26d640c2e","component":"data2-rush@HTTPListener","reference":"AwW_11Nkg-dKksP7ic8V8","system":"6a57cfe9673eb7e3c5e57784","params":{},"versioning":"enabled","bundleId":null},{"_id":"6a875126f4280e4792668bb3","component":"data2-rush@HTTPListener","reference":"osmOverpassProxy","system":"6a57cfe9673eb7e3c5e57784","params":{},"versioning":"enabled","bundleId":null,"deleteMetadata":{}}]}},{"reference":"ComponentInstancesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"6a74acf3dad5912765097ccb","component":"data2-core-primitives@Router","system":"6a57cfe9673eb7e3c5e57784","displayName":"TimelineSimulator-Router","params":{"routes":[{"component":"data2-core-primitives@Route","params":{"path":"/","name":"Timeline de Fechamentos","content":{"component":"data2-core-types@ArtboardReference","params":{"reference":"kYmaNnWZ5NlRgXfOSuhwp"}},"policies":[{"component":"data2-core-auth@Policy","params":{"authenticationStrategy":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"timelineSimAuthFinal"}}}}]}},{"component":"data2-core-primitives@Route","params":{"path":"/sessions/:session_id","name":"Sessão compartilhada","content":{"component":"data2-core-types@ArtboardReference","params":{"reference":"__C6d2F-yqwEkSNWNK9IH"}},"policies":[{"component":"data2-core-auth@Policy","params":{"authenticationStrategy":{"disabled":false,"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"timelineSimAuthFinal"}}}}]}}],"login":{"component":"data2-core-types@ArtboardReference","params":{"reference":"OjHwaLdtsQ130Tg6z4N_t"}}},"reference":"timelineSimRouter"},{"_id":"6a5f9e6da5529ef885ca9698","parent":"6a5f9e6da5529ef885ca9697","component":"data2-core-primitives@Block","system":"6a57cfe9673eb7e3c5e57784","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{"leftTop":"9"},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":180,"height":40,"exactWidth":179.8,"exactHeight":40,"growWidthWithContent":true}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":false,"x":0,"y":0,"exactX":0,"exactY":0,"fixed":false,"left":true}},"textFormat":{"disabled":false,"component":"data2-core-types@TextFormat","params":{"textOverflow":false,"verticalAlignment":"middle"}},"text":{"params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"label"}}},"component":"data2-core-types@Reference"},"style":{"padding":{"component":"data2-core-primitives@String","params":{"content":"20px"}}},"fill":{"disabled":false,"component":"data2-core-types@Fill","params":{"backgrounds":[{"params":{"color":{"disabled":false,"component":"data2-core-types@Color","params":{"rgba":"rgba(0,0,0,0.1)"}},"gradient":{"disabled":true,"component":"data2-core-types@ColorGradient","params":{}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]}},"onClick":{"params":{"reference":{"component":"data2-core-primitives@String","params":{"content":"onClick"}}},"component":"data2-core-types@Reference"},"transitions":[{"component":"data2-core-types@Transition","params":{"scope":"ALL","duration":"220","easing":"cubic-bezier(0,-0.0016249999999999876,0.3684210526315789,0.830375)"}}],"cursor":"pointer"},"reference":"label"},{"_id":"6ab3be1f3d7ee7988ad764bd","component":"data2-core-primitives@Artboard","system":"6a57cfe9673eb7e3c5e57784","displayName":"placeholder","params":{"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":1366,"height":768}},"fill":{"params":{"backgrounds":[{"params":{"color":{"disabled":false,"component":"data2-core-types@Color","params":{"rgba":"rgba(255,255,255,1)"}},"gradient":{"disabled":true,"component":"data2-core-types@ColorGradient","params":{}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"}},"reference":"vftXwRlx5WkgWped0tx3f","deleteMetadata":{}},{"_id":"6a74aea863667431f75e46f0","component":"data2-core-primitives@Artboard","system":"6a57cfe9673eb7e3c5e57784","displayName":"login","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"login"}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":1366,"height":768}},"position":{"component":"data2-core-types@Position","params":{"x":0,"y":0}}},"reference":"OjHwaLdtsQ130Tg6z4N_t"},{"_id":"6a74b63815209eb021fcad40","component":"data2-core-primitives@Artboard","system":"6a57cfe9673eb7e3c5e57784","displayName":"sessao compartilhada","params":{"title":{"component":"data2-core-primitives@String","params":{"content":"sessao compartilhada"}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":1366,"height":900}},"position":{"component":"data2-core-types@Position","params":{"x":0,"y":0}}},"reference":"__C6d2F-yqwEkSNWNK9IH"},{"_id":"6a74abe6468fc70c2ef50103","component":"data2-core-primitives@Artboard","system":"6a57cfe9673eb7e3c5e57784","displayName":"main","params":{"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":1366,"height":768}},"fill":{"params":{"backgrounds":[{"params":{"color":{"disabled":false,"component":"data2-core-types@Color","params":{"rgba":"rgba(255,255,255,1)"}},"gradient":{"disabled":true,"component":"data2-core-types@ColorGradient","params":{}},"image":{"disabled":true,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"}},"reference":"kYmaNnWZ5NlRgXfOSuhwp"},{"_id":"6ab3be213d7ee7988ad764be","parent":"6ab3be1f3d7ee7988ad764bd","component":"data2-core-primitives@Block","system":"6a57cfe9673eb7e3c5e57784","params":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"fill":{"params":{"backgrounds":[{"params":{"color":{"component":"data2-core-types@Color","params":{"rgba":"rgba(0,120,220,0.3)"},"disabled":true},"gradient":{"disabled":true,"component":"data2-core-types@ColorGradient","params":{}},"image":{"disabled":false,"component":"data2-core-types@String","params":{}}},"component":"data2-core-types@Background"}]},"component":"data2-core-types@Fill"},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":302,"height":99,"exactWidth":302.45}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":532,"y":335,"exactX":false,"exactY":false}},"text":"simulador-geomarketing","textFormat":{"disabled":false,"component":"data2-core-types@TextFormat","params":{"horizontalAlignment":"center","textOverflow":false,"verticalAlignment":"middle"}}},"deleteMetadata":{}},{"_id":"edee90b922e5038ec1d1c76c","parent":"6a74aea863667431f75e46f0","component":"natura-geomarketing@timeline-sim-login","system":"6a57cfe9673eb7e3c5e57784","displayName":"Login form instance","params":{"super":{"position":{"component":"data2-core-types@Position","params":{"x":0,"y":0,"freeTransform":true,"left":true,"right":true,"top":true,"bottom":true,"exactX":false,"exactY":false}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":1366,"height":768,"exactWidth":false,"exactHeight":false}}}},"reference":"Y87XaCN1xFnP4It1hGn6u"},{"_id":"2544724b6e0a08f3b8b81464","parent":"6a74b63815209eb021fcad40","component":"natura-geomarketing@timeline_sim_session_read","system":"6a57cfe9673eb7e3c5e57784","displayName":"Session read instance","params":{"shared":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"timeline-shared-sessionsDataSet"}},"super":{"position":{"component":"data2-core-types@Position","params":{"x":0,"y":0,"freeTransform":true,"left":true,"right":true,"top":true,"bottom":true}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":1366,"height":900}}}},"reference":"2xQ6i0KYfzxTWFEJPJCv6"},{"_id":"6a7a4288aaf288895983339d","parent":"6a74abe6468fc70c2ef50103","component":"natura-geomarketing@xoLKR_Mb_n2vkwfmdExLH","system":"6a57cfe9673eb7e3c5e57784","params":{"super":{"border":{"component":"data2-core-types@Border","params":{"color":"#808080","radius":{},"width":{"left":"0","top":"0","right":"0","bottom":"0"}}},"dimensions":{"component":"data2-core-types@Dimensions","params":{"width":1366,"height":768,"exactWidth":false,"exactHeight":false}},"position":{"component":"data2-core-types@Position","params":{"freeTransform":true,"x":0,"y":0,"exactX":0,"exactY":0,"bottom":true,"left":true,"top":true,"growV":false,"right":true,"growH":false}}},"sessionsDataset":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"timeline-shared-sessionsDataSet"}},"sharedDataset":{"component":"data2-core-types@InteractiveModuleReference","params":{"reference":"timeline-shared-sessionsDataSet"}},"dominantesCsvUrl":{"component":"data2-core-types@FileComponentUrlReference","params":{"reference":"yw_TQdp0QXEk6JsrliAqP"}}}}]}},{"_id":"5fc4faeec8da12be8bf3d08a","component":"data2-core@Data2TableDataSource","system":"5e3b796d6228227ca637d0f4","params":{"paginate":{"default":1000},"super":{},"table":"fileComponentInstances","schema":[{"component":"data2-core-types@Field","params":{"key":"component","title":"Component","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"system","title":"System","component":"data2-core-primitives@ObjectId"}},{"component":"data2-core-types@Field","params":{"key":"displayName","title":"Display Name","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":"params","title":"Params","component":"data2-core-primitives@Object"}},{"component":"data2-core-types@Field","params":{"key":"reference","title":"Reference","component":"data2-core-primitives@String"}},{"component":"data2-core-types@Field","params":{"key":{"component":"data2-core-primitives@String","params":{"content":"fileId"}},"title":"File ID","component":"data2-core-primitives@ObjectId"},"break":true}],"hooks":{"before":{"all":[{"hook":"softDelete"}],"find":[{"hook":"useCache","options":{"additionalGroupKeys":["$and.0.system","$and.1.system"],"skipCache":true}}],"create":[{"hook":"timestamp","options":{"fields":["createdAt","updatedAt"]}}],"update":[{"hook":"timestamp","options":{"fields":["updatedAt"]}}],"patch":[{"hook":"timestamp","options":{"fields":["updatedAt"]}}]},"after":{"all":[{"hook":"fileUrl","options":{"downloadField":"params.fileUrl","uploadField":"params.uploadFileUrl","enhanceSchema":false}}],"find":[{"hook":"useCache","options":{"additionalGroupKeys":["$and.0.system","$and.1.system"],"skipCache":true}}],"create":[{"hook":"invalidateCache","options":{"additionalGroupKeys":["system"],"secondaryUniqueKeys":["reference"]}},{"hook":"invalidateEnvCache"}],"update":[{"hook":"invalidateCache","options":{"additionalGroupKeys":["system"],"secondaryUniqueKeys":["reference"]}},{"hook":"invalidateEnvCache"}],"patch":[{"hook":"invalidateCache","options":{"additionalGroupKeys":["system"],"secondaryUniqueKeys":["reference"]}},{"hook":"invalidateEnvCache"}],"remove":[{"hook":"invalidateCache","options":{"additionalGroupKeys":["system"],"secondaryUniqueKeys":["reference"]}},{"hook":"invalidateEnvCache"}]}},"multi":["create"],"useEstimatedDocumentCount":true},"reference":"FileComponentInstancesTableDataSource","updatedAt":"2022-12-19T20:42:51.837Z","createdAt":"2020-11-30T13:59:52.294Z"},{"reference":"ApplicationsTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[{"_id":"6ab3be5f3d7ee7988ad764bf","displayName":"app-placeholder","reference":"LDpOkSFqccBBNdMWQvIyB","params":{"domain":{"component":"data2-core-primitives@String","params":{"content":"app"}},"entryPoint":{"disabled":false,"component":"data2-core-types@ComponentInstanceReference","params":{"component":"data2-core-primitives@Artboard","reference":"vftXwRlx5WkgWped0tx3f"}}},"system":"6a57cfe9673eb7e3c5e57784","component":"data2-core-primitives@Application"}]}},{"reference":"BundlesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}}],"processesParams":{}}}}