{"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":"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}"}}}}}},"__d2PrevCode":{"code":"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    // The direct POST sets the session COOKIE; the in-memory runtime never saw it, so a HARD\n    // navigation is required here — d2.router().goTo() would keep the stale client state and the\n    // route policy would reject again. In the builder there is no app root to go to, so we just\n    // report success instead of navigating out of /preview/<ref>.\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}","at":"2026-08-06T19:07:41.976Z"}}},{"_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"}}}}}}}},"__d2PrevCode":{"code":"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\" 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\" 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    };\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}","at":"2026-08-13T14:21:53.099Z"}}},{"_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"}}}}}}}},"__d2PrevCode":{"code":"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}\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","at":"2026-08-13T14:21:29.778Z"}}},{"_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":"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":"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"},{"_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":[]}},{"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":"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":"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":"6a74ad00a3c269c78fe7ada7","displayName":"TimelineSimulator-App","reference":"3TIqIMsYCKjuQcyffDB2C","params":{"domain":{"component":"data2-core-primitives@String","params":{"content":"timeline"}},"entryPoint":{"component":"data2-core-types@ComponentInstanceReference","params":{"component":"data2-core-primitives@Router","reference":"timelineSimRouter"}}},"system":"6a57cfe9673eb7e3c5e57784","component":"data2-core-primitives@Application"}]}},{"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"}}},"reference":"timelineSimAuthFinal"},{"_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":"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"}}]},"reference":"timeline-sim-users"},{"_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":"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"}]}},{"reference":"BundlesTableDataSource","component":"data2-core@MemoryDataSource","params":{"initialData":[]}}],"processesParams":{}}}}